浅谈前端框架原理

7,888 阅读12分钟

最近在看卡颂大佬的 《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 描述,框架渲染 UI
  • UI = 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 的变化

框架内部运行机制的实现,可以概括为以下两个步骤:

  1. 根据 state 计算出 UI 变化,如, Vue 和 React 通过对比变化前后的 VNode,知道需要更新哪些元素
  2. 根据 UI 变化,执行具体宿主(如浏览器)的 API

为什么需要分离成两个步骤?

前端框架通常会抽离出一套抽象的元素操作的 API,例如:新增/删除/移动元素、修改元素属性等原子操作。不会直接操作浏览器 DOM。这样为了做到平台无关

例如:React、Vue 可以开发浏览器、Canvas、安卓、IOS 的系统/应用,因为其本身不与任何平台耦合,只需要提供相应的宿主 API,就能做到跨平台使用框架。

不同框架,主要的差异其实是在步骤一,如何根据 state 找到 UI 变化的部分

从 state 找到 UI 变化的部分,可以有以下三种路径,去找到 UI 变化的部分:

  • 数据变化 > 应用变化 > 比对应用 > 更新元素

  • 数据变化 > 组件变化 > 比对组件 > 更新元素

  • 数据变化 > 元素变化 > 更新元素

与之对应的,即按 state 变化后,引起框架的 UI 变更的抽象层级,作为分类依据,可以将框架分为三类:

  • 应用级框架
  • 组件级框架
  • 元素级框架

image-20230130195734310

无论哪种路径,都是从最开始的数据变化,到最终的更新元素。只是不同框架,能够监听的变化层级不同,从而有了不同的处理

框架能够监听的层级越抽象,就需要花费更多的时间用于比对变化。例如应用级框架,需要比对整个应用前后的变化。

在我们常见的框架中:

  • 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

image-20230130210203415

上面是 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

image-20230130212344094

可以大概看出来,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 的修仙秘籍(点击可跳转)