生命周期与复用:资源怎样跟着实例结束
实例被创建、模板被渲染、页面中的 DOM 被插入,是不同的步骤。beforeCreate 时 data/methods/computed 等状态选项尚未初始化;created 时可以读取这些成员,但还没有挂载结果。beforeMount 在首次渲染挂载前执行,mounted 适合访问本组件已经生成的 DOM。更新时,beforeUpdate 看到的是更新前 DOM,updated 在本次 DOM 修改之后执行。不要在 updated 中无条件修改它依赖的状态,这会再次安排更新。
这些钩子都以当前实例作为 this 调用。mounted 不等于所有异步子组件、图片或请求已经完成;需要目标元素时,用组件/指令的具体挂载信号,更新后观察 DOM 则等待 nextTick。旧项目里“等 100 毫秒再找元素”的补丁,往往掩盖了不明确的生命周期契约。
观察停用和销毁的区别
下面保存为 lifecycle.html,沿用本章服务器。页面维护一个小计数器:浏览器派发 resize 时计数增加。它在显示期间订阅,停用时退订,重新显示时只恢复一份订阅。
<!doctype html>
<meta charset="utf-8">
<div id="app">
<button id="toggle" @click="visible = !visible">显示/隐藏</button>
<button id="resize" @click="resize">模拟 resize</button>
<button id="discard" @click="cached = false">销毁缓存</button>
<keep-alive v-if="cached">
<resize-counter v-if="visible"></resize-counter>
</keep-alive>
</div>
<script src="./node_modules/vue/dist/vue.js"></script>
<script>
Vue.component('resize-counter', {
data() { return { count: 0 } },
created() {
let listening = false
const handler = () => { this.count++ }
this.start = () => {
if (listening) return
listening = true
window.addEventListener('resize', handler)
}
this.stop = () => {
if (!listening) return
listening = false
window.removeEventListener('resize', handler)
}
console.log('created')
},
mounted() { this.start(); console.log('mounted') },
activated() { this.start(); console.log('activated') },
deactivated() { this.stop(); console.log('deactivated') },
beforeDestroy() { this.stop(); console.log('beforeDestroy') },
destroyed() { console.log('destroyed') },
template: '<p id="count">{{ count }}</p>'
})
new Vue({
el: '#app',
data() { return { visible: true, cached: true } },
methods: { resize() { window.dispatchEvent(new Event('resize')) } }
})
</script>第一次挂载会经过 mounted 与 activated,因此 start 必须幂等:调用两次仍只有一个监听器。固定浏览器窗口大小,先记下当前基数 N;真实调整窗口也会派发 resize,因此应按增量观察。点击模拟 resize 后为 N+1;隐藏后再次派发,实例虽然还在缓存中,count 不应变化。重新显示仍为 N+1,下一次派发才成为 N+2。没有额外原生 resize 时,这就是 0→1→2。重复切换几次后,一次 resize 仍只增加 1。
deactivated 表示 KeepAlive 缓存实例暂时不活动,没有执行最终销毁。只在 beforeDestroy 清理会让隐藏组件持续处理事件。点击销毁缓存才结束被缓存实例;beforeDestroy 时实例仍可用,适合释放资源,destroyed 时框架内部侦听和子实例等销毁工作已完成。Vue 管理自己建立的关联,window 上的外部监听和自建计时器仍需要显式结束。
removeEventListener 必须拿到注册时的同一个函数身份。写成 removeEventListener('resize', () => this.count++) 会创建另一个函数,无法删除原监听。把 handler 放进 created 的闭包既保留身份,也让每个组件有独立资源;不能把同一份可变 listening 放在组件定义外面共用。
mixin 合并的是选项,不是一个独立实例
旧项目常把通用生命周期放进 mixin。保存 mixins.cjs 运行:
const Vue = require('vue')
const events = []
const common = {
data() { return { label: 'mixin', retries: 1 } },
created() { events.push('mixin:' + this.label) },
methods: { describe() { return 'mixin' } }
}
const vm = new Vue({
mixins: [common],
data() { return { label: 'component' } },
created() { events.push('component:' + this.retries) },
methods: { describe() { return 'component' } }
})
console.log(events) // ['mixin:component', 'component:1']
console.log(vm.describe()) // component
vm.$destroy()data 合并后组件同名字段优先,所以 mixin 的 created 也看到 component。生命周期会按合并顺序都执行;同名 methods 则被组件方法覆盖。watch 同名观察项可能合并成多个处理器,不能照着 methods 的覆盖行为推断它也只剩一份。
因此修改一个叫 start 的通用方法可能影响多个钩子的调用;mixin 没有自己的命名空间。先查所有消费组件和同名选项,给资源方法明确名称,再考虑抽出普通函数或组合式函数。Vue.mixin 影响后续创建的整个构造器体系;一个局部页面修复不应该随意加成全局 mixin。
指令与插件处理不同范围的复用
若复用的是某个 DOM 元素上的行为,自定义指令比 mixin 更容易确定资源归属。下面完整文件 directive.html 点击显示/移除按钮两次,日志应显示每次插入都有对应释放:
<!doctype html>
<meta charset="utf-8">
<div id="app">
<button @click="visible = !visible">显示/移除</button>
<button v-if="visible" v-report>目标</button>
</div>
<script src="./node_modules/vue/dist/vue.js"></script>
<script>
const handlers = new WeakMap()
Vue.directive('report', {
inserted(el) {
const handler = () => console.log('focused')
handlers.set(el, handler)
el.addEventListener('focus', handler)
console.log('attached')
},
unbind(el) {
el.removeEventListener('focus', handlers.get(el))
handlers.delete(el)
console.log('released')
}
})
new Vue({ el: '#app', data() { return { visible: true } } })
</script>指令 bind 在绑定时执行,inserted 等待元素插入父节点;更新相关值时可用 update(el, binding) 读取新旧值,但它不保证子组件都完成更新,需要子树更新后的时机则考虑 componentUpdated。解绑时 unbind 释放元素资源。注意 KeepAlive 停用不一定解绑指令;“仅可见时工作”的资源仍应由组件的 activated/deactivated 管理。
插件则是一次安装能力的协议:Vue.use({ install(Vue) { / 注册组件、指令或实例能力 */ } })*。Vuex 和 Router 都通过安装把能力接入实例。旧插件可能写 Vue.prototype.$service,于是所有该构造器创建的实例都能通过 this.$service 访问。维护时要明确这是共享服务,不是每个组件独有的可变业务状态;测试中两个根实例也不会因此拥有隔离的全局配置。Vue 3 的应用级安装边界将在迁移篇对照。
练习:停用和销毁后不再计数
把 resize-counter 的订阅改成 keydown,只在 event.key === 'Enter' 时增加 count。输入顺序为 Enter、Escape、隐藏、Enter、显示、Enter、销毁缓存、Enter。要求依次可见计数为 1、1、隐藏、隐藏、1、2,销毁后不再处理。
保留实例最后一次引用用于观察销毁后的 count,不依赖“DOM 已消失”证明退订。再删除 deactivated 中的 stop 作为失败变体,确认隐藏期间的 Enter 会使重显后的数字错误增长。