前言
随着组件化、响应式、虚拟DOM等技术思想引领着前端开发的潮流,相关的技术框架大行其道,就以目前主流的Vue、React框架来说,它们都基于组件化、响应式、虚拟DOM等技术思想的实现,但是具有不同开发使用方式以及实现原理,这里就不再赘述了相关内容,这里关注的焦点在于虚拟DOM。
无论是Vue还是React都应用虚拟DOM,通过虚拟DOM从而来减少频繁的DOM操作,优化页面性能。随着虚拟DOM应用到实际生产中后,无论是Vue还是React都少不了增加虚拟DOM对象以及相应Diff过程,特别是Diff过程恰恰是影响速度的重要点。人们渐渐思考虚拟DOM真的就比直接操作DOM要快吗?渐渐出现了放弃虚拟DOM的现代化技术框架,例如Solid、Svelte等。
Svelte和Solid都是放弃虚拟DOM但是应用组件化、响应式等技术的现代化框架,Svelte则比较偏向于Vue形式风格,Solid框架则偏向于React形式风格,但是底层响应式实现完全不同,这篇文章就聊聊Solid框架以及背后相关思想。
Solid基本说明
实际上最初Solid吸引我的点就是所谓号称比React还react的言论,作为多年的React框架使用者,我了解背后的实现思想以及实际开发中的痛点,而Solid框架有很多让我想要探索的点:
- 放弃虚拟DOM下的快速更新效率,没有虚拟DOM 或广泛的差异对比
- 保持React Hooks风格下的没有所谓的Hook规则
- 细粒度的响应性控制,函数组件只执行一次以及节点级的UI视图更新策略
Solid最吸引我的点就是细粒度的响应性,相比于React以及Vue更新策略,Solid可以做到节点级的视图更新:
- React更新策略基本描述:父组件的状态变更会导致当前子树下所有组件重新运行,需要对无需更新的子组件进行memo操作
- Vue更新策略的基本描述:每一个组件都对应一个Watcher对象,组件响应性状态与视图Watcher对象关联,组件的状态变更只会影响与之关联的对应视图Watcher对象,即只更新与状态建立联系的组件
- Solid更新策略的基本描述:组件的状态变更只会影响使用该状态的节点UI视图的更新
对于Solid的使用可以查看其官网,Solid的基本使用案例如下:
import { render } from 'solid-js/web';
import { createSignal } from 'solid-js';
function App() {
const [loggedIn, setLoggedIn] = createSignal(false);
const toggle = () => setLoggedIn(!loggedIn())
return (
<>
<button onClick={toggle}>Log out</button>
<button onClick={toggle}>Log in</button>
</>
);
}
render(() => <App />, document.getElementById('app'))
render处理
组件经过Solid相关工具编译处理得到的最终代码逻辑如下:
// render(() => <Counter />, document.getElementById("app")!);
render(() => _$createComponent(Counter, {}), document.getElementById("app"));
上面实例初始化阶段的处理逻辑,render函数的逻辑如下:
function render(code, element, init, options = {}) {
let disposer;
createRoot(dispose => {
disposer = dispose;
element === document ? code() : insert(element, code(), element.firstChild ? null : undefined, init);
}, options.owner);
return () => {
disposer();
element.textContent = "";
};
}
从上面逻辑可以如下调用链:
render -> createRoot -> runUpdates -> 顺序执行createRoot传入的回调函数、completeUpdates
createRoot回调逻辑主要逻辑如下:
element === document ? code() : insert(element, code(), element.firstChild ? null : undefined, init);
主要就是两点逻辑:
- 执行code函数:code就是render传入的第一个参数,这里就会被执行,通常来说就是函数组件,即函数组件会被执行
- 执行insert函数:实现组件内容挂载到页面上
insert函数
insert函数具体处理逻辑如下:
function insert(parent, accessor, marker, initial) {
if (marker !== undefined && !initial) initial = [];
if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
createRenderEffect(current => {
insertExpression(parent, accessor(), current, marker);
}, initial);
}
insert函数的accessor参数有两种情况:
- 非函数类型:意味着是DOM挂载点,就会调用insertExpression,即将节点插入到页面DOM中,从而显示视图
- 函数类型:意味着是响应性状态,需要调用createRenderEffect函数
Solid响应性原理
类比于React的useState、useEffect,Solid提供createSignal、createEffect这两个API:
- createSignal提供响应性状态定义
- createEffect提供监听响应性状态变更后需要运行的副作用处理
createSignal是响应性状态定义来源,这里以下面实例来看看其背后的响应性原理逻辑:
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [getCount, setCount] = createSignal(1);
const increment = () => setCount(getCount() + 1);
// 只会执行一次
console.log('hello');
return (
<>
<button type="button" onClick={increment}>
点击
</button>
<div>{getCount()}</div>
</>
);
}
render(() => <Counter />, document.getElementById("app")!);
很简单的点击按钮增加计数的逻辑,当点击按钮后计数加1,而对应的div节点就会更新但是button节点不会更新。
实际上上面实例中Counter组件经过处理会变成下面形式:
unction Counter() {
const [getCount, setCount] = createSignal(1);
const increment = () => setCount(getCount() + 1);
return [(() => {
const _el$ = _tmpl$();
_el$.$$click = increment;
return _el$;
})(), (() => {
const _el$2 = _tmpl$2();
_$insert(_el$2, getCount);
return _el$2;
})()];
}
Counter组件的视图部分被处理成一个个自执行函数了,即button节点和div节点分别对应一个自执行函数,如果嵌套多级子节点呢?难道每一个节点都对应一个自执行函数吗?实际上并不是如此逻辑,从实际调试得到的两点逻辑如下:
- 只有组件的一级子节点才会一一对应一个自执行函数,次级子节点不会
- 只要节点包含响应性状态内容则会调用_$insert函数来处理
实际上render过程会触发根组件,即Counter函数组件执行,而函数组件内部中对于Signal状态的节点的处理需要调用_$insert,实际上该函数就是insert函数。此时insert函数的accessor参数就是响应性状态的getter函数,整个调用链如下:
- 函数组件内部调用insert函数 -> createRenderEffect -> 顺序执行createComputation 、 updateComputation
- updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数
createRenderEffect回调函数的逻辑如下:
createRenderEffect(current => {
insertExpression(parent, accessor(), current, marker);
}, initial);
其逻辑分为两点:
- accessor函数执行:对应着Signal状态下上文getter函数,即响应性状态的getter函数会在runComputation后被调用处理
- insertExpression:会将Signal状态值生成的DOM插入到父节点中
在accessor函数执行之前,需要了解createSignal创建响应性状态的具体逻辑,该API主要处理逻辑代码如下:
function createSignal(value, options) {
...
const s = {
value,
observers: null,
observerSlots: null,
comparator: options.equals || undefined
};
const setter = value => {
...
return writeSignal(s, value);
};
return [readSignal.bind(s), setter];
}
createSignal的主要逻辑很简单,就是返回响应的getter、setter函数并没有其他复杂逻辑执行:
- 定义包含初始值的s对象,表示上下文对象,后面的getter函数的this对象就是该上下文对象,setter函数也需要使用该上下文对象
- 上下文对象中的observers本意是观察者,必然是发布订阅模式实现的节点级更新逻辑,后面会与相关对应进行关联从而实现的,和Vue底层逻辑相似,但是不像Vue使用Proxy来实现,Solid仅仅就是单纯的函数
getter函数执行
当accessor函数执行时本质上就是执行Signal状态的getter函数,此时就会执行readSignal函数,下面是简化的逻辑:
function readSignal() {
...
if (Listener) {
const sSlot = this.observers ? this.observers.length : 0;
if (!Listener.sources) {
Listener.sources = [this];
Listener.sourceSlots = [sSlot];
} else {
Listener.sources.push(this);
Listener.sourceSlots.push(sSlot);
}
if (!this.observers) {
this.observers = [Listener];
this.observerSlots = [Listener.sources.length - 1];
} else {
this.observers.push(Listener);
this.observerSlots.push(Listener.sources.length - 1);
}
}
...
return this.value;
}
主要逻辑就是:当Listener全局变量存在的情况下,就会将Listener存入上下文对象的observers属性中,那么Listener什么时候有值呢?源码中全局查找Listenerd的赋值操作,就有一个地方,即updateComputation函数的处理,但是该函数的调用来源很多。在初始化阶段updateComputation函数的调用链上面就已经描述清楚了,其来源于createRenderEffect。
updateComputation中Listener的相关处理逻辑如下:
function updateComputation(node) {
if (!node.fn) return;
...
Listener = Owner = node;
runComputation(node, Transition && Transition.running && Transition.sources.has(node) ? node.tValue : node.value, time);
...
}
这里的node参数就是createComputation函数的对象,即Computation对象,该对象的属性有:
const c = {
fn,
state: state,
updatedAt: null,
owned: null,
sources: null,
sourceSlots: null,
cleanups: null,
value: init,
owner: Owner,
context: Owner ? Owner.context : null,
pure
};
所以Listener本质上就是Computation对象,其中该对象的fn属性存放的就是触发响应性状态getter函数的回调函数。
所以当初始化调用链触发getter函数执行时,Listener就已经存在,之后的处理逻辑就是:
- 对应Signal状态的上下文对象中保存Listener,即Signal状态上下文中保存Listener到Observes属性中
- Listener指向的Computation对象的sources属性会保存对应Signal状态的上下文
此时Computation对象与Signal状态上下文对象就互相关联起来了。后续初始化的处理逻辑就是将生成的节点通过DOM插入方法添加到节点中,没有虚拟DOM Diff的过程,这里就不继续说明了。
故而函数组件在初始化阶段内部的处理逻辑就非常清晰,具体如下:
- insert -> createRenderEffect -> 顺序执行createComputation、updateComputation
- updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数
- createRenderEffect传递的回调函数内部逻辑会顺序执行Signal状态getter函数、insertExpression,insertExpression会将Signal状态节点插入到父节点中
更新阶段
现在点击了按钮,此时上面实例中:首先是先调用getter函数,然后再调用setter函数。此时Signal状态的getter再次被执行,但是Listener会在每次赋值操作后被重置为之前的状态,即初始化阶段updateComputation最后处理时被重置为null,所以getter函数此时执行仅仅返回当前值而已。
setter函数执行
执行了setter函数,此时就会执行writeSignal函数,去看看该函数做了什么处理,下面是简化的逻辑(移除了Transition相关的逻辑):
function writeSignal(node, value, isComp) {
let current = node.value;
if (!node.comparator || !node.comparator(current, value)) {
...
node.value = value;
if (node.observers && node.observers.length) {
runUpdates(() => {
for (let i = 0; i < node.observers.length; i += 1) {
const o = node.observers[i];
...
if (TransitionRunning ? !o.tState : !o.state) {
if (o.pure) Updates.push(o);else Effects.push(o);
if (o.observers) markDownstream(o);
}
if (!TransitionRunning) o.state = STALE;else o.tState = STALE;
}
if (Updates.length > 10e5) {
Updates = [];
if (false) ;
throw new Error();
}
}, false);
}
}
return value;
}
在初始化处理阶段,Signal状态对应的上下文对象中observers已经保存了Listener对应的Computation对象。在更新阶段时setter函数被调用,此时observers是有值的。故而其调用栈逻辑如下:
- writeSignal -> runUpdates -> 顺序执行传入的回调函数、completeUpdates
- 这里的回调函数的逻辑实际上就是将Computation对象更加对应的条件条件到Updates、Effects队列中
- completeUpdates -> runQueue(对Updates、Effects 进行处理) -> 循环遍历队列依次处理,即依次调用runTop -> 一般情况下就会调用updateComputation函数
需要注意的是更新阶段updateComputation的处理有一个重要的逻辑,即cleanNode函数调用:
function updateComputation(node) {
...
cleanNode(node);
...
Listener = Owner = node;
...
}
function cleanNode() {
if (node.sources) {
while (node.sources.length) {
const source = node.sources.pop(),
index = node.sourceSlots.pop(),
obs = source.observers;
if (obs && obs.length) {
const n = obs.pop(), s = source.observerSlots.pop();
if (index < obs.length) {
n.sourceSlots[s] = index;
obs[index] = n;
source.observerSlots[index] = s;
}
}
}
}
}
Computation对象与Signal状态上下文对象建立的关联在cleanNode对象中被解绑了,即当某个Signal状态更新后,Solid会将对应Signal状态的旧Computation解绑,这就是更新阶段cleanNode主要处理逻辑。
updateComputation之后的处理就是设置新的Listener对象,之后的处理逻辑就更初始化时相同,即:
updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数
从而实现最新的Computation对象与Signal状态上下文关联,之后更新到对应的DOM节点,这个过程就实现了节点级别的视图更新。
在初始化阶段因为createRenderEffect传递的回调函数的参数中就有当前响应性状态所在位置的父节点,通过闭包特性保证了每次更新都是同一节点位置,从而实现Solid的节点级别的视图更新逻辑。
副作用处理
createEffect API是Solid用来处理副作用的方法,该方法类比React的useEffect,不同于useEffect的是不需要指明依赖项,它会自动收集依赖项。
通过下面实例来了解createEffect的执行过程:
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [getCount, setCount] = createSignal(1);
const increment = () => setCount(getCount() + 1);
createEffect(() => {
console.log(getCount());
});
return (
<>
<button type="button" onClick={increment}>
点击
</button>
<div>{getCount()}</div>
</>
);
}
render(() => <Counter />, document.getElementById("app")!);
初始化阶段
createEffect方法的逻辑具体如下:
function createEffect(fn, value, options) {
runEffects = runUserEffects;
const c = createComputation(fn, value, false, STALE),
s = SuspenseContext && useContext(SuspenseContext);
if (s) c.suspense = s;
if (!options || !options.render) c.user = true;
Effects ? Effects.push(c) : updateComputation(c);
}
在之前分析createSignal就梳理了初始化过程的关键处理过程,知道相关函数的作用:
- createComputation:就是创建新的Computation对象
- updateComputation:就是解绑对应Signal状态上下文对象与旧的Computation对象之间关联,之后将当前新的Computation对象重新与Signal状态上下文对象绑定
在初始化阶段createEffect逻辑主要就是两点:
- 创建一个新的Computation对象,即每一个createEffect都会对应一个Computation对象
- 判断Effects队列是否存在,Effects为数组就会将Computation对象保存到Effects队列中
当render函数执行时其内部会调用runUpdates函数,之后会执行completeUpdates函数,该函数内部逻辑就是处理Effects、Updates队列中内容。根据render函数的处理过程可知:
createEffect内部保存在Effects队列中副作用逻辑,会在组件挂载到节点之后被执行,初始化阶段createEffect副作用函数会立即执行,此时并没有于Signal状态上下文建立关联
当副作用逻辑执行时,如果内部存在Signal状态对象就会执行其getter函数,从而将当前Computation对象与Signal状态上下文建立关联,从而实现依赖的收集。
更新阶段
当createEffect对应的Computation与Signal状态对象建立关联后,所谓的更新阶段就是Signal状态更新时的处理逻辑流程了,实际上就是处理createEffect对应的Computation对象而已。这里需要注意的是更新阶段createEffect的处理有以下几点说明:
- createEffect对应的Computation对象的处理总是在视图节点的Computation对象之后的
- Solid中createEffect整个的处理过程是同步而非异步,不同于React useEffect的异步处理
总结
实际上从Solid的风格以及底层原理实现,你可以看到其他框架的影子:
- Solid底层实现和Vue底层响应性实现非常相似,都是基于发布订阅模式实现两个对象的绑定从而构建响应的基石,只不过在Vue中Dep对象与Watcher对象,Solid中是Signal状态上下文对象和Computation对象
- 除了采用React Hooks,Solid只支持函数组件这一种形式,还支持并发渲染即时间分片,底层实现方式与React相似,这里不再赘述了
这里梳理下Solid整体的主要处理流程,具体如下图: