前端框架分类和响应式更新

119 阅读10分钟

当谈到前端框架的时候,我们的脑海中会浮现出主流的三大框架 React、Vue、Angular 等等,但是当我们去看 React 的官网的时候,它会称自己为: A JavaScript library for building user interfaces. (用户构建用户界面的 JavaScript 库),而 Vue 也会宣称自己是 渐进式 JavaScript 框架。

所以我们首先需要探讨的是,什么是库(library),什么是框架(framework):

当我们编写复杂应用的时候,单纯依靠 React 的能力可能不足以完成我们所有功能的编写,比如页面中的路由解决方案,为了解决首屏渲染速度、满足 SEO 搜索引擎的优化等等功能,其实并不是 React 本身所包含的功能所以从技术层面上来看,React 和 Vue 都是 “构建UI的库”,然后我们可以称“包含库本身以及附加功能”的解决方案为框架,例如 Umijs、Nextjs等。Vue 提倡的渐进式指的也是按照需求渐进地引入附加功能。所以第一个问题我们清楚了,Vue、React 本身来说是构建UI的库,Angular是配套完整的框架。

但这三者都在解决一个核心的问题:数据状态与视图更新的关系。

早期的时候,JS 是给当时由文档构建成的页面添加表单交互和一些点击效果的微弱功能,后来随着网页从阅读的媒介转向更复杂的场景的时候,日益增长的需求和冗长的DOM操作产生了冲突,所以逐渐衍生出了一些库来帮助开发者更舒适和便捷的处理UI的各种场景。

1. 前端框架设计原理

UI = ƒ(state)

  • state 代表 “当前视图状态”
  • ƒ 代表 “框架内部运行机制”
  • UI 代表 “宿主环境的视图”

通俗的来讲,就是 我们将数据(状态)丢给框架,框架内部运行机制最后会宿主环境中渲染对应的视图,这也叫做数据驱动。接下来我们会拆解这个公式,来学习为什么会这么设计;

1.1 UI描述的分类

什么是UI描述想必大家在编码的过程不会太陌生,当前主流的两种 描述UI的方案如下:

  • JSX
  • 模版语言

JSX 是Meta提出的一种 “类XML语法”的ES语法糖,React 团队认为 UI 本质上与逻辑存在耦合的部分,然后开发者可以在UI的描述中绑定事件或者改变UI的样式:

const elment = <h1>hello</h1>

// 经过编译之后如下数据结构
{
  	type: 'h1',
  	key: null,
    ref: null,
    props: {
      children: 'hello'
    }
    // ...
}

因为JSX是ES的语法糖,所以它能够更灵活的与其他ES语法组合使用额,这种灵活性可以让我们轻松的描述 复杂的UI,因为我们在编码的时候也经常使用ES的语法。

模版语言的历史是从服务端开始发展的,例如PHP,其代码可以嵌入 HTML 中,当浏览器请求网页时,服务端会执行PHP代码,“填充有执行结果的HTML”作为数据返回。这类的模版语法功能比较强大,但是遇到页面结构复杂时,逻辑代码会不可避免地与UI结合起来应用。

上面提到了与UI相关的另一个词 -- 逻辑,我们前端编码的关键也在于这两个方面,优雅地编写逻辑,正确的展示UI。从逻辑和UI的关系来看上述两个描述UI的方案

  • JSX 是从逻辑出发,扩展逻辑来描述UI;
  • 模版语言,从UI出发,扩展UI来描述逻辑;

两者都达到一开始我们将的公式:UI = ƒ(state),但其对框架的实现也会有影响。

1.2 如何组织UI和逻辑

为了更好的组织UI和逻辑,我们需要引入另一个词,叫做组件。组件的作用是,有机地将UI和逻辑封装在一个松散耦合单元之中。

逻辑和UI的关系是什么?

这里我们运用初中的数学知识,自变量和因变量,当自变量发生变化时,因变量会随之变化。这个原理同样适用于UI和逻辑场景:

  • 这里存在两层关系
  • 逻辑的变量 与 UI描述,属于自变量和因变量的关系;

    • 逻辑中的变量变化,导致 UI 变化;
  • 逻辑中变量与变量之间,也可以存在自变量和因变量的关系:

    • 逻辑中的变量变化,导致 “无副作用的因变量变化”,导致UI变化;

      • 逻辑中的变量变化,导致 “有副作用的因变量变化”,触发副作用;

所以一般的框架中都存在三种模式的变量定义

  • 自变量;
  • 无副作用的因变量;
  • 有副作用的因变量;

什么是副作用?

副作用是函数式编程中的概念,是指在函数执行过程中对外部环境的影响,如果同时满足以下的条件,则称为纯函数:

  • 相同的输入始终获得相同的输出;

    • 不会修改程序的状态或引起副作用,副作用包括修改函数外部变量、调用 DOM API、I/O操作、控制台打印等“函数调用过程中产生的、外部可观察的变化”都属于副作用;

我们这里就拿 Vue 举例,在 Vue 何实现上述的三个变量:

自变量:

// 初始值为 0 的自变量
const x = ref(0);

// 取值
console.log(x.value)

// 赋值
x.value = 2;

无副作用的因变量:

// 依赖 x 的因变量 y
const y = computed(() => x.value * 2 + 1);

console.log(y.value)

有副作用的因变量:

// 调用 DOM API
watchEffect(() => document.title = x.value)

所以我们能够看到,不同的框架中,都存在上述的逻辑与UI的影响方式;

1.3 如何在组件之间传输数据

在编码过程中,我们不需要理解逻辑与UI的关系,也需要考虑每个松散耦合单元之间的组织方式。

组件的自变量或者因变量通过UI传递给另一个组件,作为其自变量。在前端框架中,组件内部定义的自变量称为 state,其他组件传递而来的自变量称为 props。

自变量的传递方式:

  • 层层传递
  • 跨层传递

对于层层传递比较好理解,父子传递时,父组件的 state 都可以作为子组件的 props  属性;而对于跨层传递,也可以通过 store 将自变量直接传递给其他层级,每个框架对于 store 的实现方式不同,在这里暂不展开;

1.4 前端框架分类的依据

在对于上述原理简介到UI描述的分类,再到逻辑与UI的关系以及组件之间的传递数据的方式,我们对于 UI = ƒ(state)应该有了初步的认识,state 的本质就是自变量,自变量通过直接或者间接的方式改变了UI的描述,最后在实际的宿主环境中进行真实渲染。所以 UI = ƒ(state) 进一步概括为两步:

  1. 根据自变量(state)变化,计算出 UI 变化;
  2. 调用具体的宿主环境 API 来执行UI的变化;

不同的框架在第二步的处理中基本一致,主要的区别在于第一个步骤。

假设有如下的组件:

从自变量与元素的变化关系,我们可以看到:

  1. a 变化导致 A 的UI 中的 {a} 变化;
  2. a 变化导致 b 变化,导致 B 的UI 中的 {b+c} 变化;
  3. a 变化导致 C 的UI中的 {a} 变化;
  4. a 变化导致 C 的UI中的{a.toFixed(2)}变化;
  5. c 变化导致 B 的UI中的{b+c}变化;

当某个自变量发生变化时,观察梳理好的路径,即可了解UI中变化的部分,进而执行具体的DOM操作。例如路径5我们可以进行 spanElemnt.textContent = b + c;

从自变量与组件的变化关系,我们可以看到:

  1. a 变化导致 A组件的 UI 变化;
  2. a 变化导致 b 变化,导致 B 组件的UI变化;
  3. a 变化导致 C 的UI变化;
  4. c 变化导致 B 的UI变化;

相较于UI元素的变化,组件的粒度会更粗一些,第4条中,c的变化导致 B 组件的UI变化,但是在B组件中,bing不能确定具体发生变化的内容,这个时候就需要再进一步对比;

从自变量与应用的关系,我们可以看到:

  1. a 变化导致应用中发生UI变化;
  2. c 变化导致引用中发生UI变化;

虽然变化的路径变为两条,但是 确定UI中变化的部分,也是框架中的核心内容。

随着抽象层级不断下降,从应用 -> 组件 -> 元素,自变量到UI的路径增多,路径越多,意味着前端框架在运行时消耗在寻找“自变量与UI的对应关系”上的时间越少。

所以基于这样的层级,我们可以大致将前端框架分为以下几类:

  • 应用级框架,变量的变化让应用发生UI变化;
  • 组件级框架,变量的变化让组件发生UI变化;
  • 元素级框架,变量的变化仅让元素发生UI变化;

2. 响应式更新

在 React 中,我们去定义无副作用的因变量时,需要显示的声明第二个参数,而在 Vue 中是不需要显示指明,其中的“自动追踪依赖的技术”叫做细粒度更新也叫做响应式更新;

我们想要的效果如下:

const [count, setCount] = useState(0);

// effect 1
useEffect(() => {
  console.log('count is:', count()); // 1. 打印 count is: 0
})

// effect 2
useEffect(() => {
  console.log('没我什么事儿'); // 2. 打印 没我什么事儿
})

setCount(2) // 打印count is: 2

当我们想达成这样的目的时,我们需要构建出如下的数据结构和依赖:

useState: image.png useEffect: image.png 代码如下:

// 保存 effect 调用栈
const effectStack = [];

function subscribe(effect, subs) {
  // 建立订阅关系
  subs.add(effect)
  // 建立以来关系
  effect.deps.add(subs)
}

function cleanup(effect) {
  // 从该effect订阅的所有state对应的subs中移除该effect
  for (const subs of effect.deps) {
    subs.delete(effect)
  }
  // 将该effect依赖的所有state对应的subs移除
  effect.deps.clear();
}

function useState(value) {
  // 保存订阅该state的effect
  const subs = new Set();
  
  const getter = () => {
    // 获取当前上下文的 effect
    const effect = effectStack[effectStack.length - 1];
    if (effect) {
      subscribe(effect, subs)
    };
    return value;
  }
  
  const setter = (nextValue) => {
    value = nextValue;
    // 通知所有订阅者执行回调
    for (const effect of [...subs]) {
      effect.execute();
    }
  }
}

function useEffect(callback) {
  const execute = () => {
    // 重置依赖
    cleanup(effect);
    // 将当前effect推入栈顶
    effectStack.push(effect);
    
    try {
      // 执行回调
      callback();
    } finnaly {
      effectStack.pop();
    }
  }
  
  const effect = {
    execute,
    deps: new Set()
  }
  
  // 立刻执行一次,建立订阅关系
  execute();
}

上面的细粒度更新的版本有两个显著的优点:

  • 无需显示指明依赖;
  • 由于可以自动跟踪依赖,因此不受 React Hooks 不能在条件语句中声明 Hooks 的限制;

为什么React Hook 没有采用如上的做法呢,这和其设计理念有些出入,因为React旨在强调 UI 反映的是某一刻的快照,那么既然是快照,那就意味着他是一个全局的的概念,状态的更新会引起整个应用重新 render,所以其更新粒度不需要很细。

学习资料: 《React 设计原理》