SolidJS 是一个语法像 React Function Component,内核像 Vue 的前端框架,本周我们通过阅读 Introduction to SolidJS 这篇文章来理解理解其核心概念。
为什么要介绍 SolidJS 而不是其他前端框架?因为 SolidJS 在教 React 团队正确的实现 Hooks,这在唯 React 概念与虚拟 DOM 概念马首是瞻的年代非常难得,这也是开源技术的魅力:任何观点都可以被自由挑战,只要你是对,你就可能脱颖而出。
整篇文章以一个新人视角交代了 SolidJS 的用法,但本文假设读者已有 React 基础,那么只要交代核心差异就行了。
SolidJS 仅支持 FunctionComponent 写法,无论内容是否拥有状态管理,也无论该组件是否接受来自父组件的 Props 透传,都仅触发一次渲染函数。
所以其状态更新机制与 React 存在根本的不同:
与 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:
在 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。
count()
不能写成 count
?笔者也没找到答案,理论上来说,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 时代一样。
本文作者:前端小毛
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!