最近在看卡颂大佬的《React 设计原理》,看了第一章,就有一种醍醐灌顶的感觉,于是决定记录分享一下这一章的内容。这里也极力推荐各位小伙伴读一下。
本人其实是 Vue 开发者,没有太多地使用过 React,只是多多少少听过一些概念,能看懂一些 React 代码
因此我的文章,会更多的以一个 Vue 开发者的角度去讲述这些
为什么要读这本书呢?
作为一个仅仅使用过 Vue 的开发者,其实我不会去在意 Vue 和 React 哪个好,这种比较没什么意义,重要的是哪个适合自己/团队,能为自己/团队实现价值。因此我其实一直在等一个比较全面的机会去了解 React 这个框架,想知道它为什么会这么火爆,跟 Vue 的差别是什么?
恰逢看到各大博主都在推这本新书,我也买了一本来读一下~
这书果然不负众望,让我对前端框架的认知,从仅仅是 Vue 如何使用、技术实现,提升到了一个更高的层次,从更高的维度去认知框架。
以前我关注的是,Vue 的这个特性是怎么实现的,那现在的关注的是,为了达到这个目的,不同框架,会如何进行设计?
卡颂大佬在《React 设计原理》中,提出了一个观点:现代前端框架的实现原理都可以用以下公式进行概括:
UI = f(state)
其中:
这个公式说明,框架内部运行机制根据当前状态渲染视图,这也能看出现代框架的一个重要特性:数据驱动
不过我在看书的时候,脑子蹦出了这个想法,为什么不是下面这个公式呢:
UI = f(state, UI描述)
这个公式表述的是:框架根据状态和 UI 描述,渲染出视图。
因为我们写界面的时候,其实是写 UI(如 template)+ script(state)的,这也是组件的组成部分。
后来我想了想,其实这两个说法,其实应该都是对的,只是角度不同:
UI = f(state, UI描述)
,是从开发者编码时,开发模式的角度进行描述,说的是,开发者提供 state 和 UI 描述,框架渲染 UIUI = f(state)
,则是在运行时,从系统运行角度,说的是,UI 在运行过程中根据状态的改变而改变。由于运行过程中,UI 描述不再改变,因此 UI 描述不作为公式的自变量接下来,我们围绕一下两点进行讲述:
前端领域经过长期发展,形成了两种主流的 UI 描述方案:
JSX 是 Meta(原 Facebook)提出的一种 ECMAScript 的语法糖,增强了代码的可读性,但其实最终 JSX 在运行时会被转换成浏览器能够识别的标准 ECMAScript 语法。
const element = (
<div>
<h1>Hello!</h1>
<h2>Good to see you here.</h2>
</div>
);
template 模板的历史更加久远,它是前后端未分离的时代,已经有的产物,它扩充的是 HTML 语法:
<script setup>
import { ref } from 'vue'
const msg = ref('Hello World!')
</script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg">
</template>
不同的框架,模板语法可能会有些许不同,但都是基于 HTML 语法进行扩展。
两种 UI 描述方案,它们的实现不同,但目的都是描述 UI。JSX 扩展 ES 语法,灵活性高。模板灵活性低,但这也意味着,分析它的难度更低,可以做一些编译时的优化。
在数据驱动的框架中,状态变化,会引起 UI 的变化
框架内部运行机制的实现,可以概括为以下两个步骤:
为什么需要分离成两个步骤?
前端框架通常会抽离出一套抽象的元素操作的 API,例如:新增/删除/移动元素、修改元素属性等原子操作。不会直接操作浏览器 DOM。这样为了做到平台无关
例如:React、Vue 可以开发浏览器、Canvas、安卓、IOS 的系统/应用,因为其本身不与任何平台耦合,只需要提供相应的宿主 API,就能做到跨平台使用框架。
不同框架,主要的差异其实是在步骤一,如何根据 state 找到 UI 变化的部分
从 state 找到 UI 变化的部分,可以有以下三种路径,去找到 UI 变化的部分:
与之对应的,即按 state 变化后,引起框架的 UI 变更的抽象层级,作为分类依据,可以将框架分为三类:
无论哪种路径,都是从最开始的数据变化,到最终的更新元素。只是不同框架,能够监听的变化层级不同,从而有了不同的处理
框架能够监听的层级越抽象,就需要花费更多的时间用于比对变化。例如应用级框架,需要比对整个应用前后的变化。
在我们常见的框架中:
三种框架用的内部实现不太相同,接下来会讲述一下它们可能用到的一些技术。
这是一种自动追踪依赖的技术,它用于自动追踪依赖的状态,当状态改变时进行更新。
例如下面代码:
const x = ref(1);
const y = computed(() => x.value * 2);
y
会自动追踪 x
,当 x
改变时,y
也会跟着改变,否则 y
不会改变。
如果没有使用响应式技术,如 React,想要实现如下效果,需要显示的进行声明依赖:
const y = useMemo(() => x * 2, [x]);
关于 Vue 的响应式实现,可以参考我写的这篇文章《六千字详解!vue3 响应式是如何实现的?》[1],这里再稍微总结一下。
需要实现响应式,需要使用 effect 函数进行包裹,下面是一个测试用例:
it('should be reactive', () => {
const a = ref(1)
let dummy
let calls = 0
effect(() => {
calls++
dummy = a.value
})
expect(calls).toBe(1)
expect(dummy).toBe(1)
a.value = 2
expect(calls).toBe(2)
expect(dummy).toBe(2)
// same value should not trigger
a.value = 2
expect(calls).toBe(2)
expect(dummy).toBe(2)
})
effect 函数会自动收集函数中使用到的响应式变量,然后当它们改变时,重新执行 effect 的回调函数。
利用这个特性,我们将 UI 的组件 render 函数,传入到 effect 函数中,那么当响应式变量改变,就会重新执行组件的渲染函数,这就是 Vue 这个组件级框架的基本实现原理。
应用级框架需要使用这个技术吗?
响应式技术,能够实现细粒度更新,例如组件粒度的更新。
而应用级框架不需要这么细的粒度,因此可以有更简单的方式实现,不需要用到响应式技术,杀鸡不需要用到牛刀~
元素级框架可以使用这个技术吗?
理论上应该是可行的,但一般不会这么做。因为依赖收集,是需要在运行时,存储到变量中的。如果每个元素都进行依赖收集,会消耗大量的资源,因此不适合。
虚拟 DOM 的知识往上说的很多了,这里稍微描述一下
虚拟 DOM(或者说 VDOM、VNode),它的作用是:
VDOM 有以下优点:
VDOM 可以多平台渲染能力,但反过来,多平台渲染能力,不一定需要 VDOM VDOM 的最终目的,其实是用于 Diff,计算出 UI 中变化的部分。但刚好又可以用于多平台渲染。
应用级框架和组件级框架,需要使用 VDOM 配合 Diff 算法,计算出 UI 中变化的元素。
组件级框架,如 Svelte,由于可以直接精准的找到 UI 变化的部分,不需要 Diff,则可以直接不使用 VDOM 技术
现在前端框架一般都有编译这一步骤,用于:
编译有两个执行时机:
它们的区别如下:
因此,在大多数情况下,我们使用 AOT 更多。不过有些框架(例如 Vue)会同时提供了 AOT 和 JIT 两种使用方式,以应对一些特殊的情况
AOT 能对模板语法编译进行优化,可以减少【根据 state 计算出 UI 变化】的花销,因此使用模板语法的框架能够从 AOT 中受益。
为什么 AOT 能对模板语法编译进行优化?
因为模板语法是固定的,相对于 ECMAScript 语法,灵活性低,但这也意味着分析的难度更低。可以分析模板语法中,动态部分和静态部分,用于提升性能。
例如 Vue,我们直接看这个 Vue PlayGround[2]
上面是 Vue 编译时,将静态 HTML 的创建提升,不需要每次更新组件都创建新的 VNode 对象,从而提升心更难
const __sfc__ = {
__name: 'App',
setup(__props) {
const msg = ref('Hello World!')
return (_ctx, _cache) => {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_hoisted_1,
// 看最后一个参数,1 /* TEXT */,标记这个元素的 Text 会变化
_createElementVNode("h1", null, _toDisplayString(msg.value), 1 /* TEXT */)
], 64 /* STABLE_FRAGMENT */))
}
}
}
Vue 编译时,可以从模板中获取信息,用于提升性能
如上图,Vue 编译的代码中,在 _createElementVNode
的最后一个参数中,会多传入一个 1(称为 PatchFlag
,注释为 Text),代表该元素的 Text 会变化,那么在更新时,只需要比对 Text 即可,从而提升了 Diff 的性能。
Vue 其实有非常多的编译优化,这个可以以后找时间再聊。
对 Vue 来说,编译优化,是一种提升性能的手段,没有也行,就是慢点而已。
Svelte 是一个极致的编译时框架,是一款重度依赖 AOT 的元素级框架。
我们看看这个 playGround[3]
可以大概看出来,**Svelte
文件编译后的代码,就直接创建元素了**(例如 DOM),而不是像 Vue 那样先编译成渲染函数,然后在运行时通过渲染函数返回的 VNode,再去创建元素。
如果有更新 UI 操作,则会编译出直接操作元素的代码。
Svelte 的基本原理,这篇文章就不讲了,篇幅有限,而且没用过 hhh,感兴趣的自己找找网上的资料
AOT 可以对 JSX 进行优化吗?
JSX 目前难以从 AOT 中收益,原因是 ECMAScript 太灵活了,难以实现静态分析。
例如:js 的对象可以复制、修改、导入导出等,用 js 变量存储的 jsx 内容,无法判断是否为静态内容,因为可能在不知道哪个地方就被修改了,无法做静态标记。
但也并不是完全没有办法,例如可以通过约束 JSX 的灵活性,使其能够被静态分析,例如 SolidJS。
本文讲述了现代前端框架实现原理公式 —— UI = f(state) ,然后讲述了 UI 描述和数据驱动两个部分
最后介绍了前端框架的三种重要技术:
[1]
《六千字详解!vue3 响应式是如何实现的?》: https://juejin.cn/post/7048970987500470279
[2]
Vue PlayGround: https://sfc.vuejs.org/#eNo9j71OxDAQhF9lcROQOFu0UUCi4w1otjnC5i6n+Ee7Gygsvzu2L6KyZ8bzaZzNe0r2ZyczmklmXpOCkO7pDcPqU2SFDEwLFFg4ehjq0wEDhjkGUfBygdeWPw4ftG0RPiNv3w/DE4bJ3XEVVIWST9tZqSqA6frST4Be6pY7vJbl3MGlHO7k/uvm2dxnnfw52ZvEUIfnVsQjEDQjdKd5dW7TaK6qSUbnZJnbd29iI19cvVneg66eLIk/fXH8FeIKRtMQBUMx5Q/UT2Lu
[3]
playGround: https://www.sveltejs.cn/examples#hello-world