Vue 3中的按需反应性
除了令人钦佩的性能改进之外,最近发布的Vue 3还带来了一些新的功能。可以说,最重要的引入是Composition API。在本文的第一部分,我们回顾了新API的标准动机:更好的代码组织和重用。在第二部分中,我们将重点讨论使用新API的一些较少讨论的方面,例如实现Vue 2的反应性系统中无法表达的基于反应性的功能。
我们将把这称为按需反应性。在介绍完相关的新功能后,我们将建立一个简单的电子表格应用来展示Vue反应性系统的新表现力。在最后,我们将讨论这种按需反应性的改进在现实世界中会有什么用途。
Vue 3的新内容以及它的重要性
Vue 3是对Vue 2的一次重大重写,引入了大量的改进,同时几乎完全保留了对旧API的向后兼容性。
Vue 3中最重要的新功能之一是合成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)为大部分的代码组织和重用问题提供了解决方案。
Vue 3中混合器的优点和缺点
Mixins允许在一个单独的代码单元中提取一个组件的功能。每个功能都被放在一个单独的混合素中,每个组件都可以使用一个或多个混合素。在混合器中定义的部分可以在组件中使用,就像它们被定义在组件本身一样。混合器有点像面向对象语言中的类,它们收集与特定功能有关的代码。像类一样,mixins可以被继承(使用)到其他的代码单元中。
然而,用mixins进行推理比较困难,因为与类不同,mixins的设计不需要考虑到封装。混合器被允许成为松散的代码片断的集合,而没有一个定义明确的接口与外部世界相连。在同一个组件中同时使用一个以上的混合器可能会导致组件难以理解和使用。
大多数面向对象的语言(如C#和Java)不鼓励甚至不允许多重继承,尽管事实上面向对象的编程范式有处理这种复杂性的工具。(有些语言确实允许多重继承,如C++,但组合仍然比继承更受欢迎)。
在Vue中使用mixins时可能出现的一个更实际的问题是*名称碰撞,*当使用两个或更多的mixins声明共同的名称时就会出现这种情况。这里需要注意的是,如果Vue处理名称碰撞的默认策略在特定情况下并不理想,开发者可以调整该策略。
另一个问题是mixins没有提供类似于类构造函数的东西。这是一个问题,因为我们经常需要在不同的组件中出现非常相似但不完全相同的功能。在一些简单的情况下,可以通过使用mixin工厂来规避这个问题。
因此,混合器并不是代码组织和重用的理想解决方案,而且项目越大,其问题就越严重。Vue 3引入了一种新的方式来解决关于代码组织和重用的相同问题。
组成API-Veu3对代码组织和重用的回答
Composition API允许我们(但不要求我们)完全解耦组件的各个部分。每一段代码--一个变量、一个计算的属性、一个手表等等--都可以独立定义。
例如,我们现在可以写(在我们的JavaScript代码的任何地方),而不是让一个对象包含一个data 部分,其中包含一个键animation_state ,其值为(默认)"playing"。
const animation_state = ref('playing');
其效果几乎与在某个组件的data 部分中声明这个变量相同。唯一的本质区别是,我们需要使在组件之外定义的ref 在我们打算使用它的组件中可用。我们通过把它的模块导入到定义组件的地方,并从组件的setup 部分返回ref 来做到这一点。我们暂时跳过这个程序,只关注一下新的API。Vue 3中的Reactivity不需要组件,它实际上是一个独立的系统。
我们可以在任何我们导入这个变量的范围内使用animation_state 。在构建了一个ref ,我们使用ref.value ,例如,获得和设置其实际值。
animation_state.value = 'paused';
console.log(animation_state.value);
我们需要'.value'这个后缀,因为否则赋值运算符会将(非反应性)值 "paused "分配给变量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 。然而,refs 的使用将根据我们是否使用SFC而有所不同,所以这样的功能也许并不可取。
有时,我们有一个实体(例如,是一个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中被部分引入,当时这种解耦的反应性数据实例被称为 "可观察"。在大多数情况下,人们可以用reactive 来代替Vue.observable 。其中一个区别是,直接访问和变异传递给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 )。
下面是一个完整的组件的例子,它将传统的方法和组合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中更具有表现力
在本文的第一部分,我们谈到了组合API的标准动机,即改进代码组织和重用。事实上,新API的主要卖点不是它的功能,而是它带来的组织上的便利:能够更清晰地构造代码。看起来这就是全部--Composition API使一种实现组件的方式避免了现有解决方案的局限性,例如mixins。
然而,新的API还有更多的意义。组成API实际上不仅可以实现更好的组织,而且可以实现更强大的反应式系统。关键因素是能够动态地将反应性添加到应用程序中。以前,人们必须在加载一个组件之前定义所有的数据、所有的计算属性等。为什么在后期添加反应性对象会有用呢?在接下来的内容中,我们看一下一个更复杂的例子:电子表格。
在Vue 2中创建一个电子表格
电子表格工具,如Microsoft Excel、LibreOffice Calc和Google Sheets都有某种反应性系统。这些工具向用户展示了一个表格,列的索引为A-Z、AA-ZZ、AAA-ZZ等,行的索引为数字。
每个单元格可以包含一个普通的值或一个公式。带有公式的单元格本质上是一个计算属性,它可能依赖于值或其他计算属性。在标准电子表格中(与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 ,我们可以创建一个对象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(/(^|[^A-Z])([A-Z])([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} 。
下面,我们介绍完整的组件,以及基本的用户界面。
<template>
<div>
<div style="margin: 1ex;">Calculations: {{ calculations }}</div>
<table class="table" border="0">
<tr class="row">
<td id="empty_first_cell"></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问世以来,大约七年过去了,表现力的提升显然没有受到高度关注。
电子表格的例子直接展示了Vue现在的能力,你也可以去看看现场演示。
但作为一个真实的文字例子,它有些小众。在什么样的情况下,新系统会派上用场?按需反应性最明显的用例可能是复杂应用程序的性能提升。

在处理大量数据的前端应用中,使用考虑不周的反应性所带来的开销可能会对性能产生负面影响。假设我们有一个商业仪表盘应用程序,可以产生公司业务活动的交互式报告。用户可以选择一个时间范围,并在报告中添加或删除性能指标。一些指标可能会显示取决于其他指标的值。
实现报告生成的一种方式是通过一个单一的结构。当用户在界面中改变一个输入参数时,一个单一的计算属性,例如report_data ,就会被更新。这个计算属性的计算是按照一个硬编码的计划进行的:首先,计算所有独立的性能指标,然后计算那些只依赖于这些独立指标的指标,等等。
一个更好的实现会将报告中的比特解耦,并独立计算它们。这样做有一些好处。
- 开发者不必对执行计划进行硬编码,这很乏味,也很容易出错。Vue的反应性系统会自动检测依赖性。
- 根据所涉及的数据量,我们可能会获得可观的性能提升,因为我们只更新逻辑上依赖于修改后的输入参数的报告数据。
如果所有可能成为最终报告一部分的性能指标在Vue组件被加载之前就已经知道了,那么即使使用Vue 2,我们也可以实现所建议的解耦功能。否则,如果后台是唯一的真相来源(数据驱动的应用程序通常是这种情况),或者有外部数据提供者,我们可以为报告的每一个部分生成按需计算的属性。
感谢Vue 3,现在这不仅是可能的,而且是容易做到的。
了解基础知识
什么是最新的Vue?
Vue 3,代号为 "One Piece",是最新的版本。
Vue 3稳定吗?
Vue 3在官方上是稳定的。然而,在编写本文介绍的代码示例时,我遇到并报告了一些小问题。
Vue 3向后兼容吗?
是的,在这个意义上,它不需要对你的代码进行实质性的修改。然而,许多应用程序将需要一些小改动。
Vue的历史有多长?
Vue首次公开是在2014年2月。Vue 1.0于2015年10月发布,而最新版本(Vue 3.0)于2020年9月发布。