Date: 2023-11-19
Tags: vue, virtualdom, markdown

VueでMarkdownのプレビューをVueの仮想DOMで表示する

動機 / モチベーション

Markdown エディタとそのプレビューを作ったんですが、エディタ側を1文字変更するごとにプレビュー側の全エレメントが更新されてしまいました。 このため、画像の再ロードが発生したり、それに伴ってエレメントの高さが変わってしまうため、プレビュー側のスクロール位置が編集中どんどんズレていったり、などの問題がありました。

Vueは 仮想DOM っていうやつで差分のあるエレメントだけ更新している、と思ったけど、全部更新されてるじゃん!と思って調べてみました。

全部更新されていた理由は、MarkdownをHTMLレンダリングしたあと、それを v-html に入れていたためでした。 innerHTML 相当なので、そのエレメント配下のエレメントは差分更新ではなく全更新になっていたというオチです。

<div class="preview" v-html="render(content)" />

前提

  • Node 18

  • Vue 3.3.4

  • TypeScript

  • markdown-it 13.0.2

コードはGitHubにあります。 https://github.com/shimizukawa/vue-md-editor-vdom/tree/main

仮想DOMの構築

公式ドキュメント レンダー関数と JSX | Vue.js によると、コンポーネントの setuprender 関数を返し、その render 関数が仮想DOMを構築して返せばよさそうです。

HTMLに対応する仮想DOMの構築は以下のような関係になっています。

<div id="foo" class="bar">
  Hello <strong>World!</strong>
</div>
import { h } from 'vue'

const vnode = h(
  'div', // type
  { id: 'foo', class: 'bar' }, // props
  [ // children
    "Hello,",
    h('strong', 'World!'),
  ]
)

この置き換えを行うために、HTMLをparser代わりの div.innerHTML に突っ込んで、 div.childNode を対象に仮想DOM化する処理を行えばよさそうです。

render 関数は以下の様になります。

export default defineComponent({
  setup(props) {
    // レンダー関数を返す
    return () => {
      if (!props.content) {
        return h("div");
      }

      const outer = document.createElement("div");
      outer.innerHTML = props.content;

      return walkNodes(outer);
    };
  },
});

walkNodes 関数の実装は、実装方法がいくつかあります。

Markdown のプレビュー用途であればパラグラフ単位で差分更新できれば充分なので、ルート直下のノードそれぞれを仮想DOMにして、それらに属するHTMLは innerHTML で持たせてしまっても良さそうです。

export default defineComponent({
  setup(props) {
    const walkNodes = (node: HTMLElement): any => {
      return Array.from(node.childNodes).map((_node) => {

        if (_node.nodeType === Node.TEXT_NODE) {
          return _node.textContent || "";
        } else if (node.nodeType === Node.ELEMENT_NODE) {
          const _props: any = {};
          for (let i = 0; i < _node.attributes.length; i++) {
            const attr = _node.attributes[i];
            _props[attr.name] = attr.value;
          }
          _props["innerHTML"] = _node.innerHTML;
          return h(
            node.tagName.toLowerCase(),
            _props,
          );
        } else {
          throw new Error("Not implemented nodeType: " + node.nodeType);
        }
      });
    };

    // レンダー関数を返す
    return () => {
      ...
    };
  }
});

一方で、子ノードを全て仮想DOMとして扱えるようになれば、一部の子ノードを別のコンポーネントに差し替えたり、イベントハンドラを設定したりなど、色々差し込めるようになるので応用が利きそうです。 ノードツリーを全て辿って仮想DOMに置き換える、ビジターパターンで実装したコードはGitHubにあります。 https://github.com/shimizukawa/vue-md-editor-vdom/blob/main/src/components/MarkdownRenderer.ts

これで、プレビュー上の変更差分だけが更新されました。