Vue 3 中的按需响应式
已发表: 2022-03-11除了令人钦佩的性能改进之外,最近发布的 Vue 3 还带来了几个新特性。 可以说最重要的介绍是Composition API 。 在本文的第一部分,我们回顾了新 API 的标准动机:更好的代码组织和重用。 在第二部分中,我们将专注于使用新 API 的讨论较少的方面,例如实现在 Vue 2 的反应性系统中无法表达的基于反应性的功能。
我们将其称为按需反应性。 在介绍了相关的新特性之后,我们将构建一个简单的电子表格应用程序来展示 Vue 响应式系统的新表现力。 最后,我们将讨论这种改进的按需响应性在现实世界中的用途。
Vue 3 的新特性及其重要性
Vue 3 是对 Vue 2 的一次重大改写,引入了大量改进,同时几乎完全保留了与旧 API 的向后兼容性。
Vue 3 中最重要的新特性之一是Composition API 。 首次公开讨论时,它的引入引发了很多争议。 如果您还不熟悉新 API,我们将首先描述其背后的动机。
通常的代码组织单元是一个 JavaScript 对象,它的键代表一个组件的各种可能类型。 因此,该对象可能有一个部分用于响应数据( data
),另一个部分用于计算属性( computed
),另一个部分用于组件方法( methods
)等。
在这种范式下,一个组件可以具有多个不相关或松散相关的功能,其内部工作分布在上述组件部分之间。 例如,我们可能有一个用于上传文件的组件,它实现了两个本质上独立的功能:文件管理和控制上传状态动画的系统。
<script>
部分可能包含如下内容:
export default { data () { return { animation_state: 'playing', animation_duration: 10, upload_filenames: [], upload_params: { target_directory: 'media', visibility: 'private', } } }, computed: { long_animation () { return this.animation_duration > 5; }, upload_requested () { return this.upload_filenames.length > 0; }, }, ... }
这种传统的代码组织方法有很多好处,主要是开发人员不必担心在哪里编写新代码。 如果我们要添加一个反应变量,我们将其插入data
部分。 如果我们正在寻找一个现有的变量,我们知道它必须在data
部分。
这种将功能实现拆分为部分( data
、 computed
等)的传统方法并不适用于所有情况。
经常引用以下例外情况:
- 处理具有大量功能的组件。 例如,如果我们想升级我们的动画代码以延迟动画的开始,我们将不得不在代码编辑器中组件的所有相关部分之间滚动/跳转。 对于我们的文件上传组件,组件本身很小,它实现的功能数量也很少。 因此,在这种情况下,在各个部分之间跳转并不是真正的问题。 当我们处理大型组件时,这个代码碎片问题变得很重要。
- 传统方法缺乏的另一种情况是代码重用。 通常,我们需要在多个组件中提供反应数据、计算属性、方法等的特定组合。
Vue 2(以及向后兼容的 Vue 3)为大多数代码组织和重用问题提供了解决方案: mixins 。
Vue 3 中 Mixins 的优缺点
Mixin 允许在单独的代码单元中提取组件的功能。 每个功能都放在一个单独的 mixin 中,每个组件都可以使用一个或多个 mixin。 在 mixin 中定义的片段可以在组件中使用,就像它们在组件本身中定义一样。 mixin 有点像面向对象语言中的类,因为它们收集与给定功能相关的代码。 与类一样,mixin 可以在其他代码单元中继承(使用)。
但是,使用 mixins 进行推理比较困难,因为与类不同,mixin 的设计不需要考虑封装。 Mixin 可以是松散绑定的代码片段的集合,没有明确定义的外部世界接口。 在同一组件中一次使用多个 mixin 可能会导致组件难以理解和使用。
大多数面向对象的语言(例如,C# 和 Java)不鼓励甚至不允许多重继承,尽管面向对象的编程范例具有处理这种复杂性的工具。 (某些语言确实允许多重继承,例如 C++,但组合仍然比继承更受欢迎。)
在 Vue 中使用 mixin 时可能出现的一个更实际的问题是名称冲突,当使用两个或多个 mixins 声明通用名称时会发生这种问题。 这里需要注意的是,如果 Vue 处理名称冲突的默认策略在给定情况下并不理想,则可以由开发人员调整该策略。这是以引入更多复杂性为代价的。
另一个问题是 mixins 不提供类似于类构造函数的东西。 这是一个问题,因为我们经常需要非常相似但不完全相同的功能出现在不同的组件中。 在一些简单的情况下,可以使用 mixin 工厂来规避这种情况。
因此,mixin 并不是代码组织和重用的理想解决方案,而且项目越大,它们的问题就越严重。 Vue 3 引入了一种新方法来解决有关代码组织和重用的相同问题。
组合 API:Vue 3 对代码组织和重用的回答
组合 API 允许我们(但不要求我们)完全解耦组件的各个部分。 每一段代码——变量、计算属性、手表等——都可以独立定义。
例如,我们现在可以编写(在我们的 JavaScript 代码中的任何位置),而不是让一个对象包含一个data
部分,该部分包含一个键animation_state
和(默认)值“正在播放”:
const animation_state = ref('playing');
效果几乎和在某些组件的data
部分声明这个变量一样。 唯一的本质区别是我们需要使在组件外部定义的ref
在我们打算使用它的组件中可用。 我们通过将其模块导入到定义组件的位置并从组件的setup
部分返回ref
来做到这一点。 我们现在将跳过这个过程,只关注新的 API 片刻。 Vue 3 中的反应性不需要组件; 它实际上是一个独立的系统。
我们可以在我们将此变量导入到的任何范围内使用变量animation_state
。 在构造一个ref
之后,我们使用ref.value
获取并设置它的实际值,例如:
animation_state.value = 'paused'; console.log(animation_state.value);
我们需要“.value”后缀,因为赋值运算符会将(非反应性)值“暂停”分配给变量animation_state
。 JavaScript 中的响应性(无论是在 Vue 2 中通过defineProperty
实现,还是在 Vue 3 中基于Proxy
实现时)都需要一个对象,我们可以响应地使用其键。
请注意,在 Vue 2 中也是如此; 在那里,我们有一个组件作为任何反应数据成员( component.data_member
)的前缀。 除非 JavaScript 语言标准引入了重载赋值运算符的能力,否则反应式表达式将需要一个对象和一个键(例如,上面的animation_state
状态和value
)出现在我们希望的任何赋值操作的左侧保持反应性。
在模板中,我们可以省略.value
,因为 Vue 必须预处理模板代码并且可以自动检测引用:
<animation :state='animation_state' />
理论上,Vue 编译器也可以以类似的方式预处理单个文件组件 (SFC) 的<script>
部分,在需要的地方插入.value
。 但是,根据我们是否使用 SFC,对refs
的使用会有所不同,所以也许这样的特性甚至是不可取的。
有时,我们有一个我们从不打算用完全不同的实例替换的实体(例如,一个 Javascript 对象或数组)。 相反,我们可能只对修改其关键字段感兴趣。 在这种情况下有一个简写:使用reactive
而不是ref
可以让我们省去.value
:
const upload_params = reactive({ target_directory: 'media', visibility: 'private', }); upload_params.visibility = 'public'; // no `.value` needed here // if we did not make `upload_params` constant, the following code would compile but we would lose reactivity after the assignment; it is thus a good idea to make reactive variables ```const``` explicitly: upload_params = { target_directory: 'static', visibility: 'public', };
使用ref
和reactive
解耦反应性并不是 Vue 3 的全新特性。它在 Vue 2.6 中部分引入,其中这种解耦的反应性数据实例被称为“可观察对象”。 在大多数情况下,可以将Vue.observable
替换为reactive
。 区别之一是直接访问和改变传递给Vue.observable
的对象是反应式的,而新的 API 返回一个代理对象,因此改变原始对象不会产生反应式效果。

Vue 3 的全新之处在于,除了响应式数据之外,组件的其他响应式部分现在也可以独立定义。 计算属性以预期的方式实现:
const x = ref(5); const x_squared = computed(() => x.value * x.value); console.log(x_squared.value); // outputs 25
同样,可以实现各种类型的手表、生命周期方法和依赖注入。 为简洁起见,我们不会在这里介绍这些内容。
假设我们使用标准 SFC 方法进行 Vue 开发。 我们甚至可能使用传统的 API,数据、计算属性等具有单独的部分。我们如何将 Composition API 的少量反应性与 SFC 集成? Vue 3 为此引入了另一个部分: setup
。 新部分可以被认为是一个新的生命周期方法(它在任何其他钩子之前执行 - 特别是在created
之前)。
下面是一个将传统方法与 Composition API 相集成的完整组件示例:
<template> <input v-model="x" /> <div>Squared: {{ x_squared }}, negative: {{ x_negative }}</div> </template> <script> import { ref, computed } from 'vue'; export default { name: "Demo", computed: { x_negative() { return -this.x; } }, setup() { const x = ref(0); const x_squared = computed(() => x.value * x.value); return {x, x_squared}; } } </script>
从这个例子中得到的东西:
- 所有 Composition API 代码现在都在
setup
中。 您可能希望为每个功能创建一个单独的文件,将该文件导入 SFC,并从setup
中返回所需的反应位(以使它们可用于组件的其余部分)。 - 您可以在同一个文件中混合使用新方法和传统方法。 请注意,
x
,即使它是一个引用,在模板代码或组件的传统部分(如computed
)中引用时也不需要.value
。 - 最后但同样重要的是,请注意我们的模板中有两个根 DOM 节点; 拥有多个根节点的能力是 Vue 3 的另一个新特性。
反应性在 Vue 3 中更具表现力
在本文的第一部分,我们谈到了 Composition API 的标准动机,即改进代码组织和重用。 确实,新 API 的主要卖点不是它的强大功能,而是它带来的组织便利性:能够更清晰地组织代码。 看起来就是这样——Composition API 实现了一种实现组件的方式,避免了现有解决方案(例如 mixins)的限制。
但是,新的 API 还有更多内容。 组合 API 实际上不仅支持更好的组织,而且支持更强大的响应式系统。 关键因素是能够动态地向应用程序添加反应性。 以前,必须在加载组件之前定义所有数据、所有计算属性等。 为什么在后期添加响应式对象会很有用? 在剩下的部分中,我们看一个更复杂的例子:电子表格。
在 Vue 2 中创建电子表格
Microsoft Excel、LibreOffice Calc 和 Google Sheets 等电子表格工具都有某种反应系统。 这些工具向用户展示了一个表格,其中列按 A–Z、AA–ZZ、AAA–ZZZ 等索引,行按数字索引。
每个单元格可能包含一个普通值或一个公式。 具有公式的单元格本质上是一个计算属性,它可能取决于值或其他计算属性。 使用标准电子表格(与 Vue 中的反应系统不同),这些计算属性甚至可以依赖于它们自己! 这种自引用在某些通过迭代逼近获得期望值的场景中很有用。
一旦单元格的内容发生变化,所有依赖于该单元格的单元格都会触发更新。 如果发生进一步的变化,可能会安排进一步的更新。
如果我们要使用 Vue 构建电子表格应用程序,自然会问我们是否可以使用 Vue 自己的反应系统,并使 Vue 成为电子表格应用程序的引擎。 对于每个单元格,我们可以记住它的原始可编辑值以及相应的计算值。 如果计算值是纯值,则计算值将反映原始值,否则,计算值是写入的表达式(公式)的结果,而不是纯值。
使用 Vue 2,实现电子表格的一种方法是让raw_values
是一个二维字符串数组,而computed_values
是一个(计算的)二维单元格值数组。
如果在加载适当的 Vue 组件之前单元格的数量很小并且是固定的,那么我们可以在组件定义中为表格的每个单元格设置一个原始值和一个计算值。 除了这种实现会导致美学上的怪异之外,在编译时具有固定数量单元格的表格可能不算作电子表格。
二维数组computed_values
也存在问题。 计算属性始终是一个函数,在这种情况下,其评估取决于自身(计算单元格的值通常需要已经计算一些其他值)。 即使 Vue 允许自引用计算属性,更新单个单元格也会导致重新计算所有单元格(无论是否存在依赖关系)。 这将是非常低效的。 因此,我们最终可能会使用反应性来检测 Vue 2 中原始数据的变化,但其他所有反应性方面的事情都必须从头开始实现。
在 Vue 3 中对计算值进行建模
使用 Vue 3,我们可以为每个单元格引入一个新的计算属性。 如果表增长,则引入新的计算属性。
假设我们有单元格A1
和A2
,我们希望A2
显示A1
的正方形,其值为数字 5。这种情况的草图:
let A1 = computed(() => 5); let A2 = computed(() => A1.value * A1.value); console.log(A2.value); // outputs 25
假设我们在这个简单的场景中停留片刻。 这里有一个问题; 如果我们希望更改A1
使其包含数字 6 怎么办? 假设我们这样写:

A1 = computed(() => 6); console.log(A2.value); // outputs 25 if we already ran the code above
这不仅将A1
中的值 5 更改为 6 。 变量A1
现在具有完全不同的标识:计算属性解析为数字 6。但是,变量A2
仍然对变量A1
的旧标识的更改做出反应。 所以, A2
不应该直接引用A1
,而是引用一些在上下文中始终可用的特殊对象,并且会告诉我们此刻A1
是什么。 换句话说,在访问A1
之前,我们需要一个间接级别,类似于指针。 Javascript 中没有指针作为一等实体,但很容易模拟一个。 如果我们希望有一个pointer
value
的指针,我们可以创建一个 object pointer = {points_to: value}
。 重定向指针相当于分配给pointer.points_to
,而取消引用(访问指向的值)相当于检索pointer.points_to
的值。 在我们的例子中,我们进行如下操作:
let A1 = reactive({points_to: computed(() => 5)}); let A2 = reactive({points_to: computed(() => A1.points_to * A1.points_to)}); console.log(A2.points_to); // outputs 25
现在我们可以用 6 代替 5。
A1.points_to = computed(() => 6); console.log(A2.points_to); // outputs 36
在 Vue 的 Discord 服务器上,用户redblobgames提出了另一种有趣的方法:不使用计算值,而是使用包装常规函数的引用。 这样,人们可以类似地交换函数而不改变引用本身的身份。
我们的电子表格实现将有一些二维数组的键引用的单元格。 这个数组可以提供我们需要的间接级别。 因此,在我们的例子中,我们不需要任何额外的指针模拟。 我们甚至可以拥有一个不区分原始值和计算值的数组。 一切都可以是计算值:
const cells = reactive([ computed(() => 5), computed(() => cells[0].value * cells[0].value) ]); cells[0] = computed(() => 6); console.log(cells[1].value); // outputs 36
然而,我们真的想区分原始值和计算值,因为我们希望能够将原始值绑定到 HTML 输入元素。 此外,如果我们有一个单独的原始值数组,我们就不必更改计算属性的定义; 它们将根据原始数据自动更新。
实施电子表格
让我们从一些基本定义开始,这些定义在很大程度上是不言自明的。
const rows = ref(30), cols = ref(26); /* if a string codes a number, return the number, else return a string */ const as_number = raw_cell => /^[0-9]+(\.[0-9]+)?$/.test(raw_cell) ? Number.parseFloat(raw_cell) : raw_cell; const make_table = (val = '', _rows = rows.value, _cols = cols.value) => Array(_rows).fill(null).map(() => Array(_cols).fill(val)); const raw_values = reactive(make_table('', rows.value, cols.value)); const computed_values = reactive(make_table(undefined, rows.value, cols.value)); /* a useful metric for debugging: how many times did cell (re)computations occur? */ const calculations = ref(0);
该计划是对每个computed_values[row][column]
进行如下计算。 如果raw_values[row][column]
不以=
开头,则返回raw_values[row][column]
。 否则,解析公式,将其编译为 JavaScript,评估编译后的代码并返回值。 为了简短起见,我们将在解析公式上作弊,我们不会在这里做一些明显的优化,比如编译缓存。
我们将假设用户可以输入任何有效的 JavaScript 表达式作为公式。 我们可以将用户表达式中出现的单元格名称的引用替换为对实际单元格值(计算)的引用,例如 A1、B5 等。 下面的函数完成了这项工作,假设类似于单元格名称的字符串确实总是标识单元格(并且不是某些不相关的 JavaScript 表达式的一部分)。 为简单起见,我们假设列索引由单个字母组成。
const letters = Array(26).fill(0) .map((_, i) => String.fromCharCode("A".charCodeAt(0) + i)); const transpile = str => { let cell_replacer = (match, prepend, col, row) => { col = letters.indexOf(col); row = Number.parseInt(row) - 1; return prepend + ` computed_values[${row}][${col}].value `; }; return str.replace(/(^|[^AZ])([AZ])([0-9]+)/g, cell_replacer); };
使用transpile
函数,我们可以从用单元格引用的 JavaScript 小“扩展”编写的表达式中获取纯 JavaScript 表达式。
下一步是为每个单元生成计算属性。 这个过程将在每个细胞的生命周期中发生一次。 我们可以创建一个返回所需计算属性的工厂:
const computed_cell_generator = (i, j) => { const computed_cell = computed(() => { // we don't want Vue to think that the value of a computed_cell depends on the value of `calculations` nextTick(() => ++calculations.value); let raw_cell = raw_values[i][j].trim(); if (!raw_cell || raw_cell[0] != '=') return as_number(raw_cell); let user_code = raw_cell.substring(1); let code = transpile(user_code); try { // the constructor of a Function receives the body of a function as a string let fn = new Function(['computed_values'], `return ${code};`); return fn(computed_values); } catch (e) { return "ERROR"; } }); return computed_cell; }; for (let i = 0; i < rows.value; ++i) for (let j = 0; j < cols.value; ++j) computed_values[i][j] = computed_cell_generator(i, j);
如果我们将上面的所有代码都放在setup
方法中,我们需要返回{raw_values, computed_values, rows, cols, letters, calculations}
。
下面,我们展示了完整的组件,以及一个基本的用户界面。
该代码在 GitHub 上可用,您也可以查看现场演示。
<template> <div> <div>Calculations: {{ calculations }}</div> <table class="table" border="0"> <tr class="row"> <td></td> <td class="column" v-for="(_, j) in cols" :key="'header' + j" > {{ letters[j] }} </td> </tr> <tr class="row" v-for="(_, i) in rows" :key="i" > <td class="column"> {{ i + 1 }} </td> <td class="column" v-for="(__, j) in cols" :key="i + '-' + j" :class="{ column_selected: active(i, j), column_inactive: !active(i, j), }" @click="activate(i, j)" > <div v-if="active(i, j)"> <input :ref="'input' + i + '-' + j" v-model="raw_values[i][j]" @keydown.enter.prevent="ui_enter()" @keydown.esc="ui_esc()" /> </div> <div v-else v-html="computed_value_formatter(computed_values[i][j].value)"/> </td> </tr> </table> </div> </template> <script> import {ref, reactive, computed, watchEffect, toRefs, nextTick, onUpdated} from "vue"; export default { name: 'App', components: {}, data() { return { ui_editing_i: null, ui_editing_j: null, } }, methods: { get_dom_input(i, j) { return this.$refs['input' + i + '-' + j]; }, activate(i, j) { this.ui_editing_i = i; this.ui_editing_j = j; nextTick(() => this.get_dom_input(i, j).focus()); }, active(i, j) { return this.ui_editing_i === i && this.ui_editing_j === j; }, unselect() { this.ui_editing_i = null; this.ui_editing_j = null; }, computed_value_formatter(str) { if (str === undefined || str === null) return 'none'; return str; }, ui_enter() { if (this.ui_editing_i < this.rows - 1) this.activate(this.ui_editing_i + 1, this.ui_editing_j); else this.unselect(); }, ui_esc() { this.unselect(); }, }, setup() { /*** All the code we wrote above goes here. ***/ return {raw_values, computed_values, rows, cols, letters, calculations}; }, } </script> <style> .table { margin-left: auto; margin-right: auto; margin-top: 1ex; border-collapse: collapse; } .column { box-sizing: border-box; border: 1px lightgray solid; } .column:first-child { background: #f6f6f6; min-width: 3em; } .column:not(:first-child) { min-width: 4em; } .row:first-child { background: #f6f6f6; } #empty_first_cell { background: white; } .column_selected { border: 2px cornflowerblue solid !important; padding: 0px; } .column_selected input, .column_selected input:active, .column_selected input:focus { outline: none; border: none; } </style>
实际使用情况如何?
我们看到了 Vue 3 的解耦反应系统如何不仅使代码更简洁,而且基于 Vue 的新反应机制允许更复杂的反应系统。 自 Vue 推出以来已经过去了大约 7 年,表现力的提升显然没有受到高度追捧。
电子表格示例直接演示了 Vue 现在的功能,您还可以查看现场演示。
但作为一个真实的例子,它有点小众。 新系统在什么情况下会派上用场? 按需响应最明显的用例可能是复杂应用程序的性能提升。

在处理大量数据的前端应用程序中,使用考虑不周的反应性的开销可能会对性能产生负面影响。 假设我们有一个业务仪表板应用程序,它生成公司业务活动的交互式报告。 用户可以选择时间范围并在报告中添加或删除性能指标。 某些指标可能显示取决于其他指标的值。
实现报告生成的一种方法是通过整体结构。 当用户更改界面中的输入参数时,会更新单个计算属性,例如report_data
。 这个计算属性的计算是根据一个硬编码的计划进行的:首先,计算所有独立的性能指标,然后是那些只依赖于这些独立指标的指标,等等。
更好的实现将解耦报告的各个部分并独立计算它们。 这样做有一些好处:
- 开发人员不必对执行计划进行硬编码,这既繁琐又容易出错。 Vue 的反应系统会自动检测依赖关系。
- 根据所涉及的数据量,我们可能会获得显着的性能提升,因为我们只更新逻辑上依赖于修改后的输入参数的报告数据。
如果在加载 Vue 组件之前知道所有可能成为最终报告一部分的性能指标,那么即使使用 Vue 2,我们也可以实现建议的解耦。否则,如果后端是唯一的事实来源(即通常是数据驱动的应用程序的情况),或者如果有外部数据提供者,我们可以为报告的每一部分生成按需计算属性。
感谢 Vue 3,这不仅是可能的,而且很容易做到。