/create-frontend-framework

Creating a front end framework for learning purpose

Primary LanguageJavaScript

Slides available here

This project aims to provide a step by step implementation of a "frontend framework". It's only for learning purpose and allows to take a first shot of internal stuff such as templating, state management, and the use of Virtual DOMs.

NEW: here's another way to implement it using React API and JSX



Template

Available at $ git checkout step-1

Template litterals as template engine

The first step is to create a folder and a file at ./framework/element.js. We'll use template litterals a template engine. Let's create our first template litteral handler called div:

const div = (strings, ...args) => console.log(strings, args);

const firstName = "Marvin";
const lastName = "Frachet";

div`Hello ${firstName} ${lastName} !`;

Run the following command to check the result of such a bunch of code:

$ node ./framework/element.js

While using templat litterals, the first argument is an array of strings corresponding to the static string of the evaluated element. The second (or n) arguments are the interpolated values. In the previous example, we use destructuring to create an array of the interpolated values because we don't know have much of them exists.

Let's use Array.reduce to create a real string with the static and interpolated ones:

const div = (strings, ...args) =>
  strings.reduce(
    (acc, currentString, index) => acc + currentString + (args[index] || ""),
    ""
  );

const firstName = "Marvin";
const lastName = "Frachet";

const template = div`Hello ${firstName} ${lastName} !`;
console.log(template); // It prints `Hello Marvin Frachet !`

For now, it's pretty good, we're able to create a simple div. Let's abstract a little bit the code to let us create any kind of elements.

Let's create an higher order function that will accept a first argument, the tagName, and that will return a child function which is our real template string handler:

const createElement = tagName => (strings, ...args) => ({
  type: tagName,
  template: strings.reduce(
    (acc, currentString, index) => acc + currentString + (args[index] || ""),
    ""
  )
});

const div = createElement("div");
const p = createElement("p");

const firstName = "Marvin";
const lastName = "Frachet";

const template = div`Hello ${firstName} ${lastName} !`;
// const template = p`Hello ${firstName} ${lastName} !`;
console.log(template);

Display content in the DOM

We'll now create a ./framework/index.js that will act as the core of our framework. For now, it will simply take the previously created element and add it to a DOM node.

export const init = (selector, component) => {
  const app = document.querySelector(selector);
  const newElement = document.createElement(component.type);
  const newTextContent = document.createTextNode(component.template);

  newElement.append(newTextContent);
  app.append(newElement);
};

We don't need console logs and template creation anymore. We simply need to export the p and div elements, let's remove the noise all around in ./framework/element

const createElement = tagName => (strings, ...args) => ({
  type: tagName,
  template: strings.reduce(
    (acc, currentString, index) => acc + currentString + (args[index] || ""),
    ""
  )
});

export const div = createElement("div");
export const p = createElement("p");

We now need to attach our element to a specific and existing DOM node: where should the application stars ?

In ./index.js, the application root file, we will tell that the application will start in the #app document node, by simply calling the previously create init function:

import { init } from "./framework";
import { div } from "./framework/element";

const firstName = "Marvin";
const lastName = "Frachet";

init("#app", div`Hello ${firstName} ${lastName}`);
// init("#app", p`Hello ${firstName} ${lastName}`); works as simply as moving div to p

Our first component

It's great, but we need to use component instead of simple div or p. We want the framework to allow for components creation. And it as simply as creating a function that wraps elements.

Create a file at ./src/user.js and add the following code:

import { div } from "../framework/element";

const firstName = "Marvin";
const lastName = "Frachet";

export const User = ({ firstName, lastName }) =>
  div`Hello ${firstName} ${lastName}`;

And so modify ./index.js:

import { init } from "./framework";
import { User } from "./src/user";

const firstName = "Marvin";
const lastName = "Frachet";

init("#app", User({ firstName, lastName }));

Using an existing virtual DOM library

Available at $ git checkout step-2

Adding Snabbdom

In the previous step, we managed DOM nodes by ourselves. With this solution, we have to manage every kind of DOM modifications, such as text, attribute, children, events etc... It's really time consuming, and more importantly, smarter people have already managed these kind of stuff.

You've probably heard of Virtual DOM. It's a concept that aims to represent and manipule DOM nodes in plain Javascript objects. Modification would happen only on the virtual tree and be applied only at the end of the process to the real DOM. The main advantage is that it's really really faster than DOM manipulations, and really easy to use. DOM operations can be batched etc...

It exists multiple Virtual DOM implementations. In this project, we'll use snabbdom, a function oriented one (present in Vue or Cyclejs).

Snabbdom exposes a h API, that is quite a common way to use VDOMs.

We can now refacto the ./framework/element to create a virtual dom node:

import h from "snabbdom/h";

const createElement = tagName => (strings, ...args) => ({
  type: "element",
  template: h(
    tagName,
    {},
    strings.reduce(
      (acc, currentString, index) => acc + currentString + (args[index] || ""),
      ""
    )
  )
});

export const div = createElement("div");
export const p = createElement("p");

With our new VDOM, ./framework/index.js will be slightly simpler. Snabbdom will manage each of the dom operations for us !

import * as snabbdom from "snabbdom";
const patch = snabbdom.init([]);

export const init = (selector, component) => {
  const app = document.querySelector(selector);
  patch(app, component.template);
};

Event handling

For now, we'll modify just a little bit ./framework/element, to separe function concerns, but also to prepare for the next steps. Let's extract the Array.reduce handler and create a createReducer function:

import h from "snabbdom/h";

const initialState = {
  template: ""
};

const createReducer = args => (acc, currentString, index) => ({
  ...acc,
  template: acc.template + currentString + (args[index] || "")
});

const createElement = tagName => (strings, ...args) => {
  const { template } = strings.reduce(createReducer(args), initialState);

  return {
    type: "element",
    template: h(tagName, {}, template)
  };
};

export const div = createElement("div");
export const p = createElement("p");

It's time to create an onClick event handler: create a file in ./framework/event.js :

export const onClick = f => ({
  type: "event",
  click: f
});

It's a specific API that we'll use later in the application

And modify the ./src/user.js:

import { div } from "../framework/element";
import { onClick } from "../framework/event";

const firstName = "Marvin";
const lastName = "Frachet";

export const User = ({ firstName, lastName }) =>
  div`${onClick(() => alert(firstName))} Hello ${firstName} ${lastName}`;

The result is a bit weird and displays the full function as a string. Actually, we only have made manage text nodes.

We need to add some behaviors inside our element creator, to make it aware of other node types than text, like events, or even attributes:

import h from "snabbdom/h";

const initialState = {
  template: "",
  on: {} // Snabbdom needs a on: {} object to manage events
};

const createReducer = args => (acc, currentString, index) => {
  const currentArg = args[index];

  // Here, we define the behavior of an event node
  if (currentArg && currentArg.type === "event") {
    return { ...acc, on: { click: currentArg.click } };
  }

  return {
    ...acc,
    template: acc.template + currentString + (args[index] || "")
  };
};

const createElement = tagName => (strings, ...args) => {
  const { template, on } = strings.reduce(createReducer(args), initialState);

  return {
    type: "element",
    template: h(tagName, { on }, template) // the second argument concerns attributes, properties and events
  };
};

export const div = createElement("div");
export const p = createElement("p");

This part is Snabbdom specific. By default Snabbdom doesn't know how to manage events. This way, we sometime need to plug some other modules. In our application, we'll tell Snabbdom to use its own internal event listener system

import * as snabbdom from "snabbdom";
const patch = snabbdom.init([
  require("snabbdom/modules/eventlisteners").default
]);

export const init = (selector, component) => {
  const app = document.querySelector(selector);
  patch(app, component.template);
};

State management

Available at $ git checkout step-3

The state of an application is kind of like a snapshot of it at a specific time. Let's represent it from a programming point of view:

const t1State = { firstName: "Marvin", lastName: "Frachet" };

const t2State = transform(t1State, { lastName: "Thomas" });
// t2State is now { firstName: "Marvin", lastName: "Thomas" }

Every frontend framework uses its own internal way to transform state. For the sake of learning and clarity, I'll try to stick as much as possible to the previous definition.

For now, we need to create a shared behavior between every of our components: we need to make any created component able to manage its own internal state.

In functional programming, when we want to add a specific and shared behavior to a function, we use to wrap it in another function, called HOF, a higher order function.

In ./framework/index.js, create a function called createComponent that will allow the state management:

import * as snabbdom from "snabbdom";
const patch = snabbdom.init([
  require("snabbdom/modules/eventlisteners").default
]);

export const init = (selector, component) => {
  const app = document.querySelector(selector);
  patch(app, component.template);
};

export const createComponent = ({ template, methods = {} }) => props =>
  template(props);

For now, this function only displays the template, let's make it incrementally.

Then make our User component a real OurFrameworkName oriented component (like extends React, new Vue or @Component in other frameworks).

In the ./src/user.js, simply wrap the User function with our previously createComponent one:

import { createComponent } from "../framework";
import { div } from "../framework/element";
import { onClick } from "../framework/event";

const firstName = "Marvin";
const lastName = "Frachet";

const template = ({ firstName, lastName }) =>
  div`${onClick(() => alert(firstName))} Hello ${firstName} ${lastName}`;

export const User = createComponent({ template });

Using methods inside the component

We're now able to add some behavior to all of our components, at one place ! It's time to add some methods inside the component.

In ./framework/index.js, add the methods props:

export const createComponent = ({ template, methods = {} }) => props =>
  template({ ...props, methods });

It's now available in the component:

import { createComponent } from "../framework";
import { div } from "../framework/element";
import { onClick } from "../framework/event";

const firstName = "Marvin";
const lastName = "Frachet";

const methods = { callMe: name => alert(name) };

const template = ({ firstName, lastName, methods }) =>
  div`${onClick(() =>
    methods.callMe(firstName)
  )} Hello ${firstName} ${lastName}`;

export const User = createComponent({ template, methods });

Modifying the component props with state

We can now make some special behavior using these methods ! Why not to be able to modify the props ? In the ./framework/index.js, add:

import * as snabbdom from "snabbdom";
const patch = snabbdom.init([
  require("snabbdom/modules/eventlisteners").default
]);

export const init = (selector, component) => {
  const app = document.querySelector(selector);
  patch(app, component.template);
};

let state = {};

export const createComponent = ({
  template,
  methods = {},
  initialState = {}
}) => {
  state = initialState;

  const mappedMethods = Object.keys(methods).reduce(
    (acc, key) => ({
      ...acc,
      [key]: (...args) => {
        state = methods[key](state, ...args);
        console.log(state); // this prints "Thomas" as firstName :D
        return state;
      }
    }),
    {}
  );

  return props => template({ ...props, ...state, methods: mappedMethods });
};

And let's now change the user component:

import { createComponent } from "../framework";
import { div } from "../framework/element";
import { onClick } from "../framework/event";

const firstName = "Marvin";
const lastName = "Frachet";

const methods = {
  changeName: (state, firstName) => ({
    ...state,
    firstName: state.firstName === "Marvin" ? "Thomas" : "Marvin"
  })
};
const initialState = { firstName: "Marvin", lastName: "Frachet" };

const template = ({ firstName, lastName, methods }) =>
  div`${onClick(() =>
    methods.changeName("Thomas")
  )} Hello ${firstName} ${lastName}`;

export const User = createComponent({ template, methods, initialState });

Last step bringing it all together

Available at $ git checkout step-4

In ./framework/index.js, add :

import * as snabbdom from "snabbdom";
const patch = snabbdom.init([
  require("snabbdom/modules/eventlisteners").default
]);

export const init = (selector, component) => {
  const app = document.querySelector(selector);
  patch(app, component.template);
};

let state = {};

export const createComponent = ({
  template,
  methods = {},
  initialState = {}
}) => {
  state = initialState;
  let previous;

  const mappedMethods = props =>
    Object.keys(methods).reduce(
      (acc, key) => ({
        ...acc,
        [key]: (...args) => {
          state = methods[key](state, ...args);
          const nextNode = template({
            ...props,
            ...state,
            methods: mappedMethods(props)
          });
          patch(previous.template, nextNode.template);
          previous = nextNode; // this prints "Thomas" as firstName :D
          return state;
        }
      }),
      {}
    );

  return props => {
    previous = template({ ...props, ...state, methods: mappedMethods(props) });
    return previous;
  };
};