渲染原理之组件结构与 JSX 编译
# 目录
# JSX 再认识
# React 中的 JSX
SolidJS 和 React 一样也是采用了 JSX 来作为组件的定义语言。JSX 我们都很熟悉了,在 React 之中,JSX 被作为是 ReactElement 的描述,每一个节点被 babel 编译为 createElement
的语法糖,类似于 h 函数。这可能给我们带来了一些固定思维,认为 JSX 就是用来描述节点的树形结构的,其实不然,JSX 作为一种组件的描述语言,它能够为编译工具提供足够的灵活性,也就是说,他也可以达到类似于 SFC 的静态分析的效果。
# SolidJS 中的 JSX
我们先来看下 SolidJS 中 JSX 的用法:
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
const increment = () => setCount(count() + 1);
return (
<button type="button" onClick={increment}>
{count()}
</button>
);
}
render(() => <Counter />, document.getElementById("app")!);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这是官方的一个案例,参见:Solid Playground (opens new window),注意 COMPILE MODE
选择 Client side rendering
,即 CSR。
可以看到,SolidJS 可以提供 React 类似的语法。SolidJS 部分灵感来源于 React,但是其原理却与 React 与天壤之别,更进一步说,SolidJS 的响应式系统更接近于 Vue,而其去除 VDOM、细粒度更新的特性则更接近于 Svelte。
现在我们来看下上述的代码在 babel-preset-solid (opens new window) 的编译下的产物(output):
import { render, createComponent, delegateEvents, insert, template } from 'solid-js/web';
import { createSignal } from 'solid-js';
const _tmpl$ = /*#__PURE__*/template(`<button type="button"></button>`, 2);
function Counter() {
const [count, setCount] = createSignal(0);
const increment = () => setCount(count() + 1);
return (() => {
const _el$ = _tmpl$.cloneNode(true);
_el$.$$click = increment;
insert(_el$, count);
return _el$;
})();
}
render(() => createComponent(Counter, {}), document.getElementById("app"));
delegateEvents(["click"]);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
最显著的特点是,JSX 并不创建类似于嵌套的 h 函数所构成的 VDOM 产物,而是更接近于一种基于 DOM 模板的静态分析。我们可以将上述的代码块归纳成如下的几个部分:
- DOM 静态模板
- 响应式系统
- 事件委托系统
DOM 静态模板即 _tmpl$
,这是组件初始化时静态的 DOM 节点,通过 document.createElement("template")
的方法创建 DOM 节点,参见:template (opens new window)。处理静态模板是在编译时处理的,并且通过静态提升的方式进行优化。
响应式系统即以 createSignal
为代表的响应式 API 所构成的内容,目的是以发布订阅模式思想为指导以代理方式为方法实现响应式,在 “信号” 发生变化时触发收集到的副作用,并且更新 DOM 节点。
事件委托系统即 delegateEvents
,参见 delegateEvents (opens new window),目的是通过事件委托的方法提升事件处理的效率,事件则统一委托在 document
上。这一点与 React 类似,不过 React 已经将事件委托节点由 document 改成了 FiberRoot 上,参考:React 17 attaches events to the root DOM container instead of the document node (opens new window)。事件委托在 document 当然会有一些安全隐患,因为 document 是 React 应用所无法完全控制的范围,但是委托到 FiberRoot(即 container)上也会存在一些问题,比如连续的拖拽事件将会出现拖拽对象无法跟随鼠标的情况,在 React 中利用原生 mousemove
实现拖拽功能便会遇到这一问题。
在后文的解析之中我们会详细的探讨这几个部分的内容,但是现在,我们不妨转换视角,思考下为什么 SolidJS 需要这么做,以及这么做能够有什么好处?要分析这个问题,我们先从 VDOM 框架说起,探讨一下基于 VDOM 原理的框架之共同特性。
# 基于 VDOM 的框架之特性
基于 VDOM 的框架在组件的编译上会具有一些共性,简述如下:
- 嵌套的 h 函数。
render
函数在每次渲染时执行。
所谓 h 函数,即为 “渲染函数”,是指通过数据结构描述的方式获取虚拟节点的函数。如 React 中的 createElement
、vue 中的 _createElementVNode
(参见:Vue SFC Playground (opens new window)) 等。
所谓 render
函数,是能够表征组件的视图、事件、状态的函数,通过执行 render
函数,可以获取最新状态的虚拟节点树。如 React 类组件的 Class.render
函数、函数式组件本身,vue 组件的 setup 函数。
我们可以深入地考虑为什么基于 VDOM 的框架会具有这样的特性,其实不难理解:
- 渲染函数为 VNode 的创建提供了便捷性,模板的解析依赖渲染函数。
- 创建状态和数据随时间变化的组件。VDOM 以及 DIFF 算法的目的就是在状态发生变化时通过对比 VDOM 来更新视图,因此组件必须能够表征出组件最新的状态和视图。
参考:
# 为什么使用 VDOM?
决定了 SolidJS 的 JSX 解析与 React 大有不同的原因是因为 SolidJS 去除了 VDOM 层,这导致了 SolidJS 中响应式系统的策略的改变。VDOM 的思路来源于 React,目的是提升节点 DIFF 的效率并且降低成本。为什么要使用 VDOM 呢?这就不得不谈到 VDOM 所具有的优势:
- 降低 DIFF 成本。直接 DIFF DOM 节点的成本简直无法想象,而 VDOM 作为 JavaScript 可控的数据结构,则可大大降低 DIFF 的成本。同时,一系列的优化手段也可使 DIFF 成本进一步降低,如更高效的 DIFF 算法,React 的 EffectTag 处理或者 Vue 的模板静态分析、静态提升等手段。
- 构建节点防腐层。框架不可能只使用 Web 这一种使用场景,在适配各种不同的渲染场景时,VDOM 则可作为防腐层存在,适配不同的节点渲染规则。
- 声明式的、状态驱动的 UI 开发体验(
declarative, state-driven UI development
)。无论是 React 还是 Vue 都强调自己的这两个特性,当然这与 VDOM 机制不可分离。这使得我们在编写组件时更高效的处理状态的变化和视图的呈现,而不用关注视图更新的时机和原理。
# VDOM 的局限性
同时 VDOM 自然也带来了一些局限性,如:
- 大量的 DIFF (包括无效的 DIFF)造成成本的提升。
- 提升 DIFF 效率的复杂性构成应用的效率瓶颈。
无论是应用级别的 DIFF 还是组件级别的 DIFF,其实都提升了响应式系统的复杂性,因为响应式的更新是离散的、细粒度的,而 VDOM 的方式则无疑将这种更新的影响范围扩散到整个应用或者组件,这种影响范围的放大造成我们想要细粒度的 DOM 更新变得异常复杂。您可以从下图中体会这一点:
参考:
- Svelte Blog: Virtual DOM is pure overhead (opens new window)
- React vs. Svelte: The War Between Virtual and Real DOM | by Keshav Kumaresan | Bits and Pieces (opens new window)
- Incremental vs Virtual DOM. Will Incremental DOM Replace Virtual… | by Chameera Dulanga | Bits and Pieces (opens new window)
# VDOM 造成的富应用化
VDOM 会使应用变得更 “重”,这在很大程度上是因为 VDOM 的粗粒度所造成的。我们可以进一步思考 VDOM 应用具体会重在哪里:
- VDOM 系统和 DIFF 算法,VDOM 系统包含了渲染函数和模板、VNode 的内存占用、DIFF 算法及其繁琐的优化策略、VNode 转化为 DOM 节点变更的 mutation 操作等。
- VDOM 的 “庞大的” 运行时。VDOM 思想本质上还是在实践轻编译重运行的理念,因此庞大的 VDOM 系统必然会包含在运行时中,成为线上产品的一大 “负担”。
VDOM 的粗粒度是一种权衡( tradeoff
),VDOM 获得了更多的节点控制权,却在一定程度上违背了细粒度更新的规则,因为无论 VDOM 系统是应用层面的还是组件层面的,都要为细粒度更新 DOM 的优化而付出巨大的代价。同时,VDOM 也将响应式系统和组件进行了捆绑,因为我们总是认为状态是属于组件的,但是从整个应用的更新来看,响应式系统是完全可以与组件进行解耦的。后文会详述这一问题。
这种让应用程序越来越重的倾向,我称之为 “富应用化”。
# SolidJS 的富组件化
SolidJS 去除了 VDOM 系统,这使得离散的、细粒度的状态变化可以直接通过响应式系统与细粒度的 DOM 更新进行对应,无论这种 DOM 更新是可调度的还是无调度的,这在一定程度上解决了 VDOM 所造成的的应用的 overhead
。
我们可以思考去除 VDOM 所带来的直接影响:
- 更轻量的应用运行时,更高效的组件更新效率。去除 VDOM 将直接减少运行时的体积,没有了 DIFF 的过程也将大大提升组件更新的效率。
- 更轻的框架成本,更少的框架倾入性,可插拔的框架模块。相比于 React,solidjs 要轻量的多,我们可以从源码中直接体会到。没有 VDOM,框架对于节点将减少很多的倾入性,这将使得 DOM 的更新更加贴近于原生的 DOM 操作。框架模块之间可以相互解耦,如响应式系统、事件系统、组件结构等,这将使得框架的可插拔性更好,可以轻易地与其他框架配合使用。
- “重编译而轻运行” 的趋势。将更多的成本转移到编译时,减轻运行时的负担。
这一切都将导致 SolidJS 的 “富组件化” 的倾向!
所谓 “富组件化”,即是将应用的成本中心由 “应用” 转移到 “组件” 中。我们可以观察上文 SolidJS JSX 的编译结果,以推测去除 VDOM 后的 SolidJS 是如何实现 DOM 的更新的。
毫无疑问,这种因状态更新而更新 DOM 的成本是转移到了组件上。在 SolidJS 中,响应式系统在注入组件时,会将更新 DOM 节点作为副作用的一部分。显然这部分的副作用虽然有着良好的性能,但由于其复杂性,成本也会随着项目的规模增长而变得更大。如果单独从组件的角度来看,组件已经具有了
# 响应式系统与组件的解耦
参考: