实例与 Options API:先读懂原程序
打开旧组件,看到 data、computed、methods、watch,不要先找它们分别怎样改成 ref 或 setup。这些选项共同描述一个实例:它保存哪些状态,怎样计算派生值,收到命令后做什么,以及状态变化后需要执行什么外部工作。Options API 在 Vue 3 中也能使用;本章选择 Vue 2 的运行时,是为了理解旧程序实际依赖的行为。
建立一个可以反复运行的环境
在新的空目录中保存下面的 package.json,再执行 npm install。不要把这些依赖装进 Vue 3 网站目录。Node 实验保存成 .cjs,使用 CommonJS 的 require 读取安装包;后文浏览器实验使用同一个目录中的 UMD 文件。
{
"private": true,
"dependencies": {
"vue": "2.7.16",
"vuex": "3.6.2",
"vue-router": "3.6.5"
}
}2
3
4
5
6
7
8
先用 node options.cjs 运行下面的完整文件。它不挂载网页,因此可以把“数据机制”与“DOM 显示”分开观察。
// options.cjs
const Vue = require('vue')
async function main() {
const changes = []
let evaluations = 0
const vm = new Vue({
data() { return { price: 12, quantity: 2 } },
computed: {
total() {
evaluations++
return this.price * this.quantity
}
},
methods: {
add() { this.quantity++ }
},
watch: {
quantity(value, previous) { changes.push([previous, value]) }
}
})
console.log(vm.quantity, vm.$data.quantity) // 2 2
console.log(vm.total, vm.total, evaluations) // 24 24 1
const add = vm.add
add()
add()
console.log(vm.quantity, vm.total, evaluations) // 4 48 2
console.log(changes) // []
await Vue.nextTick()
console.log(changes) // [[2, 4]]
vm.$destroy()
}
main().catch(console.error)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
new Vue(options) 不是把对象原样存起来。Vue 读取配置,执行 data 函数,观察返回对象,并在实例上建立状态访问入口。因此 vm.quantity 与 vm.$data.quantity 指向同一个状态位置,修改任一入口都影响另一入口。vm 只是常用变量名;$data 是 Vue 提供的实例属性。以 $ 或 _ 开头的自定义 data 字段不会像普通字段那样代理到实例,避免把自己的业务名称放进框架保留区域。
选项中的普通方法由 Vue 绑定到实例,因此取出 vm.add 再调用仍然修改这个实例。方法内部的 this.quantity 则是一次当前状态读取,不是定义 options 时就计算的值。把 add 改成箭头函数会捕获外层 this;Vue 无法通过绑定改变箭头函数的 this。这不是“Vue 不支持箭头函数”:方法内部的异步回调需要沿用当前实例时,箭头函数恰好合适。
派生值的缓存和副作用的队列
第一次读取 total 时计算得到 24;第二次读取复用缓存。quantity 连续变为 3、4 后,缓存失效,但并不立即为了每次赋值重算。下一次读取 total 才得到 48。所以 evaluations 是 2,而不是 3 或 4。计数器仅用于观察求值次数;实际 computed 应保持纯计算,不能在 getter 中发送请求或反向修改输入。
watch 的目标不同。它需要知道 quantity 发生了变化,然后记录日志或同步外部系统。默认侦听回调进入异步更新队列,同一轮中的重复任务会合并。两次赋值并不保证两次回调;这里观察到的是 2 → 4。nextTick 让代码等到本轮 Vue 更新队列处理完;它不会等待任意网络请求,也不会让本来不可观察的属性突然具备响应式能力。
如果总价只是价格乘数量,使用 computed 可避免同时维护 quantity 和 total 两份事实。若把 total 放进 data,再在 watch 中更新,必须考虑初始化、每个输入的变化及异步队列窗口。watch 更适合真正的副作用;异步搜索需要处理旧结果失效,下一篇会在旧实例上建立这个过程。
每个组件为什么需要自己的 data
一份 options 可以创建多个实例,data 函数在每次实例创建时执行。把下面代码独立保存为 isolation.cjs:
const Vue = require('vue')
const options = { data() { return { draft: { text: '' } } } }
const first = new Vue(options)
const second = new Vue(options)
first.draft.text = 'A'
console.log(second.draft.text, first.draft === second.draft) // '' false
first.$destroy()
second.$destroy()2
3
4
5
6
7
8
失败变体是先在外面创建 const shared = { text: '' },再让 data 返回 { draft: shared }。虽然依然写了 data 函数,两个 draft 仍然是同一个对象;改第一份会改第二份。真正的要求是每次调用创建需要隔离的可变对象,而不是只检查有没有函数语法。根实例可以接受 data 对象,但可复用组件的 data 必须是函数。
把相同状态接到网页
在实验目录保存下列 index.html,执行 python3 -m http.server 5186 后打开 **http://localhost:5186/**。这里安装包的完整构建包含模板编译器;只含运行时的构建不能在浏览器里把 template 字符串编译成渲染函数。
<!doctype html>
<meta charset="utf-8">
<div id="app">
<button @click="add">增加一件</button>
<p>{{ quantity }} 件,共 {{ total }} 元</p>
</div>
<script src="./node_modules/vue/dist/vue.js"></script>
<script>
new Vue({
el: '#app',
data() { return { quantity: 2, price: 12 } },
computed: { total() { return this.quantity * this.price } },
methods: { add() { this.quantity++ } }
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
el 选择挂载位置。插值从当前实例读取 quantity 和 total,@click 在点击时调用 add。初始显示 2 件、24 元;点击后状态先变为 3,随后 Vue 更新文本为 3 件、36 元。模板不写 this,方法和 computed 的 JavaScript 函数内部需要写 this。这是两个不同的求值上下文。
维护类装饰器组件时,也可以先做同样的成员分类:实例字段通常承载 data,getter 承载 computed,普通成员承载 methods。还原这些职责后再判断行为,而不是假设装饰器名字本身保证隔离和正确时序。
练习:折扣与实例隔离
在 options.cjs 中增加 discount: 0,把 total 定义为 Math.max(0, price * quantity - discount)。新增 setDiscount(value),只接受有限且非负的数字,非法输入抛出 Error 并保持旧状态。
用价格 12、数量 2,依次输入 5、100、-1 和字符串 '5'。观察总价为 19、0,后两次失败且折扣仍为 100。再创建第二个实例,确认第一份折扣不影响第二份;解释为什么 total 不需要 watch 来维持。