Eloquent JavaScript

Marijn Haverbeke

← Back to blog

Review

Eloquent JavaScript is a book that teaches you the fundamentals of programming through JavaScript. From my five minutes of research online, it seemed to the be popular beginner resource, with other recommendations being The Modern JavaScript Tutorial, JavaScript: The Definitive Guide and the You Don’t Know JS Yet book series.

Overall, I really enjoyed the text and the exercises. The level of difficulty scales up pretty quickly so I wouldn’t recommend this book as a first introduction to programming but if you have learned another language it should be manageable. The projects were a lot of fun and satisfying to implement.

JavaScript has an interesting history, given how it was rapidly hacked together by Brendan Eich and how the web has evolved since then. Some of the design decisions are questionable and very confusing as a beginner but actually make some sense once you understand them. Before reading Eloquent JavaScript, I was working my way through SICPOne of the great classics of computer science and you can really see the inspiration that JavaScript took from Scheme.

Below are my solutions to the projects and the accompanying exercises from the text.

Project 1: Path-finding robot

The first project of the book is to build a mail-delivery robot that picks up and drops off parcels. Immediately, we are exposed to graphs and path-finding algorithmsThese are not the focus of the project but are cool to see, and get to try solve an interesting problem.

Try out the robot here!

Measuring a robot

To objectively compare robots, we want to make sure each time we generate a task, we give that task to both robots and track the result.

function compareRobots(robot1, memory1, robot2, memory2) {
  let steps1 = 0,
    steps2 = 0;
  for (let task = 0; task < 100; task++) {
    let state = VillageState.random();
    steps1 += countRobot(state, robot1, memory1);
    steps2 += countRobot(state, robot2, memory2);
  }

  console.log(`robot1: ${steps1 / 100}, robot2: ${steps2 / 100}`);
}

Robot efficiency

The trick to designing a more efficient robot than the goalOrientedRobot is to realise that the goalOrientedRobot only looks at one parcel at a time. In some cases, the robot would be able to find a better route if it considered all parcels. This was fun to play around with, although I wished I could pause the animation to think about what I would do as my next move if I were the robotI’ve built this into my version of the robot animation.

function greedyRobot({ place, parcels }, route) {
  if (route.length == 0) {
    let min_route = [];

    for (let parcel of parcels) {
      if (parcel.place != place) {
        route = findRoute(roadGraph, place, parcel.place);
      } else {
        route = findRoute(roadGraph, place, parcel.address);
      }

      if (min_route.length == 0 || route.length < min_route.length) {
        min_route = route;
      }
    }

    route = min_route;
  }

  return { direction: route[0], memory: route.slice(1) };
}

Project 2: Egg programming language

In this project we build our own simple programming language called Egg:

do(define(x, 10),
   if(>(x, 5),
      print("large"),
      print("small")))

# Console: large

Egg is a tiny, simple language with a simple, uniform syntax. Everything in Egg is an expression. An expression can be a value, an identifier, or an application. Applications are what is applied to a function or a special form like if or while.

Expressions of type “value” represent literal strings or numbers. Their value property contains the string or number value that they represent. Expressions of type “identifier” are used for names (variables or bindings). Such objects have a name property that holds the identifier’s name as a string. Finally, “apply” expressions represent applications. They have an operator property that refers to the expression that is being applied, as well as an args property that holds an array of argument expressions.

The >(x, 5) part of the previous program would be represented like this:

{
  type: "apply",
  operator: {type: "identifier", name: ">"},
  args: [
    {type: "identifier", name: "x"},
    {type: "value", value: 5}
  ]
}

To let us do interesting things in our language, we include a few special forms and useful bindings in the global scope:

Try out the Egg interpreter!

Arrays

We can implement arrays in Egg by adding the functions array, length, and element to the global scope. Respectively, they initialise an array, compute its length, and retrieve an element by index. Like with if and while, we cheat and use JavaScript’s arrays in our implementation.

globalScope.array = (...values) => {
  return [...values];
};

globalScope.length = (array) => {
  return array.length;
};

globalScope.element = (array, n) => {
  return array[n];
};

Comments

Allowing comments in Egg is a matter of changing the parser so it skips the rest of the line when it encounters a #.

function skipSpace(string) {
  let skippable = string.match(/^(\s|#.*)*/);
  return string.slice(skippable[0].length);
}

Fixing scope

Assigning a new value to a binding with define can be counterintuitive because it may create a new binding in the local scope instead of updating the desired binding. We can get around this by adding the set function which will search for the binding in outer scopes.

specialForms.set = (args, scope) => {
  if (args.length != 2 || args[0].type != "identifier") {
    throw new SyntaxError("Incorrect use of set");
  }

  let value = evaluate(args[1], scope);
  let name = args[0].name;

  for (let s = scope; s; s = Object.getPrototypeOf(s)) {
    if (Object.prototype.hasOwnProperty(s, name)) {
      s[name] = value;
      return value;
    }
  }

  throw new ReferenceError(`Could not find ${name} in any scope.`);
};

Project 3: Platformer game

This project uses the DOM as the rendering backend for a simple 2D platformer. Everything in the game is made of div elements with styling and positioning applied. You can actually get decent performance by adding and removing DOM elements, although working this way can feel a little clunkyA more idiomatic approach is to use the <canvas> element.

Try out the platformer game here!

Game over

We can keep track of the player’s lives by adding some logic to the function that starts the game.

async function runGame(plans, Display) {
  let lives = 3;
  for (let level = 0; level < plans.length; ) {
    console.log(`Lives: ${lives}`);
    let status = await runLevel(new Level(plans[level]), Display);
    if (status == "lost") {
      lives--;
      if (lives == 0) {
        console.log("You lost, restarting the game...");
        level = 0;
        lives = 3;
      }
    }
    if (status == "won") level++;
  }
  console.log("You've won!");
}

Pausing the game

Adding a way to pause/unpause the game is not immediately obvious since adding another key listener in the section where we listen to user input won’t work. The solution is to add the key listener for pausing higher up, putting that logic into the runLevel function.

function runLevel(level, Display) {
  let display = new Display(document.body, level);
  let state = State.start(level);
  let ending = 1;
  let running = "yes";

  return new Promise((resolve) => {
    function escHandler(event) {
      if (event.key != "Escape") return;
      event.preventDefault();
      if (running == "no") {
        console.log("Unpausing game");
        running = "yes";
        runAnimation(frame);
      } else if (running == "yes") {
        console.log("Pausing game");
        running = "pausing";
      } else {
        running = "yes";
      }
    }

    window.addEventListener("keyup", escHandler);

    let keys = trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp", "a", "d", "w"]);

    function frame(time) {
      if (running == "pausing") {
        console.log("Game paused");
        running = "no";
        return false;
      }

      state = state.update(time, keys);
      display.syncState(state);
      if (state.status == "playing") {
        return true;
      } else if (ending > 0) {
        ending -= time;
        return true;
      } else {
        display.clear();
        window.removeEventListener("keyup", escHandler);
        keys.unregister();
        resolve(state.status);
        return false;
      }
    }

    runAnimation(frame);
  });
}

A monster

Using the scaffolding we’ve set up for the player, adding an enemy isn’t too hard. The tricky part is determining whether the player jumped on the head of the monster. The simplest way to approach is to compare the y-coords of the player and the monster at the time of collision.

class Monster {
  constructor(pos, speed) {
    this.pos = pos;
    this.speed = speed;
  }

  update(time, state) {
    let newPos = this.pos.plus(this.speed.times(time));
    if (!state.level.touches(newPos, this.size, "wall")) {
      return new Monster(newPos, this.speed);
    } else {
      return new Monster(this.pos, this.speed.times(-1));
    }
  }

  collide(state) {
    let player = state.player;
    if (player.pos.y < this.pos.y) {
      let filtered = state.actors.filter((a) => a != this);
      return new State(state.level, filtered, "playing");
    }

    return state;
  }

  get type() {
    return "monster";
  }

  get size() {
    return new Vec(1.2, 2);
  }

  static create(pos) {
    return new Monster(pos.plus(new Vec(0, -1)), new Vec(2, 0));
  }
}

Exercise: Game of Life

Using HTML forms, it’s possible to write a basic version of the Game of Life. Each generation, a checkbox transitions according to the following rules:

  1. Underpopulation: any checked checkbox with less than two checked neighbours becomes unchecked
  2. Survival: any checked checkbox with two or three neighbours stays checked in the next generation
  3. Overpopulation: any checked checkbox with more than three checked neighbours becomes unchecked
  4. Reproduction: any unchecked checkbox with exactly three neighbours becomes checked

Technically, the Game of Life is meant to be played on an infinite grid. There are a few ways to address this, such as wrapping cells around to the other side of the grid in case they go off screen. However, I didn’t bother with this – try to imagine there is a sea of dead cells around the grid.

Try out the Game of Life!

Project 4: Pixel art editor

As discussed in the platformer project, another way to render graphics on the web is using <canvas> elements. The design of this application is the first time we use components, which logically distribute state throughout the interface. Properly dealing with the flow of data is important to avoid ending up with spaghetti code. An interesting problem was adding event listeners to work both with mouse events and touch events, each of which work slightly differently.

Try out the pixel art editor!

Keyboard bindings

We want to add keyboard shortcuts such that pressing ctrl- or cmd- and the first letter of the tool automatically switches to that tool. Fortunately, the way we’ve designed the app makes this easy to do – we just need to add an event listener that dispatches on the different keybindings.

class PixelEditor {
  constructor(state, config) {
    let { tools, controls, dispatch } = config;
    this.state = state;

    this.canvas = new PictureCanvas(
      state.picture,
      (pos) => {
        let tool = tools[this.state.tool];

        // Call the tool once
        let onMove = tool(pos, this.state, dispatch);
        // Pass along function in case we need to redraw
        if (onMove) return (pos) => onMove(pos, this.state);
      },
      () => dispatch({ commit: true })
    );

    this.controls = controls.map((Control) => new Control(state, config));

    const keyDown = (event) => {
      if (event.key == "z" && (event.ctrlKey || event.metaKey)) {
        dispatch({ revert: "undo" });
      } else if (event.key == "y" && (event.ctrlKey || event.metaKey)) {
        dispatch({ revert: "redo" });
      } else if (!event.ctrlKey && !event.metaKey && !event.altKey) {
        for (let tool in tools) {
          if (event.key == tool[0]) {
            dispatch({ tool });
            return;
          }
        }
      }
    };

    this.dom = elt(
      "div",
      { tabIndex: 0, onkeydown: (event) => keyDown(event) },
      this.canvas.dom,
      elt("br"),
      ...this.controls.reduce((a, c) => a.concat(" ", c.dom), [])
    );
  }
  ...
}

Efficient drawing

At the moment, each time we create a new state, we are redrawing all the pixels on the canvas which can be expensive. One way we can optimise this is to keep track of the previous state and then ’diff’ that against the new state and only draw the pixels that change.

function patchPicture(prev, curr, canvas, scale) {
  let cx = canvas.getContext("2d");

  for (let y = 0; y < prev.height; y++) {
    for (let x = 0; x < prev.width; x++) {
      if (prev.pixel(x, y) != curr.pixel(x, y)) {
        cx.fillStyle = curr.pixel(x, y);
        cx.fillRect(x * scale, y * scale, scale, scale);
      }
    }
  }
}

Circles

Figuring out how to draw circles was a lot of fun. I struggled for a bit getting circles which were mostly squares with individual pixels sticking out (that looked like nipples) but managed to fix this by taking the ceil of the distance when I was computing the radius. Below is what I found to give the best looking circles.

function circle(start, state, dispatch) {
  const dist = (x, y) => {
    let dx = x - start.x;
    let dy = y - start.y;
    return Math.sqrt(dx * dx + dy * dy);
  };

  function drawCircle(pos) {
    let radius = Math.ceil(dist(pos.x, pos.y));

    let xStart = Math.max(0, start.x - radius);
    let yStart = Math.max(0, start.y - radius);
    let xEnd = Math.min(start.x + radius, state.picture.width);
    let yEnd = Math.min(start.y + radius, state.picture.height);

    let drawn = [];
    for (let y = yStart; y <= yEnd; y++) {
      for (let x = xStart; x <= xEnd; x++) {
        let d = dist(x, y);
        if (d < radius) drawn.push({ x, y, colour: state.colour });
      }
    }

    dispatch({ picture: state.picture.draw(drawn) });
  }

  drawCircle(start);
  return drawCircle;
}

Proper lines

This was much more difficult than the previous exercises. I’ve heard of Bresenham’s line algorithm before and that seemed to be what was needed. Generalising the algorithm to work for all octants took some time but it was satisfying being able to draw quick lines without gaps.

function drawLine(start, pos, colour) {
  let dx = Math.abs(pos.x - start.x);
  let sx = start.x < pos.x ? 1 : -1;

  // Note the negative
  // In graphics, y++ means moving down
  let dy = -Math.abs(pos.y - start.y);
  let sy = start.y < pos.y ? 1 : -1;

  let err = dx + dy;

  let drawn = [];
  for (let x = start.x, y = start.y; ; ) {
    drawn.push({ x, y, colour });
    if (x == pos.x && y == pos.y) break;
    let err2 = 2 * err; // avoid dividing
    if (err2 >= dy) {
      if (x == pos.x) break;
      err += dy;
      x += sx;
    }
    if (err2 <= dx) {
      if (y == pos.y) break;
      err += dx;
      y += sy;
    }
  }

  return drawn;
}

Project 5: Skill-sharing website

This project was most like a real web app. In short, it allows users to add talks (for some kind of meetup) and comments on those talks. Using long-polling, it was possible to have the client update whenever any changes occurred on the server. To summarise, long polling is where clients continually ask the server for new information using regular HTTP requests, and the server delays its answer until it an update. The trick is to always have a polling request open and to set a long maximum time for each request to avoid timing out. Node made this easy to manage on the server side.

Disk persistence

The simplest way we can add disk persistence is to make our update function write any changes to a file.

class SkillShareServer {
  updated() {
    this.version++;
    let res = this.talkResponse();
    this.waiting.forEach((resolve) => resolve(res));
    this.waiting = [];

    writeFile(filePath, JSON.stringify(this.talks), (err) => {
      if (err) throw err;
    });
  }
  ...
}

Then, when we launch the server, we want it to read in any talks from disk, if they exist. We pass this to the SkillShareServer through its constructor.

const filePath = "data.json";

async function loadTalks(file = filePath) {
  let data;
  try {
    data = JSON.parse(await readFile(file, { encoding: "utf-8" }));
  } catch (err) {
    data = {};
  }

  // Need to do this to remove the prototype
  // If not, we wouldn't be able to use the 'in' operator safely
  return Object.assign(Object.create(null), data);
}

Comment field resets

One issue with how we’ve built the app is that it replaces DOM elements each time it renders. This may be annoying if you start writing a comment, and then someone else posts something, leading to the app replacing the comment box and losing its content.

The simplest way to fix this is to keep track of comments as part of state and using that to fill in the content when the view updates.

class Talk {
  constructor(talk, dispatch) {
    this.comments = elt("div");
    this.dom = elt(
      "section",
      { className: "talk" },
      elt(
        "h2",
        null,
        talk.title,
        " ",
        elt(
          "button",
          {
            type: "button",
            onclick() {
              dispatch({ type: "deleteTalk", talk: talk.title });
            },
          },
          "Delete"
        )
      ),
      elt("div", null, "by ", elt("strong", null, talk.presenter)),
      elt("p", null, talk.summary),
      this.comments,
      elt(
        "form",
        {
          onsubmit(e) {
            e.preventDefault();
            let form = e.target;
            dispatch({
              type: "newComment",
              talk: talk.title,
              message: form.elements.comment.value,
            });
            form.reset();
          },
        },
        elt("input", { type: "text", name: "comment" }),
        " ",
        elt("button", { type: "submit" }, "Add comment")
      )
    );
    this.syncState(talk);
  }

  syncState(talk) {
    this.talk = talk;
    this.comments.textContent = "";
    for (let comment of talk.comments) {
      this.comments.appendChild(renderComment(comment));
    }
  }
}

function renderComment(comment) {
  return elt(
    "p",
    { className: "comment" },
    elt("strong", null, comment.author),
    ": ",
    comment.message
  );
}