Skip to content

实例与 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 文件。

json
{
  "private": true,
  "dependencies": {
    "vue": "2.7.16",
    "vuex": "3.6.2",
    "vue-router": "3.6.5"
  }
}

先用 node options.cjs 运行下面的完整文件。它不挂载网页,因此可以把“数据机制”与“DOM 显示”分开观察。

js
// 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)

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:

js
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()

失败变体是先在外面创建 const shared = { text: '' },再让 data 返回 { draft: shared }。虽然依然写了 data 函数,两个 draft 仍然是同一个对象;改第一份会改第二份。真正的要求是每次调用创建需要隔离的可变对象,而不是只检查有没有函数语法。根实例可以接受 data 对象,但可复用组件的 data 必须是函数。

把相同状态接到网页 ​

在实验目录保存下列 index.html,执行 python3 -m http.server 5186 后打开 **http://localhost:5186/**。这里安装包的完整构建包含模板编译器;只含运行时的构建不能在浏览器里把 template 字符串编译成渲染函数。

html
<!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>

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 来维持。

核对答案 · 继续:响应式限制与异步观察

用心记录每一步成长