Koalablog

Little VDOM 浅析

https://github.com/wzhzzmzzy/little-vdom-ts

Forked from: https://github.com/luwes/little-vdom

HTML 的写法

一般来说,我们在现代前端开发流程当中开发网页时,写 HTML 的方法主要有三种:

首先,第一种方法自然是直接写 HTML,这种方法中也包含一些扩展了 HTML 的库或者框架,包括 HTMX、AlpineJS 等,特点是可以直接在 .html 文件中使用,对工具链没有要求。这种方法的特点是简单,代价则是无法对 HTML 本身做任何抽象。

类似的,第二种方式是编写基于框架的 HTML 模板,Vue、Svelte、Astro 等框架都属于此类,特点是语法近似于 HTML,通过自定义指令或者属性对 HTML 做操作;这些框架有时候还会支持一些 HTML 方言,比如 Pug 和 EJS。这种方式一般会对工具链有更多的要求,例如编写成特定后缀名的文件,需要复杂的构建工具,构建过程中会与 JS 深度绑定等等。

最后一种方式就是 JSX/TSX 了。JSX 的独特之处在于他是扩展过的 JS 文件,支持 JS 语法的同时又支持 JSX 语法,让我们可以将 HTML 元素当作 JS Object 来使用,随意地在代码当中引用和传递。在构建过程方面,JSX 和第二种很像:需要构建工具、代码一般会使用 .jsx 后缀。不同点在于 JSX 算是一种比较通用的范式,流行于各大框架之中,所以也被 tsc、esbuild 等主流构建工具直接支持。

JSX

JSX 转译

JSX 的转译逻辑是将我们写的 HTML 标签转译为一系列 JS 方法调用,以 React 16 为例(因为 React 17+ 换了新的方案):

// index.jsx
import ReactDOM from 'react-dom'
import App from './App.jsx'

ReactDOM.render(
  <App />, 
  document.getElementById('root')
);

// App.jsx
const App = () => {
  return (
    <>
	  <div className="app">React App</div>
	  <button onClick={() => alert('hello')}>Say Hello</div>
	</>
  );
}
// transpiled index.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App.jsx'

ReactDOM.render(
  React.createElement(App, null, null), 
  document.getElementById('root')
);

// transpiled App.jsx
import React from 'react'
const App = () => {
  return React.createElement(
    React.Fragment,
    null,
    [
	  React.createElement(
	    'div', { className: 'app' },
	    'React App'
      ),
      React.createElement(
	    'button', { onClick: () => alert('hello') },
	    'Say Hello'
      ),
    ]
  )
}

通过对比可以看到,转译后就是调用了 React.createElement,第一个参数是标签名,第二个是标签的属性,第三个参数是子标签。更进一步,我们可以枚举出来渲染器需要处理的三种元素,分别是 HTMLElement、Text、自定义组件。

React 17 的变化则引入了 JSX Runtime,转译后的代码有了些许变化,主要的收益是让 JSX 转译不需要引入全量 React。

// index.jsx, transpiled for React 17+
import { jsx as _jsx } from 'react/jsx-runtime'
import ReactDOM from 'react-dom'
import App from './App.jsx'

ReactDOM.render(
  _jsx(App, {}), 
  document.getElementById('root')
);

// App.jsx, transpiled for React 17+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from 'react/jsx-runtime'
const App = () => {
  return _jsxs(
    _Fragment,
    {
      children: [
		_jsx('div', { className: 'app', children: 'React App' }),
	    _jsx('button', { onClick: () => alert('hello'), children: 'Say Hello' }),
	  ]
    }
  )
}

构建 JSX

可以看到 JSX 处理方式也是五花八门,打包器需要帮我们完成了 JSX 到 JS 转义的语法部分工作,所以编写或者构建 JSX 时,需要配置一些字段来告诉打包器要怎么处理 JSX,例如 tsconfig.json 有以下几个配置项:

interface Config {
    // preserve - 保留 JSX 语法不变,输出的文件扩展名是 jsx
    // react - 对应 React 16.x 的处理方式
    // react-jsx - 对应 React 17+ 的处理方式
    // react-jsxdev - 对应 React 17+,多了一些 debug 信息
    // react-native - 保留 JSX 语法不变,输出的文件扩展名是 js
	"jsx": "preserve" | "react" | "react-jsx" | "react-jsxdev" | "react-native",
	// JSX 转译后的函数名
	"jsxFactory": "React.createElement",
	// JSX Fragment 组件
	"jsxFragment": "React.Fragment",
	// 使用 jsx: "react-jsx" 时,指定引入 jsx-runtime 的目录,例如 react/jsx-runtime
	// 使用 jsx: "preserve" 时,使用指定的库来解析 JSX 元素的类型,用于类型检查
	"jsxImportSource": "react",
}

如果我们可以使用类 React 的方式解析 JSX,那就可以直接使用 react 或者 react-jsx;而类似 Vue、SolidJS 这样的框架,其定义的 JSX 解析逻辑于 React 有比较大的区别,就会使用 preserve 模式,将 JSX 代码传递给后续的构建工具来处理。因此,不管我们是否使用了 tsc 处理 JSX 文件,都需要在 tsconfig.json 中配置一些 JSX 相关的选项。

转念一想,既然 tsc 和 esbuild 都支持 JSX 转译,那么我们只要有一个能将 JS 正常渲染为 DOM 的库,就可以使用 JSX 写 HTML 了。我们来看看要如何手搓一个一百行的 JSX 渲染器吧。

简单起见,我们不考虑响应式相关的逻辑,只做很纯粹的 JSX 到 HTML 的 mount 和 update。

Little VDOM

Little VDOM 是一个 JSX 渲染器玩具,一共只有 160 行代码(TS 版本 260 行),提供了 hFragmentrender 这三个核心函数,使常规的打包器(babel、esbuild、tsc)能够将 JSX 代码转义成 JS 函数调用,进而渲染成 DOM 元素。这个 Lib 通过 Cherry-Pick 部分 Preact 的单元测试(renderkeysfragmentrefscreateRoot)来验证它的功能。

@luwes/little-vdom 提供的使用方法是这样的:

/** @jsx h */

// Components get passed (props, state, setState)
function Counter(props, { count = 0 }, update) {
  const increment = () => update({ count: ++count });
  return <button onclick={increment}>{count}</button>
}

function Since({ time }, state, update) {
  setTimeout(update, 1000); // update every second
  const ago = (Date.now() - time) / 1000 | 0;
  return <time>{ago}s ago</time>
}

render(
  <div id="app">
    <h1>Hello</h1>
    <Since time={Date.now()} />
    <Counter />
  </div>,
  document.body
);

TypeScript Version

我花了一些时间,对 @luwes/little-vdom 这个版本进行了一些改写,同时给源码部分添加了 TypeScript 类型定义,改掉了一些难以理解或是不太合理的写法。

JSX 转义

先看看 JSX 转义部分:

// JSX Component
const App = ({ name }) => <><div class="app">Hello, {name}</div></>
// equivalent to
const App = ({ name }) => h(Fragment, {}, [
	h('div', { ["class"]: "app" }, [`Hello, ${name}`])
])

const root = document.getElementById('root')
// JSX render
const main = render(<App name="My Love" />, root)
// equivalent to
const main = render(h(App{ name: "My Love" }), root)

这个转义部分决定了渲染库实现 hFragment 时的函数定义,其中 Fragment 本质就是个空节点,用于渲染多个同一级的兄弟节点。可以看到,每一个 JSX 组件函数的返回值都是一次 h 调用,渲染时被送入 render 函数,被渲染成 root 节点的子节点。

渲染入口

下面是我微调过的 little-vdom.js 部分源码,是 h \ Fragment \ render 的实现,同时给所有属性添加了注释。

const h = (type, props, ...children): VNode => {
  return {
    // VNode 类型,有函数组件、标准 DOM 和纯文本三种
    _type: type,
    // 外部传入的属性,也就是 jsx 标签上的属性
    _props: props,
    // 子节点
    _children: children.filter((_) => _ !== false && _ !== null),
    // VNode 的唯一标识
    key: props && props.key,
  };
};

const Fragment: FunctionalComponent = (props) => {
  return props.children;
};

const render = (newVNode, dom, oldVNode = dom._vnode) => {
  // _vnode 是 dom 对象上对应 vnode 的引用,初始化时设置为空 VNode,diff 时会更新 VNode
  if (!oldVNode) oldVNode = dom._vnode = nilVNode();
  return diff(h(Fragment, {}, newVNode), dom, oldVNode);
};

h 函数的每次调用会返回一个 VNodeFragment 是一个函数组件,render 则会调用另一个名叫 diff 的函数,同时在外面包裹一层 Fragment,整体逻辑还是非常简单的。有一些特别的是这个 dom._vnode,这里为了简单起见,没有自行维护一份和 DOM 对应的 VDOM 树在内存当中,而是直接在现成的 DOM 节点上添加了一份引用,用于缓存对应的 VNode。

DIFF

如果对 VDOM 有所耳闻的话,肯定会了解到虚拟 DOM 的核心逻辑之一就是在渲染阶段进行 DIFF 。所谓 DIFF,就是用新生成的虚拟 DOM 节点和原先的虚拟 DOM 节点进行比较,根据比较结果修改真实 DOM 树(插入、删除)。

little-vdom 的核心逻辑就是一个递归的 DIFF 函数,核心思路是将 VNode 分三类处理:数组、函数组件和标准 DOM 元素。函数组件需要先执行渲染函数,然后对新旧两份渲染函数结果再次进行 DIFF;标准 DOM 元素需要先创建对应元素、更新 Props,然后对子元素进行 DIFF,二次绘制的情况还需要判断是否需要插入到原位;对于数组,直接对子元素进行 DIFF 即可。简化过后的逻辑如下:

const diff = (
  newVNode: VNode | VNode[],
  dom: DOMElement,
  oldVNode: VNode,
  currentChildIndex: number = -1
): VNode => {
  // VNode[]
  if (Array.isArray(newVNode)) {
    return diffChildren(dom, newVNode, oldVNode);
  }

  // FCVNode
  if (typeof newVNode._type === "function") {
    // FC render result
    const renderResult = newVNode._type(
		/* ... */
    );

    // Memoized renderResult in _patched
    // diff renderResult with oldVNode
    newVNode._patched = diff(
      renderResult,
      dom,
      oldVNode?._patched || nilVNode(),
      currentChildIndex
    );

    return (dom._vnode = newVNode);
  }

  // ElementVNode or TextVNode
  // Create DOM instance
  const newDom =
    newVNode._type
      ? document.createElement(newVNode._type)
      : new Text(newVNode._props as string);

  // Rerender DOM only if props changed
  if (newVNode._props != oldVNode?._props) {
    // Standard ElementVNode
    if (newVNode._type) {
      const { key, ref, ...newProps } = (newVNode as ElementVNode)._props;

      if (ref) ref.current = newDom;

      // Merge props to DOM
      for (let name in newProps) {
        // Simplified logic
	    (newDom as HTMLElement).setAttribute(name, value);
      }
    }
    // Update Text element
    else {
      (newDom as Text).data = newVNode._props as string;
    }
  }

  // diff child nodes
  diffChildren(newDom, (newVNode as ElementVNode)._children, oldVNode);

  // Insert at position
  // 1. oldVNode doesn't have dom means initial render
  // 2. rendered but currentChildIndex === -1 means just remove
  if (!oldVNode?.dom || currentChildIndex !== -1) {
    dom.insertBefore(
      (newVNode.dom = newDom),
      dom.childNodes[(currentChildIndex as number) + 1] || null
    );
  }

  // Update dom._vnode
  return (dom._vnode = Object.assign(oldVNode, newVNode));
};

可以看到 diff 实际返回的是传入的 newVNode。比较特别的逻辑是 newVNode._patched,这个字段中存储了渲染结果,用于再次渲染时和下一次的渲染结果做比较。对当前节点 diff 完成之后,还需要对子节点进行递归 diff,这里通过调用 diffChildren 处理。

diffChildren 的逻辑相对简单很多,本质上就是筛选出和当前新节点对应的老节点,如果新老节点的位置完全一致,就不做调整,反之需要插入新节点并将老节点清除。同时,递归 diff 新老节点。这里需要注意,清理老节点时需要清理它的所有 _children_patched ,避免遗漏。以下是详细逻辑:

const diffChildren = (
  parentDom: DOMElement,
  newChildren: VNodeLike[],
  oldVNode: VNode
): VNode => {
  const oldChildren: (VNode | null)[] = oldVNode._normalizedChildren || [];

  oldVNode._normalizedChildren = newChildren.concat
    .apply([], newChildren)
    .map((child: VNodeLike, index: number) => {
      // If the vnode has no children we assume that we have a string and
      // convert it into a text vnode.
      let nextNewChild: VNode;
      if ((child as VNode)._type === undefined) {
        nextNewChild = h("", "" + child);
      } else {
        nextNewChild = child as VNode;
      }

      // If we have previous children we search for one that matches our
      // current vnode.
      const nextOldChild =
        oldChildren.find((oldChild, childIndex) => {
          // Check if oldChild exists and matches the new child
          const isMatchingChild =
            oldChild &&
            oldChild._type === nextNewChild._type &&
            (oldChild as any).key === (nextNewChild as any).key;

          if (isMatchingChild) {
            // If child index same as old child index, don't need rerender, skip this node
            if (childIndex === index) {
              console.log("new", nextNewChild, oldChild);
              index = -1;
            }
            oldChildren[childIndex] = null;
            return oldChild;
          }

          return isMatchingChild;
        }) || nilVNode();

      // Continue diffing recursively against the next child.
      return diff(nextNewChild, parentDom, nextOldChild, index);
    });

  // remove old children if there are any
  oldChildren.filter((i) => !!i).map(removePatchedChildren);

  return oldVNode;
};

function removePatchedChildren(child: VNode) {
  const { _children = [], _patched = [] } = child as FCVNode;
  // remove children
  _children.concat(_patched).map((c) => c && removePatchedChildren(c));
  // remove dom
  child.dom && child.dom.remove();
}

总结

Little VDOM 是一份极简的 JSX 实现,通过阅读它可以快速理解 JSX + VDOM 实现的大概思路,也就是实现 JSX 所需的 hFragmentrender 函数,将生成的所有组件都以 VNode 的形式存储于内存中,构建出与真实 DOM 一一对应的一颗 VDOM 树。需要重新渲染时,以递归的方式遍历 VDOM 树,在原先的 DOM 树上做更新即可。

这份实现性能较差,原因是因为每次重新渲染都是全量 DIFF,虽然在渲染时会跳过无需重渲染的节点,但是还是需要遍历整棵树,耗时较长。基于此,优化思路可以参考 React,例如渲染优先级(Lane,优先处理用户事件相关的响应)、时间切片(Fiber + requestIdleCallback),也可以参考 Solid 和 Vue,基于 Proxy 实现响应式数据和增量刷新。