2019-09-29

仮想DOMを使ったなんちゃってReact/Reduxなフレームワークを作ってみた

React/Reduxの仕組みを知るために仮想DOMを使ったなんちゃってフレームワークを作ってみました。

今回はその備忘録。

Reactに関しては

Reduxに関しては

の記事を参考にしました。

完成するとこんな感じで書けるようになります。

class App extends Base {
  onMounted() {
    this.state = {
      text: 0,
      records: ['hoge', 'fuga'],
    }
    fetch('https://httpbin.org/get').then((res) => {
      this.setState({
        text: res.statusText,
      });
      this.context.manager.scheduleRender();
    })
  }

  onChangeText(e) {
    this.setState({
      text: e.target.value
    });
    this.dispatch(changeMessage(e.target.value));
  }

  onClickButton() {
    this.state.records.push(this.state.text);
    this.setState({
      records: this.state.records,
    })
  }

  render() {
    return `<div>
  <if c="this.state.text.length === 0">
    <p>blank</p>
  </if>
  <if c="this.state.text.length !== 0">
    <p>{this.state.text}</p>
  </if>
  <ul>
    <for records="{this.state.records}" var="record">
      <li>{variables.record}</li>
    </for>
  </ul>
  <p>{this.getStoreState().message + '!!'}</p>
  <input type="text" value="{this.state.text}" oninput="{this.onChangeText.bind(this)}"/>
  <input type="button" value="Add" onclick="{this.onClickButton.bind(this)}"/>
  <Foo text="{this.state.text}"/>
</div>`
  }
}

class Foo extends Base {
  render() {
    return `<div>{this.props.text}</div>`;
  }
}

const manager = new AppManager('#app', new App({}, {}));
manager.render();

なんちゃってReact

最初のレンダリング時にはセレクタをコンポーネントのクラスを指定し、AppManagerはコンポーネントクラスを使ってDOMを構築してセレクタの要素に対して appendChild します。

class AppManager {
  constructor(selector, app) {
    this.currentTree = null;
    this.currentRoot = null;
    this.app = app;
    this.app.context.manager = this;
    this.selector = selector;
  }

  render() {
    const newTree = this.app.renderTree();
    if (this.currentRoot === null) {
      this.currentRoot = document.querySelector(this.selector);
      this.currentRoot.appendChild(this.createElement(newTree));
    } else {
      this.replaceNode(this.currentRoot, this.currentTree, newTree, 0);
    }
    this.currentTree = newTree;
    this.skipRender = false;
  }

最初は#renderのif条件が真のブロックを通ることになります。

Base#renderTreeは以下のように実装していて、コンポーネントクラスの#renderで取得した文字列(HTML)をパースして、Converterで仮想DOMに変換します。

  renderTree() {
    const parser = new DOMParser();
    const el = parser.parseFromString(this.render(), 'application/xml');
    const converter = new Converter(this);
    return converter.convert(el.children[0]);
  }

仮想DOMはこんな感じなJSオブジェクトです。

{
  tag: 'div',
  attributes: {},
  children: [
    {
      tag: 'if',
      attributes: { c: 'this.state.text.length === 0' },
      children: [
        { tag: 'p', attributes: {}, children: [{ text: 'blank' }] }
      ]
    },
...
  ]
}

あとはこれを使って document.createElement やら document.createTextNode を使ってDOMツリーを作成して、それをセレクタの要素に対して appendChild すれば初回のレンダリングは完了します。

仮想DOMから実際のDOMを生成するときにonClickなどのイベントハンドラもセットしますが、イベントハンドラの処理後に再レンダリングするように調整します。#scheduleRenderのところ。 (本当はsetStateを契機にできると良いんですがやり方がわからず妥協)

const el = document.createElement(element.tag);
for (let k in element.attributes) {
  const v = element.attributes[k];
  if (this.isEvent(k)) {
    const eventName = k.slice(2);
    el.addEventListener(eventName, (e) => {
      v(e);
      this.scheduleRender();
    })
  } else {
    el.setAttribute(k, v);
  }
}

再レンダリングされる前にステートが変わって#renderの結果が変わる場合は、変更部分の再レンダリングを行います。 レンダリング結果の変更判定は仮想DOMで行い、差分の部分だけ createElement removeElement replaceElement で実DOMの操作をします。 実際は以下のようにDOMと新旧仮想DOMを引数にいれて走査して適宜DOMの操作をしていきます

replaceNode(el, oldNode, newNode, index) {
  if (!oldNode) {
    el.appendChild(this.createElement(newNode));
    return;
  }

  const target = el.childNodes[index];
  if (!newNode) {
    el.removeChild(target);
    return;
  }

なんちゃってRedux

createStoreは以下のように実装

function createStore(reducer) {
  let state = {};
  const listeners = [];
  const getState = () => state;
  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach((listener) => listener());
  };
  const subscribe = (listener) => listeners.push(listener);

  dispatch();

  return {
    dispatch,
    getState,
    subscribe,
  }
}

stateを保持させてreducerで新しいstateをセットするようなdispatch関数を返す感じ。

あとはこんな感じでreducerをつくってcreateStoreを呼び出します

const reducer = (prev, action) => {
  if (!action) {
    return {
      message: 'foo',
    };
  }
  switch (action.type) {
    case 'message':
      return {
        message: action.message,
      };
  }
};

const store = createStore(reducer);

もちろんこれだけではダメで、このstoreは各コンポーネントから適切に呼び出せる必要があります。 Reactではどうやらcontextという仕組みで持ち回っているようなので、それっぽいものを作りました。

const manager = new AppManager('#app', new App({}, { store }));
manager.render();

コンポーネントの第二引数がcontextオブジェクトで、コンポーネントのプロパティにセットされます。 そして、render時に子コンポーネントをレンダリングする際にcontextプロパティを引き回します。

if (/^[A-Z].*$/.test(el.tagName)) {
  const klass = eval(el.tagName);
  return (new klass(attributes, this.context.context)).renderTree();
}

これによって親から子へcontextという名のグローバルなオブジェクトを共有することができ、storeを共通で使えるようになります。

テンプレートの記述方法

これに関しては構文解析とか面倒だったのでXMLベースで記述する仕様としました。 ifはifタグ、forはforタグといった具合に特殊構文に関してはタグで全て対応し、{expression} という記述があったら、随時コンポーネントのコンテキストでevalするようにしています。

class Converter {
  constructor(context) {
    this.context = context;
  }

  convert(el, variables = {}) {
    switch (el.nodeType) {
      case Node.ELEMENT_NODE:
        const attributes = {};
        for (let i = 0; i < el.attributes.length; i++) {
          const node = el.attributes[i];
          attributes[node.name] = this.evalText(node.value, variables);
        }
        if (el.tagName === 'if') {
          const c = this.evalText(`{${el.attributes.c.value}}`, variables);
          if (!c) {
            return null;
          }
          const children = this.convertChildren(el, variables);
          return new Element('div', attributes, children)
        }
このエントリーをはてなブックマークに追加