网络日志

精读《SolidJS》

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 雕。

更完善的 Hooks 实现

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,生命周期函数有 onMountonCleanUp,状态监听函数有 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 的神秘面纱已经解开了,下面笔者自问自答一些问题。

为什么 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 的绑定不支持解构

由于响应式特性,解构会丢失代理的特性:

// ✅
const App = (props) => <div>{props.userName}</div>;
// ❎
const App = ({ userName }) => <div>{userName}</div>;

虽然也提供了 splitProps 解决该问题,但此函数还是不自然。该问题比较好的解法是通过 babel 插件来规避。

createEffect 不支持异步

没有 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 时代一样。

讨论地址是:精读《SolidJS》· Issue #438 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证