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