Vue3 的 Reactive 响应式到底是什么
Vue 3 除了令人钦佩的性能改进,还带来了一些新功能。可以说,最重要的介绍是 Composition API
。在本文的第一部分中,我们将概括 Vue3 创建新 API 的动机:即,更好的组织和重用代码。在第二部分中,我们将重点讨论使用新API时较少讨论的方面,例如响应式特性。我将响应式特性其称为按需响应。
在介绍了相关的新特性之后,我们将构建一个简单的电子表格应用程序来演示。最后,我将讨论这种按需响应的改进在现实场景中的用途。
Vue3 有什么新功能,为什么它很重要
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)为大多数代码组织和重用问题提供了解决方案: mixin
。
Vue3 中 Mixins 的优缺点
Mixin 允许在单独的代码单元中提取组件的功能。每个功能都放在一个单独的 mixin 中,每个组件都可以使用一个或多个 mixin。在 mixin 中定义的代码可以在组件中使用,就像它们在组件本身中定义一样。mixin 有点像面向对象语言中的类。与类一样,mixin 可以在其他代码中继承。
但是,使用 mixins 理解起来比较困难,因为与类不同,mixin 的设计不需要考虑封装。Mixin 可以是松散绑定的代码片段的集合。在同一组件中一次使用多个 mixin 可能会导致组件难以理解和使用。
大多数面向对象的语言(例如 C# 和 Java)不鼓励甚至不允许多重继承,尽管面向对象的编程范式具有处理这种复杂性的工具。
在 Vue 中使用 mixin 时可能出现的一个更实际的问题是名称冲突,当使用两个或多个 mixins 声明通用名称时会发生这种问题。这里需要注意的是,如果 Vue 处理名称冲突的默认策略在给定情况下并不理想,则可以由开发人员调整该策略。但是这就引入了更多的复杂性。
另一个问题是 mixins 不提供类似于类构造函数的东西。这是一个问题,因为我们经常需要非常相似但不完全相同的功能出现在不同的组件中。在一些简单的情况下,可以使用 mixin 工厂函数来规避这种情况。
因此,mixin 并不是代码组织和重用的理想解决方案,而且项目越大,它们的问题就越严重。Vue 3 引入了一种新方法来解决有关代码组织和重用的相关问题。
Composition API:Vue 3 代码的组织和重用
Composition API
允许我们完全解耦组件的各个部分。每一段代码:变量、computed
属性、watch
等,都可以独立定义。
例如,我们现在可以编写(在我们的 JavaScript 代码中的任何位置),而不是让一个对象包含一个数据部分,该部分包含一个键 animation_state
和 一个 "playing" 值:
const animation_state = ref('playing');
效果几乎和在某些组件的数据部分声明这个变量一样。唯一的本质区别是我们需要使在组件外部定义的 ref
。我们可以通过将其模块导入到定义组件的位置并从组件的 setup
部分返回 ref
来做到这一点。我们现在将跳过这个过程,只关注新的 API
。Vue 3 中的响应式是不需要组件的,它实际上是一个独立的系统。
我们可以在我们将此变量导入到的任何范围内使用变量 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
实现时)都需要一个对象。
在那里,我们有一个组件作为任何响应式数据的成员(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
// 如果我们没有将`upload_params`设为常量,下面的代码会编译,但我们会在赋值后失去响应性。因此,明确地使响应式变量 const 是一个好主意。
upload_params = {
target_directory: 'static',
visibility: 'public',
};
使用 ref
和 reactive
解耦响应式并不是 Vue 3 的全新特性。它在 Vue 2.6 中就已经部分引入了,其中这种解耦的响应式数据实例被称为“可观察对象”。在大多数情况下,可以用响应式替换 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
同样,可以实现各种类型的 watch
、生命周期方法和依赖注入。为简洁起见,我们不会在这里介绍这些内容。
假设我们使用标准 SFC 方法进行 Vue 开发。我们甚至可能使用传统的 API,data、computed属性等。
我们如何将 Composition API 的少量响应式部分与 SFC 集成?
Vue 3 为此引入了另一个部分:setup
。该部分可以被认为是一种新的生命周期方法(它在任何其他钩子之前执行 - 特别是在 create
之前)。
下面是一个将传统方法与 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
是一个引用,在模板代码或组件的传统部分(例如计算)中引用时,它也不需要.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
这不仅将 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
我们的电子表格实现将有一些二维数组的键引用的单元格。这个数组可以提供我们需要的间接级别。因此,在我们的例子中,我们不需要任何额外的指针模拟。我们甚至可以拥有一个不区分原始值和计算值的数组。一切都可以是计算值:
const cells = reactive([
computed(() => 5),
computed(() => cells[0].value * cells[0].value)
]);
cells[0] = computed(() => 6);
console.log(cells[1].value); // outputs 36
然而,我们真的想区分原始值和计算值,因为我们希望能够将原始值绑定到 HTML 的 input
元素。此外,如果我们有一个单独的原始值数组,我们就不必更改计算属性的定义,它们将根据原始数据自动更新。
创建电子表格
让我们从一些基本定义开始,这些定义在很大程度上是不言自明的。
const rows = ref(30), cols = ref(26);
/* 如果一个字符串编码一个数字,则返回该数字,否则返回一个字符串 */
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));
/* 一个有用的调试指标:单元(重新)计算发生了多少次? */
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(() => {
// 我们不希望 Vue 认为 computed_cell 的值取决于 `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 {
// Function 的构造函数接收函数体作为字符串
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 推出以来已经过去了大约 7 年,表现力的提升显然没有受到高度追捧。
电子表格只是作为一个真实的例子,有点小众。这套系统在什么情况下会派上用场?个人觉得,按需响应最明显的用例可能是复杂应用程序的性能提升。
在处理大量数据的前端应用程序中,使用考虑不周的响应式的开销可能会对性能产生负面影响。假设我们有一个业务仪表板应用程序,可以生成公司业务活动的交互式报告。用户可以选择时间范围并在报告中添加或删除性能指标。某些指标可能显示取决于其他指标的值。
当用户更改界面中的输入参数时,会更新单个计算属性,例如 report_data
。这个计算属性的计算是根据一个硬编码的计划进行的:首先,计算所有独立的性能指标,然后是那些只依赖于这些独立指标的指标,等等。
更好的实现将解耦报告的各个部分并独立计算它们。这样做有一些好处:
-
开发人员不必对执行计划进行硬编码,这既繁琐又容易出错。Vue 的响应式系统会自动检测依赖关系。
-
根据所涉及的数据量,我们可能会获得显着的性能提升,因为我们只更新逻辑上依赖于修改后的输入参数的报告数据。