比React还Vue3的框架SolidJS,为什么性能可以Number 1?

8,198 阅读10分钟

本文正在参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

前言

SolidJS 是什么呢,这位小朋友可谓是非常的“谦虚”,号称支持现代前端特性:JSXFragmentsContextPortalsSuspenseStreaming SSRError Boundaries并发渲染等现代功能。无论是客户端还是服务端,目前的性能评分都最高!

咋一看好家伙,这是什么都有啊,让其他框架怎么活,但当你真的了解它后才会发现这真的是一个宝藏框架呐~

另外由于是后起之星,也没有那么重的历史包袱(类比 React Hook 原理需要兼容 class Component 导致源码过于复杂),其代码非常易读,可以作为新手入门学习使用。

任何框架都可以看作是思想与设计模式的组合,所以我们可以抱着学习的心态去看待这款框架。

学习学习.png

1. 初试牛刀

首先我们看一个简单的计数器 Demo,

import { render } from 'solid-js/web';
import { createSignal, createEffect } from 'solid-js';

const Counter = () => {
  const [getCount, setCount] = createSignal(0);
  const add = () => setCount(getCount() + 1);
  createEffect(() => {
    console.log('count is change:', getCount());
  });

  return (
    <button type='button' onClick={add}>
      {getCount()}
    </button>
  );
};

render(() => <Counter />, document.getElementById('root'));

咋一看,嚯~ 这不就是 React 嘛~

还有 effect 也不用手动声明依赖了,看着又有一点 Vue3 的影子。

来看看上面代码的打包产物,

打包资源产物.jpg

发现js文件竟然不到8K,真的可以这么精致吗!

其实呀,SolidJS不仅打包体积小,性能也是 Number 1

来看看 js-framework-benchmark 跑分结果

性能对比

有同学说了,你这不乱说嘛,第一明明是 Vanilla

实际上,Vanilla 就是纯粹的原生 JavaScript,通常作为一个性能比较的基准。

那么 SolidJS 为什么既做到了体积小,还做到了性能强,甚至超越了前端巨头 VueReact 呢?

凭什么呀.png

接下来我们简单分析其原理来一探究竟!

我今天就要好好看看.png

2. 庐山真面目

2-1、平衡了 jsx 与 template 的利弊

说到这里不得不提一下 jsxtemplate 的优缺点

  • jsx
    • 优点:作为js的语法糖拥有高度灵活性,可以随意编写
    • 缺点:因为过于灵活在 编译阶段 很难分析操作意图
  • template
    • 优点:因为语法有限制,大部分带有 操作意图(v-if、v-for) 的代码都可以在 编译阶段被识别以做优化
    • 缺点:写法受限,大部分情况下不如jsx灵活

有同学可能会疑惑了,编译阶段 能做什么,识别操作意图 有那么重要吗?

说到这里,不得不提一下 Vue3

Vue3 对比 Vue2 性能之所以实现了一个质的飞跃,这其中就离不开 编译阶段优化

  • 1、 比如在编译阶段标记出template中永远不会变化的节点作为静态节点存储,将来更新时直接绕过他们;

  • 2、提前对v-if、v-for这一类区块做区分,将来diff时绕过不必要的判断;

  • 3、绑定props时记录哪些属性可能会变,将来 diff 时只对比“可能会变化的动态节点和属性”,跳过“永远不会变化的节点和属性”。

  • 除此之外还有缓存事件处理程序等等

这么一看,编译阶段是很有必要哈,但jsx为什么不能做这些呢?

为什么呀,是我哪里不好嘛.png

想象一下,假如我们要做一个 根据条件渲染组件 的功能, 在 template 中默认我们只能用指令v-if来实现:

<template>
  <div>
    <span v-if="status === 1">通过</span>
    <span v-else-if="status === 2">拒绝</span>
  </div>
</template>

但在 JSX 中有很多写法,随便就可以想到三种:

return status === 1 ? <span>通过</span> : status === 2 ? <span>拒绝</span> : null;
return (
  <>
    {status === 1 && <span>通过</span>}
    {status === 2 && <span>拒绝</span>}
  </>
);
switch (status) {
  case 1:
    return <span>通过</span>;
  case 2:
    return <span>拒绝</span>;
}

如果每种情况都去判断一遍,那么 编译阶段 将会非常复杂且耗时,另外显得也非常麻瓜。

挠头.png

Vue3 之所以做了那么多 编译时 优化离不开其特定的 模板语法 ,在限制约束的前提下做了很多可识别的优化。

JSX 因为太过于自由使得很难做 意图分析,看来太自由也不是一件十全十美的事情哈

过火.png

SolidJS 采用的方案是:在 JSX 的基础上做了一层规范,中文译名为 控制流

写法上类似某种预设的组件,用于编译阶段优化:

return (
  <Switch>
    <Match when={status === 1}>
      <span>通过</span>
    </Match>
    <Match when={status === 2}>
      <span>拒绝</span>
    </Match>
  </Switch>
);

这样在编译阶段就可以做意图分析,提前知道这是在做按条件渲染,然后编译成对应的dom操作即可。

可以简洁概括为:

  • 即借鉴了 template 更容易做编译阶段优化的优势

  • 又保留了 JSX 的灵活性

2-2. No Dom Diff

No Dom Diff 是说 SolidJS更新粒度方面,摒弃了虚拟dom,采用节点级更新

说到更新粒度,可以先总结下目前前端主流的几种方案:

  • 应用级更新:状态更新会引起整个应用render,具体渲染哪些内容取决于协调的结果。代表作有 React
  • 组件级更新:状态更新时只会引起绑定了该状态的组件渲染,具体渲染哪些内容同样取决于协调的结果。代表作有vue2.x
  • 节点级更新:状态更新时直接触法绑定该状态的节点更新,也就是指向型更新。代表作有vue1.xSvelteSolidJS

或许你会奇怪了,React 不也是组件级更新吗?

那我们可以思考一个问题:为什么hook有顺序的限制、useEffect中有脏数据?

那种感觉又上来了.png

这是因为 React 每次更新都会重新走一遍更新流程,做这些限制是为了获取到完整的VDom树/Fiber树,通过 diff新旧两棵树来决定真正更新哪些组件,所以 React 并不是组件级更新。这也就是为什么在React Fiber架构出现之前如果优化不当容易出现更新卡顿问题,而Vue2.x并不会的原因。

我打我自己.gif

Vue1.x当初摒弃了节点级更新,是因为那时候的 Vue响应式原理 采用三大对象:Observer、Dep、Watcher,三者都是复杂对象(参考复杂对象和简单对象),由于还要递归观察data下的子对象,所以一个应用中的观察者会非常多,很容易因占用过多内存而出现卡顿问题。所以到了Vue2.x就改用组件级更新了。

SolidJS对于三大对象均采用简单对象存储,另外不需要递归观察,所以占用内存非常少。

解决了占用内存多的问题,接下来是如何更新dom,具体的做法是:在编译阶段提前生成类似 insertupdatedeletedom操作方法,将来更新时直接调用。

或许有的同学还有其他疑惑:虚拟 dom 当初诞生的原因可不仅仅是为了节省 dom 开销,还有一个更重要的原因是为了跨平台开发React NativeWeex),那 SolidJS 如何实现跨平台渲染呢?

细心的你可能已经发现了这行代码

import { render } from 'solid-js/web';

没错,SolidJSReact 的理念非常像,作为 渐进式框架,他们都将 核心库渲染库 分离开来,

solid-js/webweb 平台节点操作方法集,如果我们将来要做 自定义渲染,只需要再实现一个 solid-js/Android 或者 solid-js/IOS 即可,是不是思路很新颖,跨平台与性能兼得!

听着怪有道理.png

2-3. 重·编译时

  • 提前生成节点渲染方法

    刚才说到 SolidJSjsx 中借鉴了部分 template 的规范写法,在编译阶段 分析意图,提前生成对应的dom操作方法

  • 按需打包,缩小体积

    这一步也就是 tree-shaking,只打包用到的模块,近一步缩小打包资源体积。

2-4. 轻·运行时

由于没有了diff这一大规模计算,使得运行时代码更轻量,所以SolidJS在更新时也更简洁

  • 这是 SolidJS 在更新时的js调用栈

    SolidJS

  • 而这是 React v16 在更新时的js调用栈

    React v16

    另外这一些特性使得另外一件事情更容易落地:微前端

    现在微前端的一大痛点是多个项目打包出来的资源体积过大,而轻运行时会使得代码体积将对小巧,也因此非常适合做Web Component(这是w3c提出的一个新标准)。想象一下:未来VueReact都做到了轻·运行时,我们只需要把组件编译成Web Component,然后在主应用直接引用即可。再也不用为不同技术栈、不同版本差异所苦恼了!

真滴嘛你真好.png

2-5. 不被顺序限制的 hook

说到前端框架中的 Hook,最先将这个方案落地的是React,但由于React一直推崇 immutable 思想,每次更新必须重新走一遍整个树的更新流程,使得 React Hook 不可以在条件循环中使用,否则可能使渲染结果受到影响,注意是可能,不是一定哦~

后来尤大发布了Vue3.0,伴随而来的一大特性是Composition API,俗称Vue3 hook,由于Vue2以后都采用组件级更新粒度,再加上响应式原理采用的是自动收集依赖,所以Vue3 hook不会有顺序/条件的限制,另外还可以嵌套使用。

SolidJS响应式原理主要借鉴了React Hook的思想,同时也保留了Vue3依赖收集模型,所以用起来非常丝滑。

拿来吧你.gif

不过有意思的是,对于 numberstringboolean 这一类非引用类型 stateVue3 将其自动封装成具有 current 属性的对象:

const count = ref(0)

const show = () => {
  console.log('count:' + count.value)
}

因为基本数据类型不可以被Proxy代理,无法实现数据劫持

到了SolidJS,干脆就把获取数据的方式变成了 getter 方法

const [getCount, setCount] = createSignal(0)

const show = () => {
  console.log('count:' + getCount())
}

在方法内部同样可以做 数据劫持 ,简直巧妙呀!

大概流程是这样:

  1. getter 方法内部将依赖收集起来
  2. setter 方法中触法依赖更新

发布订阅

更详细的原理请参考这篇文章【Vue3 源码】从源码层面观察 vue 的变化 - 响应式原理

Vue3 不同的是,Vue3 依然保留了 虚拟 dom ,所以 diff 依然会占用 运行时 时间,

SolidJS则忽略了这一步,所以性能更好。

惊不惊喜.jpeg

3. 其他

  • 脚手架:degit,内部集成了 vite

  • 支持TS类型友好

  • 现代前端框架大部分特性:FragmentsPortalsContextSuspense事件委托SSR等等

真是典型的“我全都要”

我全都要.jpeg

4. 总结与思考

大家都说前端的技术发展是一个轮回

比如目前比较火热的 Tailwind css,很像现代版的 bootstrap

SSR 很像当初 jsp 的现代版

前端框架的打包产物逐渐缩小体积,试图回到原生js项目的体积

原生 DOM 经历了 虚拟 DOM 会不会又会慢慢回归到 原生 DOM呢~

当然最关心的一个问题就是 SolidJS 能否上 生产环境 ?个人感觉还是先不要着急,可以用在 个人项目 练练手,或者作为一个学习源码的好机会(毕竟这个框架拥有现代前端的大多数特性,而且没有那么重的历史版本包袱,相比React还是比较容易读的)