最近在看卡颂大佬的 《React 设计原理》,看了第一章,就有一种醍醐灌顶的感觉,于是决定记录分享一下这一章的内容。这里也极力推荐各位小伙伴读一下。
本人其实是 Vue 开发者,没有太多地使用过 React,只是多多少少听过一些概念,能看懂一些 React 代码
因此我的文章,会更多的以一个 Vue 开发者的角度去讲述这些
为什么要读这本书呢?
作为一个仅仅使用过 Vue 的开发者,其实我不会去在意 Vue 和 React 哪个好,这种比较没什么意义,重要的是哪个适合自己/团队,能为自己/团队实现价值。因此我其实一直在等一个比较全面的机会去了解 React 这个框架,想知道它为什么会这么火爆,跟 Vue 的差别是什么?
恰逢看到各大博主都在推这本新书,我也买了一本来读一下~
这书果然不负众望,让我对前端框架的认知,从仅仅是 Vue 如何使用、技术实现,提升到了一个更高的层次,从更高的维度去认知框架。
以前我关注的是,Vue 的这个特性是怎么实现的,那现在的关注的是,为了达到这个目的,不同框架,会如何进行设计?
前端框架
卡颂大佬在《React 设计原理》中,提出了一个观点:现代前端框架的实现原理都可以用以下公式进行概括:
UI = f(state)
其中:
- state —— 当前的视图的状态
- f —— 框架内部的运行机制
- UI —— 宿主环境的视图
这个公式说明,框架内部运行机制根据当前状态渲染视图,这也能看出现代框架的一个重要特性:数据驱动
不过我在看书的时候,脑子蹦出了这个想法,为什么不是下面这个公式呢:
UI = f(state, UI描述)
这个公式表述的是:框架根据状态和 UI 描述,渲染出视图。
因为我们写界面的时候,其实是写 UI(如 template)+ script(state)的,这也是组件的组成部分。
后来我想了想,其实这两个说法,其实应该都是对的,只是角度不同:
UI = f(state, UI描述)
,是从开发者编码时,开发模式的角度进行描述,说的是,开发者提供 state 和 UI 描述,框架渲染 UIUI = f(state)
,则是在运行时,从系统运行角度,说的是,UI 在运行过程中根据状态的改变而改变。由于运行过程中,UI 描述不再改变,因此 UI 描述不作为公式的自变量
接下来,我们围绕一下两点进行讲述:
-
UI 描述
-
数据/状态驱动,不同的数据驱动模式,其内部实现机制也会不同
如何描述 UI
前端领域经过长期发展,形成了两种主流的 UI 描述方案:
- JSX
- template
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 的变化
框架内部运行机制的实现,可以概括为以下两个步骤:
- 根据 state 计算出 UI 变化,如, Vue 和 React 通过对比变化前后的 VNode,知道需要更新哪些元素
- 根据 UI 变化,执行具体宿主(如浏览器)的 API。
为什么需要分离成两个步骤?
前端框架通常会抽离出一套抽象的元素操作的 API,例如:新增/删除/移动元素、修改元素属性等原子操作。不会直接操作浏览器 DOM。这样为了做到平台无关
例如:React、Vue 可以开发浏览器、Canvas、安卓、IOS 的系统/应用,因为其本身不与任何平台耦合,只需要提供相应的宿主 API,就能做到跨平台使用框架。
不同框架,主要的差异其实是在步骤一,如何根据 state 找到 UI 变化的部分
从 state 找到 UI 变化的部分,可以有以下三种路径,去找到 UI 变化的部分:
-
数据变化 > 应用变化 > 比对应用 > 更新元素
-
数据变化 > 组件变化 > 比对组件 > 更新元素
-
数据变化 > 元素变化 > 更新元素
与之对应的,即按 state 变化后,引起框架的 UI 变更的抽象层级,作为分类依据,可以将框架分为三类:
- 应用级框架
- 组件级框架
- 元素级框架
无论哪种路径,都是从最开始的数据变化,到最终的更新元素。只是不同框架,能够监听的变化层级不同,从而有了不同的处理
框架能够监听的层级越抽象,就需要花费更多的时间用于比对变化。例如应用级框架,需要比对整个应用前后的变化。
在我们常见的框架中:
- React 属于应用级框架
- Vue 属于组件级框架
- Svelte 属于元素级框架
三种框架用的内部实现不太相同,接下来会讲述一下它们可能用到的一些技术。
前端框架用到的技术
响应式
这是一种自动追踪依赖的技术,它用于自动追踪依赖的状态,当状态改变时进行更新。
例如下面代码:
const x = ref(1);
const y = computed(() => x.value * 2);
y
会自动追踪 x
,当 x
改变时,y
也会跟着改变,否则 y
不会改变。
如果没有使用响应式技术,如 React,想要实现如下效果,需要显示的进行声明依赖:
const y = useMemo(() => x * 2, [x]);
关于 Vue 的响应式实现,可以参考我写的这篇文章《六千字详解!vue3 响应式是如何实现的?》,这里再稍微总结一下。
需要实现响应式,需要使用 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 函数包裹的函数体,拥有了响应性 —— 当 effect 内的函数中的 ref 对象 a.value 被修改时,该函数会自动重新执行
- 当 a.value 被设置成同一个值时,函数并不会自动的重新执行。
effect 函数会自动收集函数中使用到的响应式变量,然后当它们改变时,重新执行 effect 的回调函数。
利用这个特性,我们将 UI 的组件 render 函数,传入到 effect 函数中,那么当响应式变量改变,就会重新执行组件的渲染函数,这就是 Vue 这个组件级框架的基本实现原理。
应用级框架需要使用这个技术吗?
响应式技术,能够实现细粒度更新,例如组件粒度的更新。
而应用级框架不需要这么细的粒度,因此可以有更简单的方式实现,不需要用到响应式技术,杀鸡不需要用到牛刀~
元素级框架可以使用这个技术吗?
理论上应该是可行的,但一般不会这么做。因为依赖收集,是需要在运行时,存储到变量中的。如果每个元素都进行依赖收集,会消耗大量的资源,因此不适合。
Virtual DOM
虚拟 DOM 的知识往上说的很多了,这里稍微描述一下
虚拟 DOM(或者说 VDOM、VNode),它的作用是:
- 描述 UI
- 通过对比 VDOM 前后的变化,计算出 UI 中变化的部分。即 Diff。
VDOM 有以下优点:
- 相对于 DOM 有体积优势
- 多平台渲染能力
VDOM 可以多平台渲染能力,但反过来,多平台渲染能力,不一定需要 VDOM
VDOM 的最终目的,其实是用于 Diff,计算出 UI 中变化的部分。但刚好又可以用于多平台渲染。
应用级框架和组件级框架,需要使用 VDOM 配合 Diff 算法,计算出 UI 中变化的元素。
元素级框架,如 Svelte,由于可以直接精准的找到 UI 变化的部分,不需要 Diff,则可以直接不使用 VDOM 技术
AOT 预编译优化
现在前端框架一般都有编译这一步骤,用于:
- 代码转换,如:ts 编译为 js,Vue 将 vue 文件转换成 js
- 编译优化
- 代码压缩、打包
编译有两个执行时机:
- 构建时编译(AOT,预编译)
- 运行时编译(JIT,即时编译)
它们的区别如下:
- AOT 可以提前进行编译,用户直接运行编译后的代码,可以减少首屏时间。而 JIT 则会消耗更多时间用于编译
- JIT 的应用代码体积会更大,因为需要包含编译的相关逻辑
因此,在大多数情况下,我们使用 AOT 更多。不过有些框架(例如 Vue)会同时提供了 AOT 和 JIT 两种使用方式,以应对一些特殊的情况
AOT 能对模板语法编译进行优化,可以减少【根据 state 计算出 UI 变化】的花销,因此使用模板语法的框架能够从 AOT 中受益。
为什么 AOT 能对模板语法编译进行优化?
因为模板语法是固定的,相对于 ECMAScript 语法,灵活性低,但这也意味着分析的难度更低。可以分析模板语法中,动态部分和静态部分,用于提升性能。
例如 Vue,我们直接看这个 Vue PlayGround
上面是 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
可以大概看出来,Svelte
文件编译后的代码,就直接创建元素了(例如 DOM),而不是像 Vue 那样先编译成渲染函数,然后在运行时通过渲染函数返回的 VNode,再去创建元素。
如果有更新 UI 操作,则会编译出直接操作元素的代码。
Svelte 的基本原理,这篇文章就不讲了,篇幅有限,而且没用过 hhh,感兴趣的自己找找网上的资料
AOT 可以对 JSX 进行优化吗?
JSX 目前难以从 AOT 中收益,原因是 ECMAScript 太灵活了,难以实现静态分析。
例如:js 的对象可以复制、修改、导入导出等,用 js 变量存储的 jsx 内容,无法判断是否为静态内容,因为可能在不知道哪个地方就被修改了,无法做静态标记。
但也并不是完全没有办法,例如可以通过约束 JSX 的灵活性,使其能够被静态分析,例如 SolidJS。
总结
本文讲述了现代前端框架实现原理公式 —— UI = f(state) ,然后讲述了 UI 描述和数据驱动两个部分
-
UI 描述中,讲述了 JSX 和 template 模板的区别,它们的目的都是描述 UI,只是代表着不同的开发模式而已。不同的框架语法虽有不同,但都是这两种形式。
-
数据驱动部分,按 state 变化后,引起框架的 UI 变更的抽象层级,对框架进行了分类,分为应用级、组件级、元素级框架。
最后介绍了前端框架的三种重要技术:
- 响应式技术,实现了细粒度的更新,是组件级应用的一种实现
- 虚拟 DOM,最终目的是快速找出一组 UI 元素中变化的部分,应用级和组件级框架需要使用。元素级框架由于直接指导变化的元素,因此不需要
- AOT 预编译优化,使用模板的框架,能从 AOT 预编译优化中受益,因为模板的结构固定,容易分析。JSX 则难以优化,除非约束 JSX 的灵活性
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)