SolidJS 是一个语法像 React Function Component,内核像 Vue 的前端框架,本周我们通过阅读 Introduction to SolidJS 这篇文章来理解理解其核心概念。
为什么要介绍 SolidJS 而不是其他前端框架?因为 SolidJS 在教 React 团队正确的实现 Hooks,这在唯 React 概念与虚拟 DOM 概念马首是瞻的年代非常难得,这也是开源技术的魅力:任何观点都可以被自由挑战,只要你是对,你就可能脱颖而出。
整篇文章以一个新人视角交代了 SolidJS 的用法,但本文假设读者已有 React 基础,那么只要交代核心差异就行了。
SolidJS 仅支持 FunctionComponent 写法,无论内容是否拥有状态管理,也无论该组件是否接受来自父组件的 Props 透传,都仅触发一次渲染函数。
所以其状态更新机制与 React 存在根本的不同:
- React 状态变化后,通过重新执行 Render 函数体响应状态的变化。
- Solid 状态变化后,通过重新执行用到该状态代码块响应状态的变化。
与 React 整个渲染函数重新执行相对比,Solid 状态响应粒度非常细,甚至一段 JSX 内调用多个变量,都不会重新执行整段 JSX 逻辑,而是仅更新变量部分:
const App = ({ var1, var2 }) => (
<>
var1: {console.log("var1", var1)}
var2: {console.log("var2", var2)}
</>
);
上面这段代码在 var1
单独变化时,仅打印 var1
,而不会打印 var2
,在 React 里是不可能做到的。
这一切都源于了 SolidJS 叫板 React 的核心理念:面向状态驱动而不是面向视图驱动。正因为这个差异,导致了渲染函数仅执行一次,也顺便衍生出变量更新粒度如此之细的结果,同时也是其高性能的基础,同时也解决了 React Hooks 不够直观的顽疾,一箭 N 雕。
SolidJS 用 createSignal
实现类似 React useState
的能力,虽然看上去长得差不多,但实现原理与使用时的心智却完全不一样:
const App = () => {
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
};
我们要完全以 SolidJS 心智理解这段代码,而不是 React 心智理解它,虽然它长得太像 Hooks 了。一个显著的不同是,将状态代码提到外层也完全能 Work:
const [count, setCount] = createSignal(0);
const App = () => {
return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
};
这是最快理解 SolidJS 理念的方式,即 SolidJS 根本没有理 React 那套概念,SolidJS 理解的数据驱动是纯粹的数据驱动视图,无论数据在哪定义,视图在哪,都可以建立绑定。
这个设计自然也不依赖渲染函数执行多次,同时因为使用了依赖收集,也不需要手动申明 deps 数组,也完全可以将 createSignal
写在条件分支之后,因为不存在执行顺序的概念。
用回调函数方式申明派生状态即可:
const App = () => {
const [count, setCount] = createSignal(0);
const doubleCount = () => count() * 2;
return <button onClick={() => setCount(count() + 1)}>{doubleCount()}</button>;
};
这是一个不如 React 方便的点,因为 React 付出了巨大的代价(在数据变更后重新执行整个函数体),所以可以用更简单的方式定义派生状态:
// React
const App = () => {
const [count, setCount] = useState(0);
const doubleCount = count * 2; // 这块反而比 SolidJS 定义的简单
return (
<button onClick={() => setCount((count) => count + 1)}>
{doubleCount}
</button>
);
};
当然笔者并不推崇 React 的衍生写法,因为其代价太大了。我们继续分析为什么 SolidJS 这样看似简单的衍生状态写法可以生效。原因在于,SolidJS 收集所有用到了 count()
的依赖,而 doubleCount()
用到了它,而渲染函数用到了 doubleCount()
,仅此而已,所以自然挂上了依赖关系,这个实现过程简单而稳定,没有 Magic。
SolidJS 还支持衍生字段计算缓存,使用 createMemo
:
const App = () => {
const [count, setCount] = createSignal(0);
const doubleCount = () => createMemo(() => count() * 2);
return <button onClick={() => setCount(count() + 1)}>{doubleCount()}</button>;
};
同样无需写 deps 依赖数组,SolidJS 通过依赖收集来驱动 count
变化影响到 doubleCount
这一步,这样访问 doubleCount()
时就不用总执行其回调的函数体,产生额外性能开销了。
对标 React 的 useEffect
,SolidJS 提供的是 createEffect
,但相比之下,不用写 deps,是真的监听数据,而非组件生命周期的一环:
const App = () => {
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log(count()); // 在 count 变化时重新执行
});
};
这再一次体现了为什么 SolidJS 有资格 “教” React 团队实现 Hooks:
- 无 deps 申明。
- 将监听与生命周期分开,React 经常容易将其混为一谈。
在 SolidJS,生命周期函数有 onMount
、onCleanUp
,状态监听函数有 createEffect
;而 React 的所有生命周期和状态监听函数都是 useEffect
,虽然看上去更简洁,但即便是精通 React Hooks 的老手也不容易判断哪些是监听,哪些是生命周期。
为什么 SolidJS 可以这么神奇的把 React 那么多历史顽疾解决掉,而 React 却不可以呢?核心原因还是在 SolidJS 增加的模板编译过程上。
以官方 Playground 提供的 Demo 为例:
function Counter() {
const [count, setCount] = createSignal(0);
const increment = () => setCount(count() + 1);
return (
<button type="button" onClick={increment}>
{count()}
</button>
);
}
被编译为:
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$;
})();
}
首先把组件 JSX 部分提取到了全局模板。初始化逻辑:将变量插入模板;更新状态逻辑:由于 insert(_el$, count)
时已经将 count
与 _el$
绑定了,下次调用 setCount()
时,只需要把绑定的 _el$
更新一下就行了,而不用关心它在哪个位置。
为了更完整的实现该功能,必须将用到模板的 Node 彻底分离出来。我们可以测试一下稍微复杂些的场景,如:
<button>
count: {count()}, count+1: {count() + 1}
</button>
这段代码编译后的模板结果是:
const _el$ = _tmpl$.cloneNode(true),
_el$2 = _el$.firstChild,
_el$4 = _el$2.nextSibling;
_el$4.nextSibling;
_el$.$$click = increment;
insert(_el$, count, _el$4);
insert(_el$, () => count() + 1, null);
将模板分成了一个整体和三个子块,分别是字面量、变量、字面量。为什么最后一个变量没有加进去呢?因为最后一个变量插入直接放在 _el$
末尾就行了,而中间插入位置需要 insert(_el$, count, _el$4)
给出父节点与子节点实例。
SolidJS 的神秘面纱已经解开了,下面笔者自问自答一些问题。
React Hooks 使用 deps 收集依赖,在下次执行渲染函数体时,因为没有任何办法标识 “deps 是为哪个 Hook 申明的”,只能依靠顺序作为标识依据,所以需要稳定的顺序,因此不能出现条件分支在前面。
而 SolidJS 本身渲染函数仅执行一次,所以不存在 React 重新执行函数体的场景,而 createSignal
本身又只是创建一个变量,createEffect
也只是创建一个监听,逻辑都在回调函数内部处理,而与视图的绑定通过依赖收集完成,所以也不受条件分支的影响。
因为 SolidJS 函数体仅执行一次,不会存在组件实例存在 N 个闭包的情况,所以不存在闭包问题。
React 响应的是组件树的变化,通过组件树自上而下的渲染来响应式更新。而 SolidJS 响应的只有数据,甚至数据定义申明在渲染函数外部也可以。
所以 React 虽然说自己是响应式,但开发者真正响应的是 UI 树的一层层更新,在这个过程中会产生闭包问题,手动维护 deps,hooks 不能写在条件分支之后,以及有时候分不清当前更新是父组件 rerender 还是因为状态变化导致的。
这一切都在说明,React 并没有让开发者真正只关心数据的变化,如果只要关心数据变化,那为什么组件重渲染的原因可能因为 “父组件 rerender” 呢?
虚拟 dom 虽然规避了 dom 整体刷新的性能损耗,但也带来了 diff 开销。对 SolidJS 来说,它问了一个问题:为什么要规避 dom 整体刷新,局部更新不行吗?
对啊,局部更新并不是做不到,通过模板渲染后,将 jsx 动态部分单独提取出来,配合依赖收集,就可以做到变量变化时点对点的更新,所以无需进行 dom diff。
笔者也没找到答案,理论上来说,Proxy 应该可以完成这种显式函数调用动作,除非是不想引入 Mutable 的开发习惯,让开发习惯变得更加 Immutable 一些。
由于响应式特性,解构会丢失代理的特性:
// ✅
const App = (props) => <div>{props.userName}</div>;
// ❎
const App = ({ userName }) => <div>{userName}</div>;
虽然也提供了 splitProps
解决该问题,但此函数还是不自然。该问题比较好的解法是通过 babel 插件来规避。
没有 deps 虽然非常便捷,但在异步场景下还是无解:
const App = () => {
const [count, setCount] = createSignal(0);
createEffect(() => {
async function run() {
await wait(1000);
console.log(count()); // 不会触发
}
run();
});
};
SolidJS 的核心设计只有一个,即让数据驱动真的回归到数据上,而非与 UI 树绑定,在这一点上,React 误入歧途了。
虽然 SolidJS 很棒,但相关组件生态还没有起来,巨大的迁移成本是它难以快速替换到生产环境的最大问题。前端生态想要无缝升级,看来第一步是想好 “代码范式”,以及代码范式间如何转换,确定了范式后再由社区竞争完成实现,就不会遇到生态难以迁移的问题了。
但以上假设是不成立的,技术迭代永远都以 BreakChange 为代价,而很多时候只能抛弃旧项目,在新项目实践新技术,就像 Jquery 时代一样。
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)