在上一篇文章中,深入的了解了 props 相关的一些内容,还涉及到了一点点组件编译相关的。
Solid 之旅 —— 为什么 props 被解构后会导致响应式丢失
通过这篇文章,主要了解两个方面:
- Solid 的组件编译
- Solid 是如何实现 Signal 的插入和细颗粒度更新的?
先从一个最简单的案例来说,不添加任何响应式数据,先了解组件编译后的内容。
Solid 官方提供了 playground 可以进行调试
组件编译
import { render } from "solid-js/web";
function Counter() {
return (
<button type="button">
click
</button>
);
}
render(() => <Counter />, document.getElementById("app")!);
编译后
import { template as _$template } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
var _tmpl$ = /*#__PURE__*/_$template(`<button type=button>click`);
import { render } from "solid-js/web";
function Counter() {
return _tmpl$();
}
render(() => _$createComponent(Counter, {}), document.getElementById("app"));
我们一个个来看,<Counter>
组件内的 html 标签被使用一个名叫 template
函数调用了
template
来看一下 template 函数的源码:
export function template(html, isCE, isSVG) {
let node;
const create = () => {
const t = document.createElement("template");
t.innerHTML = html;
return isSVG ? t.content.firstChild.firstChild : t.content.firstChild;
};
// backwards compatible with older builds
const fn = isCE
? () => untrack(() => document.importNode(node || (node = create()), true))
// 返回一个克隆的节点,然后再对其进行操作
: () => (node || (node = create())).cloneNode(true);
fn.cloneNode = fn;
return fn;
}
这个函数的主要目的就是根据 html 字符串创建一个模板元素。
create
函数用来根据 html 参数创建一个模板fn
用来根据这个模板克隆出来一份 DOM
可以直接拿到控制台实践一下,效果就是这样:
对于 <template>
这一块内容,如果不熟悉的话,可以看一下我之前的文章。
结束标签的省略问题
对于编译后的内容没有结束标签,算是一种小优化。
通过省略闭合标签,编译器可以生成更简洁的代码,从而减少内存使用和提高性能。
本质上是利用了浏览器渲染来进行兜底。
可以拿一个 html 来进行测试:
<button type=button>click
最终渲染出来的结果:
<button type=button>click</button>
这时候浏览器会自动修复错误。
但不是所有情况都可以省略结束标签了,像下面这种情况:
<div>
<span id="text">hello
<button id="btn">点击</button>
</div>
浏览器渲染的效果就是:
<div>
<span id="text">
hello
<button id="btn">点击</button>
</span>
</div>
像这种情况,浏览器的自动修复就出现了问题。因为浏览器会外向内对标签进行匹配,最后再进行修复。
最终得的结论,就是最后一个元素的结束标签是可以省略的,即:
<div>
<div>
<span id="text">hello</span>
<button id="btn">点击
</div>
<div>
<span id="text">hello</span>
<aside>ttt
</div>
最终就会将所有的结束标签加上:
<div>
<div>
<span id="text">hello</span>
<button id="btn">点击</button>
</div>
<div>
<span id="text">hello</span>
<aside>ttt</aside>
</div>
</div>
template
函数的原理就讲完了,再回过头来看编译后的函数,我们接着往下看,Counter 组件这里没什么说的了,返回了克隆出来的模板。
createComponent
往下接着看 <Counter />
被编译成了 createComponent(Counter, {})
,这个在之前讲《Solid 之旅 —— 为什么 props 被解构后会导致响应式丢失》的时候涉及到过,这里再提一下。
来看一下 createComponent
函数:
export function createComponent<T>(Comp: Component<T>, props: T): JSX.Element {
if ("_SOLID_DEV_") return devComponent(Comp, props || ({} as T));
return untrack(() => Comp(props || ({} as T)));
}
很简单,单纯的执行了一下这个组件,因为通过前面的响应式原理那边,我们也知道,Solid 的更新靠的是 Signal
、Effect
等等响应式函数实现了细颗粒度更新。
render
再回头来看最后的 render
函数:
export function render(code, element, init, options = {}) {
if ("_DX_DEV_" && !element) {
throw new Error(
"The `element` passed to `render(..., element)` doesn't exist. Make sure `element` exists in the document."
);
}
// disposer 用于接收 root(createRoot)的清理函数
let disposer;
root(dispose => {
disposer = dispose;
element === document
// code 实质就是一个 createComponent 函数,它会经过编译返回一个 JSX.Element
? code()
: insert(element, code(), element.firstChild ? null : undefined, init);
}, options.owner);
return () => {
disposer();
element.textContent = "";
};
}
这里面涉及一个 root
→ createRoot
函数,我们先通过官网文档看一下它的用处:
Creates a new non-tracked owner scope that doesn’t auto-dispose. This is useful for nested reactive scopes that you do not wish to release when the parent re-evaluates.
All Solid code should be wrapped in one of these top level as they ensure that all memory/computations are freed up. Normally you do not need to worry about this as createRoot is embedded into all render entry functions.
即:
创建一个不自动释放的新非跟踪所有者范围。这对于你不希望在父级上下文重新计算时释放的嵌套响应式作用域很有用。
所有 Solid 代码都应该包装在这些顶级代码之一中,因为它们确保所有内存/计算都被释放。通常,您无需担心这一点,因为 createRoot 已嵌入到所有渲染入口函数中。
也就是说 createRoot
用于创建一个独立的响应式上下文。
- “这里的不自动释放”指的是会返回一个手动清理的函数给你
- “不希望在父级上下文重新计算时释放的嵌套响应式”指的就是响应式的重新计算之后在当前上下文中进行执行,不会影响其他响应式上下文,也就是独立的。
我们在到 createRoot
内部看一下:
export function createRoot<T>(fn: RootFunction<T>, detachedOwner?: typeof Owner): T {
const listener = Listener,
owner = Owner,
unowned = fn.length === 0,
current = detachedOwner === undefined ? owner : detachedOwner,
// 这里创建了一个 root,即响应式上下文
root: Owner = unowned
? "_SOLID_DEV_"
? { owned: null, cleanups: null, context: null, owner: null }
: UNOWNED
: {
owned: null,
cleanups: null,
context: current ? current.context : null,
// 这里做了 owner 树
owner: current
},
updateFn = unowned
? "_SOLID_DEV_"
? () =>
fn(() => {
throw new Error("Dispose method must be an explicit argument to createRoot function");
})
: fn
// 返回一个手动 dispose 的函数
: () => fn(() => untrack(() => cleanNode(root)));
Owner = root;
Listener = null;
try {
return runUpdates(updateFn as () => T, true)!;
} finally {
Listener = listener;
Owner = owner;
}
}
这里的处理是有点类似于 runComputation
的,具体的不在过多介绍,可以看一下响应式原理那一篇文章。
这里的 cleanNode
也就是清理函数,在响应式原理那边也讲过,会在 completeUpdates
之后进行自动清理;也就是上面官网说的会在上层更新的时候自动清理响应式。
后面就是加入到 Updates
进行根据优先级进行更新执行 fn 了。
接着往下看,在 root 里面执行了 insert(element, code(), element.firstChild ? null : undefined, init)
。
insert
函数很重要,不仅仅在这里,这后续实现 Signal
的插入和更新都至关重要。
export function insert(parent, accessor, marker, initial) {
if (marker !== undefined && !initial) initial = [];
if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
// 这里先忽略
effect(current => insertExpression(parent, accessor(), current, marker), initial);
}
insert
函数内部接着调用了 insertExpression(parent, accessor, initial, marker);
实现最终的插入逻辑。
这里 accesor
即 <Counter />
组件是一个 DOM 元素,不是函数,所以走上面这个 insertExpression
。
function insertExpression(parent, value, current, marker, unwrapArray) {
while (typeof current === "function") current = current();
if (value === current) return current;
const t = typeof value,
multi = marker !== undefined;
parent = (multi && current[0] && current[0].parentNode) || parent;
// 暂时省略其他判断代码
if (value.nodeType) {
if (hydrating && value.parentNode) return (current = multi ? [value] : value);
if (Array.isArray(current)) {
if (multi) return (current = cleanChildren(parent, current, marker, value));
cleanChildren(parent, current, null, value);
} else if (current == null || current === "" || !parent.firstChild) {
parent.appendChild(value);
} else parent.replaceChild(value, parent.firstChild);
current = value;
} else if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, value);
return current;
}
insertExpression
整个函数很长,又很多类型的判断逻辑和实现逻辑,这里只截取了部分用到的地方。现在我们暂时只需要知道几个参数:
- parent:父元素
- value:指
<Counter>
组件的 DOM元素
其他参数暂时用不到,这里就会走 value.nodeType
这一块的逻辑,然后忽略 hydrating
和 current
等等,最终就会走到 parent.appendChild(value);
这里,实现最终的插入。
到这里,我们大概知道了 render
函数所作的事情:
- 创建一个独立的响应式上下文
- 将跟组件插入到页面元素上进行渲染
如何实现 Signal 的插入和细颗粒度更新的?
接下来,我们给案例添加 Signal
,看看 Solid
是如何实现 Signal
的插入和更新的。
以官方的源案例为例:
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(1);
const increment = () => setCount(count => count + 1);
return (
<button type="button" onClick={increment}>
{count()}
</button>
);
}
render(() => <Counter />, document.getElementById("app")!);
编译后:
import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
var _tmpl$ = /*#__PURE__*/_$template(`<button type=button>`);
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(1);
const increment = () => setCount(count => count + 1);
return (() => {
var _el$ = _tmpl$();
_el$.$$click = increment;
_$insert(_el$, count);
return _el$;
})();
}
render(() => _$createComponent(Counter, {}), document.getElementById("app"));
_$delegateEvents(["click"]);
想比较之前的,这里主要变化的在于 内部实现了一个 Signal 的插入逻辑;先暂且忽略 Solid 对 事件的处理。
原理
这里注意一个细节,尽管我们在组件内给 button
的值是 count()
,但编译后,还是还原成了 count
,这对实现后面的响应式很关键,因为如果直接变成 count()
的话,那么就是简简单单的一个字面量了,不要再说后续的东西了。
先来看一下 _$insert(_el$, count);
,为什么一个简单的插入就能实现后续的细颗粒度更新。
export function insert(parent, accessor, marker, initial) {
if (marker !== undefined && !initial) initial = [];
if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
effect(current => insertExpression(parent, accessor(), current, marker), initial);
}
我们知道 count 实际是一个函数,所以这次会走下面的 insertExpression
,相较于上面,这里多了个 effect,也就是 createEffect
,这就是最最最关键的一点。
Signal 凭什么实现响应式,就是和 Computation(Effect)结合,然后内部进行依赖收集,相互关联,最终实现响应式。这里也是同一个道理。
我们先不往里面看,单单看这一行 effect(current => insertExpression(parent, accessor(), current, marker), initial);
。
这里我们知道了 effect 和 count 之间做了依赖收集,那么,在后续的 count 更新的时候,就会找到该 effect,也就是重新执行了一遍 insertExpression
,这不就实现了只有 Signal 更新的细颗粒度嘛!!!
如果对于响应式这一块忘了或者不熟悉,可以重温一下之前响应式那一篇文章。
回过来再想想,其实发现很简单。
接下来,在重新看一下 insertExpression
的剩余代码:
再来解释一下几个参数的含义:
parent
:父节点value
:最新值current
:当前值,这里会从effect
上拿取t
:value 的类型
function insertExpression(parent, value, current, marker, unwrapArray) {
while (typeof current === "function") current = current();
// 如果没有变化,则直接返回
if (value === current) return current;
const t = typeof value,
multi = marker !== undefined;
parent = (multi && current[0] && current[0].parentNode) || parent;
// 对于不同类型的处理
if (t === "string" || t === "number") {
if (t === "number") {
value = value.toString();
if (value === current) return current;
}
if (multi) {
let node = current[0];
if (node && node.nodeType === 3) {
node.data !== value && (node.data = value);
} else node = document.createTextNode(value);
// cleanChildren 对于单节点直接清空,多节点则进行匹配替换
current = cleanChildren(parent, current, marker, node);
} else {
// 处理
if (current !== "" && typeof current === "string") {
// data 是 TextNode 节点属性,用于获取或设置文本节点的内容
current = parent.firstChild.data = value;
} else current = parent.textContent = value;
}
} else if (value == null || t === "boolean") {
current = cleanChildren(parent, current, marker);
} else if (t === "function") {
// 如果还是函数,继续递归
effect(() => {
let v = value();
while (typeof v === "function") v = v();
current = insertExpression(parent, v, current, marker);
});
return () => current;
} else if (Array.isArray(value)) {
const array = [];
const currentArray = current && Array.isArray(current);
// 如果需要解包数组,则进行解包
if (normalizeIncomingArray(array, value, current, unwrapArray)) {
// 数组需要响应式处理
effect(() => (current = insertExpression(parent, array, current, marker, true)));
return () => current;
}
// 数组的逻辑处理
if (array.length === 0) {
current = cleanChildren(parent, current, marker);
if (multi) return current;
} else if (currentArray) {
if (current.length === 0) {
// 追加
appendNodes(parent, array, marker);
// 协调新旧数组的差异
} else reconcileArrays(parent, current, array);
} else {
current && cleanChildren(parent);
appendNodes(parent, array);
}
current = array;
} else if (value.nodeType) {
if (hydrating && value.parentNode) return (current = multi ? [value] : value);
if (Array.isArray(current)) {
if (multi) return (current = cleanChildren(parent, current, marker, value));
cleanChildren(parent, current, null, value);
} else if (current == null || current === "" || !parent.firstChild) {
parent.appendChild(value);
} else parent.replaceChild(value, parent.firstChild);
current = value;
} else if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, value);
// current 用于提供给 effect
return current;
}
insertExpression
主要就是对于不同类型进行的特殊处理,实现最后的插入和更新。
事件处理
最后再提一嘴 Solid 是如何处理事件的,回到上面的代码,可以看到 Solid 在最后调用了 delegateEvents(["click"]);
直接来看一下这个函数:
// 添加事件代理,对指定的事件添加到 document 上进行代理
export function delegateEvents(eventNames, document = window.document) {
const e = document[$$EVENTS] || (document[$$EVENTS] = new Set());
for (let i = 0, l = eventNames.length; i < l; i++) {
const name = eventNames[i];
if (!e.has(name)) {
e.add(name);
// 添加对应的事件处理函数
// name 和正常事件名称一直,但组件元素的事件名称是 $$xxx 这种形式,最终在 eventHandler 上做处理
document.addEventListener(name, eventHandler);
}
}
}
用于将事件名称列表的事件委托添加到 document
中。它在 $$EVENTS
中维护一组委托事件。
其实就是正常的事件代理到 document
上,Solid 将其抽出来的原因是只对用到的事件进行处理,进行了一些优化。
总结
到这里,差不多对Solid 的组件编译有个大概了解了,最主要的是学习到了Solid 是如何实现 Signal 的插入和细颗粒度更新的,了解了它是如何实现的之后,也发现原理是如此的简单