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 行),提供了 h
、Fragment
、render
这三个核心函数,使常规的打包器(babel、esbuild、tsc)能够将 JSX 代码转义成 JS 函数调用,进而渲染成 DOM 元素。这个 Lib 通过 Cherry-Pick 部分 Preact 的单元测试(render
、keys
、fragment
、refs
、createRoot
)来验证它的功能。
@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)
这个转义部分决定了渲染库实现 h
和 Fragment
时的函数定义,其中 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
函数的每次调用会返回一个 VNode
,Fragment
是一个函数组件,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 所需的 h
、 Fragment
、render
函数,将生成的所有组件都以 VNode 的形式存储于内存中,构建出与真实 DOM 一一对应的一颗 VDOM 树。需要重新渲染时,以递归的方式遍历 VDOM 树,在原先的 DOM 树上做更新即可。
这份实现性能较差,原因是因为每次重新渲染都是全量 DIFF,虽然在渲染时会跳过无需重渲染的节点,但是还是需要遍历整棵树,耗时较长。基于此,优化思路可以参考 React,例如渲染优先级(Lane,优先处理用户事件相关的响应)、时间切片(Fiber + requestIdleCallback),也可以参考 Solid 和 Vue,基于 Proxy 实现响应式数据和增量刷新。