主题
实现响应式与生命周期(Lesson7 2/3)
本页对应 Lesson07,共 6 段视频,约 1 小时 30 分。
学习目标
- 能使用
Object.assign完成对象合并,准确说明目标对象、源对象与返回值的区别,并验证合并操作是否修改目标对象。 - 能通过
input事件将表单输入值写回data中对应属性,正确处理事件回调中的this指向,并验证“视图更新数据状态”的效果。 - 能使用
Object.defineProperty遍历data属性定义getter/setter,说明Observer监听数据变化并通知订阅者的职责。 - 能实现订阅者/发布订阅机制,为同一数据属性收集多个更新函数,并在数据变化时依次触发,使数据变化驱动视图更新。
- 能解析并绑定
v-on:click等事件指令,将模板中的事件处理函数映射到methods并绑定正确this,验证点击后能修改数据并更新视图。 - 能描述挂载阶段
beforeCreate、created、beforeMount、mounted的执行顺序,说明各阶段data、methods、refs、真实 DOM 的可用性,并在对应钩子中完成初始化、数据修改或异步请求。
知识点
目标与整体思路
为什么需要:上午的版本只能“数据渲染到视图”,还不能“视图改数据”,更不能“数据一变,多个视图自动更新”。本节要把 Zhang 变成一个小型响应式系统。
怎么想:数据与订阅者是一对多。observe 负责侦听 $data 的读写,compile 负责扫描模板指令并生成 Watcher,Watcher.update 负责把新值写回 DOM。
怎么做:构造函数先保存 $data、$el、绑定 methods,再调用 observe(),最后调用 compile(this.$el)。顺序不能反:先 observe 才有实例上的代理属性和 binds 数组,compile 里才能 this[attrValue] 取值、this.binds[attrValue].push(...)。
易错点:若先 compile 后 observe,this.str 还不存在,视图会渲染空;this.binds[attrValue] 也会是 undefined。转写稿里正是通过调换顺序、打印 this.str 和 binds 验证的。
发布者:拦截读写并通知订阅者
为什么需要:直接改 $data.str 不会触发任何更新,必须把 $data 的属性代理到实例上,并在 set 时通知订阅者。
怎么想:用 Object.defineProperty(this, key, {...}) 为每个 $data 属性定义 getter/setter;getter 返回 $data[key],setter 先写回 $data[key],再遍历 this.binds[key] 调用 item.update()。
怎么做:在 observe() 中先 this.binds[key] = [],再定义属性;binds 保存该数据对应的多个 Watcher。Object.assign(Zhang.prototype, {...}) 把 observe、compile 挂到原型上,注意 Object.assign 第一个参数是目标对象,返回的也是目标对象。
易错点:binds[key] 必须初始化为数组;通知时用 forEach(item=>item.update()),不能把 Watcher 对象当函数直接调用。转写稿里曾把 push 写成赋值,导致多个订阅者只剩最后一个,必须用 push。
解析器:扫描指令并生成 Watcher
为什么需要:模板里的 z-model、z-text、z-html、@click 需要被识别,并把“数据变化后如何改 DOM”保存成订阅者。
怎么想:compile(el) 遍历 el.children,对每个 node 先递归 this.compile(node) 解析子元素,再判断当前节点指令。z-model 设置 node.value 并监听 input;z-text 设置 innerText;z-html 设置 innerHTML;三者都 new Watcher(node, 属性名, this, attrValue) 并 push 到 binds[attrValue]。
怎么做:Watcher 保存 node/attr/zm/prop,update() 执行 this.node[this.attr] = this.zm[this.prop]。z-model 的 input 回调里用箭头函数保持 this 为实例,然后 this[attrValue] = e.target.value,从而触发 setter。
易错点:this.$data[attrValue] 与 this[attrValue] 要区分;中括号里是变量,点后面是固定属性名。@click 要先用 config.methods[key].bind(this) 绑定实例,再在解析时 node.addEventListener("click", this[attrValue])。递归 compile 不能漏,否则嵌套 div 里的指令解析不到;递归出口是节点没有子元素。
调试顺序与易错点
为什么需要:响应式链路长,顺序错、this 错、数组用法错都会表现为“视图不动”。
怎么想:先验证 observe 后 this.str 可读可写;再验证 compile 后 binds.str 里有多个 Watcher;最后点按钮改 str/num,观察 input、z-text、z-html 是否同步。
怎么做:在 set 里打印“数据状态更改”,在 compile 后打印 this.binds;点击按钮输出方法名,确认 @click 已绑定。转写稿中先点按钮无反应,补 hasAttribute("@click") 和 addEventListener 后恢复;先 compile 后 observe 时视图空白,调换后恢复。
易错点:addEventListener 的第二个参数要用已绑定 this 的方法;z-model 的 input 回调不要用普通函数导致 this 指向元素;Watcher 的 update 不要写成箭头函数丢 this;Object.defineProperty 的 getter 中 this.$data 的 this 是实例,不能写成 $data。
完整代码
js
function Zhang(config){
// 增加实例属性$data
this.$data = config.data;
// 增加实例属性$el
this.$el = document.querySelector(config.el);
// 绑定数据下的订阅者
this.binds = {};
for(let key in config.methods){
this[key] = config.methods[key].bind(this);
}
// 调用发布者
this.observe();
// 调用解析器
this.compile(this.$el);
}
Object.assign(Zhang.prototype,{
// 发布者:1- 侦听数据发生改变 2- 通知订阅者
observe(){
for(let key in this.$data){
this.binds[key] = [];
Object.defineProperty(this,key,{
get(){
return this.$data[key];
},
set(v){
this.$data[key] = v;
this.binds[key].forEach(item=>item.update())
}
})
}
},
// 增加解析器
compile(el){
const nodes = el.children;
for(let i=0;i<nodes.length;i++){
// 获取要解析的具体元素
const node = nodes[i];
this.compile(node);
// 判断元素是否拥有@click属性
if(node.hasAttribute("@click")){
const attrValue = node.getAttribute("@click");
// 为node增加click事件
node.addEventListener("click",this[attrValue])
}
// 判断元素是否使用了z-model
if(node.hasAttribute("z-model")){
// 获取到z-model的属性值
const attrValue = node.getAttribute("z-model");// str
// input的value属性值设置为data数据中的str属性值.
node.value = this[attrValue];
// 为node增加input事件
node.addEventListener("input",(e)=>{
// 更新str数据状态
this[attrValue] = e.target.value;
});
// 提供一个修改node元素value属性值的方法
// this.binds[attrValue].push(()=>{
// node.value = this[attrValue];
// });
this.binds[attrValue].push(new Watcher(node,"value",this,attrValue))
}
if(node.hasAttribute("z-text")){
const attrValue = node.getAttribute("z-text");
node.innerText = this[attrValue];
// 提供一个修改node元素的innerText属性值的方法
// this.binds[attrValue].push(()=>{
// node.innerText = this[attrValue];
// })
this.binds[attrValue].push(new Watcher(node,"innerText",this,attrValue))
}
if(node.hasAttribute("z-html")){
const attrValue = node.getAttribute("z-html");
node.innerHTML = this[attrValue];
// 提供一个修改node元素的innerHTML属性值的方法
// this.binds[attrValue].push(()=>{
// node.innerHTML = this[attrValue];
// });
this.binds[attrValue].push(new Watcher(node,"innerHTML",this,attrValue))
}
}
}
})
/*
* node:元素
* attr:元素属性名
* zm:实例
* prop:实例属性名
* */
function Watcher(node,attr,zm,prop){
this.node = node;
this.attr = attr;
this.zm = zm;
this.prop = prop;
}
Watcher.prototype.update = function(){
// 将元素下的某个属性的值设置为实例中的状态下的某个属性值
this.node[this.attr] = this.zm[this.prop];
}html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="lib/Zhang7.js"></script>
</head>
<body>
<div id="root">
<button @click="changeStr">点我</button>
<input z-model="str" type="text">
<div z-text="str"></div>
<div z-html="str"></div>
<hr/>
<div>
<input type="text" z-model="num">
<div z-text="num"></div>
</div>
</div>
</body>
<script>
// 数据与订阅者是一对多的关系。
// 编译器(compile):编译模板
// 发布者(observe):负责侦听数据,一旦数据发生改变会通过订阅者
// 产生订阅者(Watcher):生成订阅者。
// 由编译器(解析器)对模板进行解析,解析的过程中一旦发现有对应的(数据,函数,过滤器等)信息,那么会将对应的数据,(Watcher)增加一个订阅者。
// 一旦数据发生改变,侦听器(observe)会通知对应数据的所有订阅者。订阅者负责更新视图。
const zm = new Zhang({
el: "#root",
data: {
str: "<a>我在尚硅谷学习</a>",
num: 1
},
methods: {
changeStr() {
this.str += "!";
this.num++;
// console.log(this.str,this.num);
}
}
})
</script>
</html>生命周期与钩子函数:先把概念说清

为什么需要:组件从创建到销毁会经历多个阶段,框架需要在不同阶段暴露“插口”,让我们在合适的时机做初始化、取 DOM、发请求或清理资源。
怎么想:生命周期是“实例/组件从创建到销毁的过程”;在这个过程中暴露出来的函数就叫钩子函数。钩子不是我们主动调用,而是框架执行到某个阶段时来调用我们写的函数。
怎么做:记住两件事即可:有哪些钩子、分别在什么时候执行。转写稿强调现阶段不要展开太多应用,先把顺序和执行时机弄明白。
易错点:钩子函数名字是固定的,不能随意加减;控制台输出顺序与你写在代码里的先后无关,只与生命周期阶段有关。
挂载阶段的四个钩子:按执行顺序记
为什么需要:挂载阶段最容易混淆“实例是否创建好”“DOM 是否可用”。
怎么想:挂载阶段按顺序是 beforeCreate → created → beforeMount → mounted。转写稿反复强调:挂载阶段的钩子在生命周期中只执行一次。
怎么做:可以写一段验证代码,把四个钩子都打印出来,并在里面分别访问 data、methods、$refs:
html
<div id="app">
<h3 ref="h3Ref">{{ title }}</h3>
</div>js
new Vue({
el: '#app',
data: { title: '挂载阶段' },
methods: { fn() {} },
beforeCreate() {
console.log('beforeCreate', this.title, this.fn, this.$refs.h3Ref)
},
created() {
console.log('created', this.title, this.fn, this.$refs.h3Ref)
},
beforeMount() {
console.log('beforeMount', this.$refs.h3Ref)
},
mounted() {
console.log('mounted', this.$refs.h3Ref)
}
})易错点:不要凭“代码写在上面还是下面”判断顺序;要看框架执行到哪个阶段。
beforeCreate:实例创建完成之前
为什么需要:有些全局能力要在实例真正初始化前挂好。
怎么想:new Vue() 后先执行 beforeCreate,此时实例还没创建完成,data、methods、$refs 都还拿不到。转写稿中的验证结果是:这里打印 this.title、this.fn、this.$refs.h3Ref 都取不到。
怎么做:适合做与实例数据无关的准备工作,例如转写稿提到的权限判断、设置 Vue.prototype、初始化事件总线等。
易错点:不要在这里访问 this.title 或 this.$refs,否则得到空值/undefined。
created:实例创建完成
为什么需要:此时可以访问数据和方法,适合做数据层面的初始化。
怎么想:created 时实例已创建完成,data、methods 可用;但模板还没挂载到页面上,所以 $refs 仍拿不到真实 DOM。
怎么做:转写稿演示在 created 里修改 title,因为后面才挂载,所以挂载时直接使用修改后的最新数据,不会触发一次“更新”。
易错点:不要以为 created 里能操作 DOM;也不要把它和 mounted 发请求的典型场景混为一谈。
beforeMount:挂载前最后一次调整
为什么需要:模板已经编译,但还没替换到页面,适合做挂载前最后的数据调整。
怎么想:转写稿说此时“模板已编译好,但还没挂载完成”,$refs 依然拿不到。
怎么做:可在这里对数据做最后一次调整,例如根据权限改标题;挂载时会用调整后的数据渲染。
易错点:不要在这里获取真实 DOM;此时页面还没完成替换。
mounted:挂载完成
为什么需要:这是最常用的钩子之一,因为真实 DOM 已经可用。
怎么想:转写稿验证:mounted 里 this.$refs.h3Ref 能拿到真实元素;此处可发 Ajax、修改数据状态、获取真实 DOM 内容。
怎么做:需要操作 DOM、初始化依赖 DOM 的库、发请求时,优先考虑 mounted。
易错点:挂载阶段钩子只执行一次;后续数据变化进入更新阶段,不要指望 mounted 反复执行。另要理解 el 与 template:有 template 用模板,没有则用 el 对应容器的内容作为模板;最终由渲染函数渲染并替换。
挂载阶段要解决什么问题
挂载阶段是 Vue 实例从“创建”到“把模板渲染进页面”的过程。本节要掌握的不是生命周期所有细节,而是四个钩子的执行时机:beforeCreate、created、beforeMount、mounted,以及每个阶段能拿到什么、不能拿到什么。
为什么需要:只有知道钩子执行时机,才知道权限判断、原型扩展、数据初始化、AJAX 请求、DOM 操作分别该放在哪里。
怎么想:先记顺序,再用 console.log 验证 $data、data、methods、$refs 在各阶段是否存在。
怎么做:按笔记代码依次打印四组信息。
易错点:钩子执行顺序由生命周期阶段决定,不由你写在配置对象里的先后顺序决定;挂载阶段的钩子在整个生命周期中只执行一次。
beforeCreate:实例创建前
为什么需要:此时实例还没创建完成,适合做与实例数据无关的全局准备,例如判断用户权限、设置 Vue.prototype。转写稿里提到可在这里挂载 $axios、$bus 等原型能力。
怎么想:既然实例还没创建完成,data、methods、$refs 都不应存在。
怎么做:在 beforeCreate 中打印:
js
console.log("$data", this.$data); // undefined
console.log("data", this.title); // undefined
console.log("methods", this.fn); // undefined
console.log("ref", this.$refs.h3Ref); // undefined易错点:不要在这里访问 this.title 或调用 this.fn();设置原型时注意拼写是 Vue.prototype,不要写成 Vue.prorotype。
created:实例创建完成
为什么需要:实例已经创建完成,可以访问数据和方法,适合做数据初始化、调用方法。
怎么想:$data、data、methods 已经有了,但还没挂载,所以 $refs 仍拿不到真实 DOM。
怎么做:笔记中在 created 里修改 this.title = "学习挂载阶段",然后打印:
js
console.log("$data", this.$data); // {title:"学习挂载阶段"}
console.log("data", this.title); // 学习挂载阶段
console.log("methods", this.fn); // function(){}
console.log("ref", this.$refs.h3Ref); // undefined易错点:created 中修改数据状态不会更新视图,因为此时还没进入挂载渲染;不要误以为改了数据页面就会立刻变。
beforeMount:挂载前最后一次调整
为什么需要:模板即将挂载,适合在渲染前对数据做最后一次调整。
怎么想:数据和方法可用,但真实 DOM 还没替换完成,$refs 仍拿不到。
怎么做:笔记中把 title 改成“正在学习挂载阶段”,打印结果中 $data 和 data 都是新值,$refs.h3Ref 仍是 undefined。
易错点:这里改数据,最终挂载会使用最新数据;但不要在这里操作真实 DOM,因为还拿不到。
mounted:挂载完成
为什么需要:此时模板已经渲染到页面,可以获取真实 DOM,也常用于通过 AJAX 调数据并修改状态。
怎么想:$refs.h3Ref 应该能拿到 <h3> 元素;数据状态保持挂载前最后的值。
怎么做:笔记中打印:
js
console.log("$data", this.$data); // {title:"正在学习挂载阶段"}
console.log("data", this.title); // 正在学习挂载阶段
console.log("methods", this.fn); // function(){}
console.log("ref", this.$refs.h3Ref); // h3易错点:$refs 在 mounted 之前拿不到;mounted 只执行一次,后续数据更新不会再走挂载钩子。
完整示例代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
<h3 ref="h3Ref">{{title}}</h3>
</div>
</body>
<script>
// 挂载阶段的钩子函数在整个生命周期中只会执行一次.
new Vue({
el:"#app",
data:{
title:"挂载阶段"
},
methods:{
fn(){
}
},
beforeCreate(){
// 实例创建完成之前调用:常用于判断用户权限以及设置Vue.prototype
// const $axios = axios.create({
// baseURL
// });
// Vue.prorotype.$axios = $axios;
// Vue.prototype.$bus = new Vue();
console.group("**********1-beforeCreate*************************");
console.log("$data",this.$data);// undefined
console.log("data",this.title);// undefined
console.log("methods",this.fn);// undefined
console.log("ref",this.$refs.h3Ref);// undefined
console.groupEnd();
},
created(){
// 实例创建完成.可以在此处调用属性以及方法
this.title = "学习挂载阶段";// 修改数据状态,不会更新视图.
console.group("**********2-created*************************");
console.log("$data",this.$data);// {title:"学习挂载阶段"}
console.log("data",this.title);// 学习挂载阶段
console.log("methods",this.fn);// function(){}
console.log("ref",this.$refs.h3Ref);// undefined
console.groupEnd();
},
beforeMount(){
this.title = "正在学习挂载阶段";
console.group("**********3-beforeMount*************************");
console.log("$data",this.$data);// {title:"正在学习挂载阶段"}
console.log("data",this.title);// 正在学习挂载阶段
console.log("methods",this.fn);// function(){}
console.log("ref",this.$refs.h3Ref);// undefined
console.groupEnd();
},
mounted(){
// 可以在此处通过AJAX调用数据,并修改数据状态.
console.group("**********4-mounted*************************");
console.log("$data",this.$data);// {title:"正在学习挂载阶段"}
console.log("data",this.title);// 正在学习挂载阶段
console.log("methods",this.fn);// function(){}
console.log("ref",this.$refs.h3Ref);// h3
console.groupEnd();
}
})
</script>
</html>易错点小结
beforeCreate中$data、data、methods、$refs都拿不到。created中数据和方法可用,但$refs仍为undefined。created中改数据不会更新视图,因为还没挂载。beforeMount中改数据,挂载时会用最新值;但此时仍拿不到真实 DOM。mounted中才能通过$refs拿到真实元素,常用于 DOM 操作和 AJAX 初始化。- 挂载阶段四个钩子只执行一次,后续更新阶段不会重复执行。
更新阶段在生命周期中的位置
转写稿里反复讲“生命周期是从实例创建到销毁的过程”,并把钩子函数按阶段串起来:挂载阶段执行 beforeCreate、created、beforeMount、mounted,而且挂载阶段的钩子只执行一次。本节笔记关注的是后面的“更新阶段”:beforeUpdate 和 updated。笔记注释已经点明关键:这两个钩子只有数据状态发生更改以后才会执行;beforeUpdate 是视图更新之前,但数据已经更新;updated 是视图更新之后,数据也是更新后的。
为什么需要更新阶段?因为数据变化和 DOM 变化不是同一瞬间完成的。我们有时需要观察“数据已经变了、页面还没变”的时刻,也需要在“页面已经按新数据渲染完”之后做后续处理。怎么想?把更新阶段理解成一次更新流程的两个切面:先进入 beforeUpdate,再更新视图,最后进入 updated。怎么做?在示例中点击按钮让 num += 1,通过两个钩子分别打印 this.num 和 this.$refs.btnRef.innerText,对比数据与 DOM 的先后关系。易错点是:不要把 beforeUpdate 当成“DOM 已更新”,也不要在首次加载时就期待它执行。
先看完整示例
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
<button ref="btnRef" @click="num+=1">{{num}}</button>
</div>
</body>
<script>
new Vue({
el:"#app",
data:{
num:1
},
beforeCreate(){
console.log("1-beforeCreate")
},
created(){
console.log("2-created");
},
beforeMount(){
console.log("3-beforeMount");
},
mounted(){
console.log("4-mounted");
},
// beforeUpdate,updated只有数据状态发生更改以后才会执行.
// 视图更新之前.数据已经更新
beforeUpdate(){
console.log("1-beforeUpdate",this.num,this.$refs.btnRef.innerText)
},
// 视图更新后.数据更新
updated(){
console.log("2-updated",this.num,this.$refs.btnRef.innerText)
}
})
</script>
</html>这段代码保留了两个关键信息:按钮上绑定了 @click="num+=1",并且用 ref="btnRef" 把按钮元素取到 this.$refs.btnRef。因此我们可以在更新钩子里同时观察数据 this.num 和真实 DOM 的 innerText。
beforeUpdate:数据已新,DOM 还旧
为什么需要它?因为有些判断必须在视图真正改变之前完成。怎么想?看笔记注释:“视图更新之前,数据已经更新。”也就是说,beforeUpdate 执行时,this.num 已经是新值,但按钮上的文字还没被 Vue 重新渲染。怎么做?点击按钮后,num 从 1 变成 2,在 beforeUpdate 中打印 this.num 与 this.$refs.btnRef.innerText,应看到数据是新值、DOM 仍是旧值。易错点:如果在这里读取 DOM 并以为它已经同步,就会拿到旧内容;另外,首次加载页面时不会执行 beforeUpdate,因为它不是挂载阶段钩子。
updated:DOM 已按新数据更新
为什么需要它?因为有些操作要等页面真正渲染完才能做。怎么想?笔记注释写的是“视图更新后,数据更新”。此时 this.num 是新值,this.$refs.btnRef.innerText 也应是新值。怎么做?继续用同一个按钮点击触发更新,在 updated 中打印两者,和 beforeUpdate 的打印结果对照。易错点:updated 不是“数据一改立刻执行”,而是更新流程走到视图更新之后才执行;它也不在首次挂载时执行。转写稿里强调挂载阶段钩子只执行一次,而更新阶段是数据状态更改后才会进入,二者不要混为一谈。
调试过程与验证
转写稿视频6验证挂载顺序时,是在各个钩子里写 console.log,启动后按 beforeCreate、created、beforeMount、mounted 的顺序观察;同时提到 mounted 后可以拿到真实 DOM、可以修改数据状态。迁移到本节代码:先刷新页面,控制台只出现挂载阶段日志;再点击按钮,才会出现 1-beforeUpdate 和 2-updated。在 beforeUpdate 里对比 this.num 与 this.$refs.btnRef.innerText,可以看到“数据先变、DOM 后变”;在 updated 里再对比,二者一致。转写稿里“挂在”应统一为“挂载”,“勾子”应统一为“钩子”,“定阅者”应统一为“订阅者”,阅读时注意这些同音字。
易错点汇总
第一,beforeUpdate 和 updated 只有数据状态更改后才执行,首次加载不要等它们。第二,beforeUpdate 里 DOM 还是旧视图,不要在这里读新 DOM。第三,updated 里 DOM 已更新,适合做依赖真实 DOM 的后续观察。第四,$refs.btnRef 依赖 ref 绑定,挂载完成后才更可靠;本节在更新阶段读取它,是因为按钮已经在挂载阶段进入页面。第五,更新钩子可能随多次数据变化反复进入,调试时用日志顺序判断,不要只凭一次点击下结论。
卸载阶段要解决什么问题
为什么需要: 一个 Vue 实例从创建、挂载、更新到销毁,生命周期钩子会在不同阶段执行。转写稿里反复强调:先别急着研究“怎么用”,先弄清“钩子函数在什么时候执行”。卸载阶段也是一样:实例被销毁时,如果不清理自己在 mounted 里启动的定时器、事件监听等外部资源,它们可能继续运行,造成内存泄漏或逻辑继续执行。
怎么想: 把“启动”和“清理”成对看。笔记里在 mounted 中用 setInterval 启动定时器,每秒让 num++ 并打印 interval;那么卸载时就应该用 clearInterval(this.timer) 停掉它。beforeDestroy 是销毁前,实例仍可访问;destroyed 是销毁后,适合做最后的清理和观察。
怎么做: 本节代码保留如下:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="lib/vue.js"></script>
</head>
<body>
<button onclick="fn()">外部销毁</button>
<div id="app">
<button ref="btnRef" @click="num++">{{num}}</button>
<button @click="$destroy()">销毁</button>
</div>
</body>
<script>
const vm = new Vue({
el:"#app",
data:{
num:1
},
beforeDestroy(){
console.log("1-beforeDestroy",this.num,this.$refs.btnRef);
},
destroyed(){
console.log("2-destroyed",this.num,this.$refs.btnRef);
clearInterval(this.timer);
},
mounted(){
this.timer = setInterval(()=>{
console.log("interval")
this.num++;
},1000);
}
})
function fn(){
vm.$destroy();
}
</script>
</html>易错点: $destroy() 不会自动帮你清除 setInterval。定时器是你自己创建的,就要自己清理。clearInterval(this.timer) 依赖 mounted 中把定时器 id 挂到 this.timer 上,所以不要写成局部变量后卸载阶段拿不到。钩子名是 beforeDestroy 和 destroyed,注意大小写和拼写。
从挂载到卸载:先看钩子执行时机
为什么需要: 转写稿里讲挂载阶段时提到:生命周期钩子是在实例到达某个阶段时执行;挂载阶段的钩子通常只执行一次,mounted 里可以拿到真实 DOM,也常在这里发 Ajax、修改数据状态。这个思路可以迁移到卸载阶段:不要死记“在哪个钩子写什么”,而要先判断此时实例、DOM、$refs 是否还可访问。
怎么想: beforeDestroy 在销毁前调用,此时还可以读取 this.num 和 this.$refs.btnRef;destroyed 在销毁后调用,代码里仍打印这两个值,是为了对照观察销毁前后的状态,但实际项目中不应在 destroyed 里继续依赖 DOM 操作。笔记用两个按钮分别从外部和内部触发销毁:外部是 fn() 调用 vm.$destroy(),内部是模板中的 @click="$destroy()"。
怎么做: 点击“销毁”按钮后,控制台应先出现 1-beforeDestroy,再出现 2-destroyed,这正是钩子顺序。之后因为 destroyed 中执行了 clearInterval(this.timer),定时器应停止打印 interval。如果注释掉 clearInterval,定时器还会继续跑,这就是调试卸载清理是否生效的最直接方式。
易错点: 转写稿中“挂在”应纠正为“挂载”,“替载”应纠正为“卸载”。不要把 destroyed 当成“还能安全操作 DOM”的钩子;真正适合做清理准备的是 beforeDestroy,而最终停掉外部资源可放在 destroyed。另外,$destroy 是实例方法,外部销毁要拿到 vm 再调用。
两个销毁入口与调试清单
为什么需要: 实际项目里,实例销毁可能由内部按钮、路由切换或外部代码触发。理解两个入口,能帮助你排查“为什么定时器还在跑”。
怎么想: 内部入口是 @click="$destroy()",直接调用当前实例方法;外部入口是 onclick="fn()",在函数里调用 vm.$destroy()。两者最终都走同一套销毁流程,所以都会触发 beforeDestroy 和 destroyed。
怎么做: 调试时先点击内部“销毁”,观察日志顺序和 interval 是否停止;再刷新页面点击“外部销毁”,验证同样效果。还可以先点 num++ 按钮观察数字变化,再销毁,确认销毁后不再响应视图更新。清理清单包括:setInterval/setTimeout、手动绑定的事件、全局事件总线订阅等;本节只演示定时器。
易错点: 不要在 destroyed 里假设 $refs 一定还能用于业务操作;多个定时器要分别保存 id 并逐个清理;this.timer 要在 mounted 中赋值,避免 undefined 导致清理失败。掌握“启动在 mounted、清理在卸载钩子”的配对思路,比背代码更重要。
生命周期钩子的定位
为什么需要: 组件从创建、挂载、更新到销毁,会经历不同阶段。很多逻辑必须在正确时机执行,例如初始化数据、操作真实 DOM、发请求、清理定时器。钩子函数就是 Vue 在这些阶段暴露出来的入口。
怎么想: 现阶段先不要追求“所有应用场景”,先掌握两件事:有哪些钩子、它们在什么时候执行。转写稿里也强调:先会卡再会走再会跑,先弄清执行时机。
怎么做: 本节示例包含父组件和子组件 One,把各钩子用 console.log 打出来,通过按钮修改数据、切换 v-if 观察顺序。
易错点: 钩子名字固定,不能随意加减;beforeCreate 阶段实例尚未创建完成,不要在这里访问 data、methods、$refs。转写稿中调试验证过:beforeCreate 时这些通常取不到;created 时 data、methods 可取,但真实 DOM 仍取不到;mounted 后才能拿到真实 DOM。
父子组件初始化与挂载顺序
为什么需要: 父组件模板里使用了子组件,Vue 必须先创建父、再创建子,子挂载完成后父才算挂载完成。理解顺序,才能知道在哪个钩子里能拿到子组件或真实 DOM。
怎么想: 初始化挂载顺序可记为:父 beforeCreate → 父 created → 父 beforeMount → 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted → 父 mounted。本节代码运行后日志编号 1—8 正是这个顺序。
怎么做: 先看本节完整示例:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
<button @click="userName+='!'">{{userName}}</button>
<button @click="isShow=!isShow">isShow取反</button>
<One v-if="isShow" :user-name="userName"></One>
</div>
</body>
<script>
new Vue({
el:"#app",
data:{
userName:"zhangsan",
isShow:true
},
components:{
// 父组件更新如果影响到子组件视图的话,子组件会执行更新阶段的钩子。
One:{
props:["user-name"],
template:(`
<h3>one:{{userName}}</h3>
`),
beforeCreate(){
console.log("4-One->beforeCreate")
},
created(){
console.log("5-One->created");
},
beforeMount(){
console.log("6-One->beforeMount");
},
mounted(){
console.log("7-One->mounted");
},
beforeUpdate(){
console.log("one->beforeUpdate")
},
updated(){
console.log("one->updated")
},
beforeDestroy() {
console.log("one->beforeDestroy")
},
destroyed(){
console.log("one->destroyed")
}
}
},
beforeCreate(){
console.log("1-beforeCreate")
},
created(){
console.log("2-created");
},
beforeMount(){
console.log("3-beforeMount");
},
mounted(){
console.log("8-mounted");
}
})
</script>
</html>易错点: 父 mounted 晚于子 mounted,因为父要等子挂载完;props 名与父组件传值写法要对应,示例中父组件写 :user-name,子组件声明 "user-name",模板里访问 userName 时要注意大小写问题。
更新阶段钩子
为什么需要: 数据变化后视图可能需要更新,更新前、更新后常用来做状态同步或读取更新后的 DOM。
怎么想: 更新钩子不是数据一变就一定触发。笔记注释明确:父组件更新如果影响到子组件视图,子组件才执行更新阶段钩子。点击 userName+='!' 修改父数据,子组件接收的 user-name 变化且模板用到它,所以子组件执行 beforeUpdate、updated。
怎么做: 运行示例,先看初始化日志,再点第一个按钮,观察控制台出现 one->beforeUpdate、one->updated。本节父组件没写更新钩子,所以只看到子组件更新日志。
易错点: 不要以为父数据变子组件一定更新;只有影响子组件视图时才会触发。更新阶段可执行多次,和挂载阶段只执行一次不同。
销毁阶段钩子
为什么需要: 组件被移除时要清理资源,例如定时器、事件监听,避免内存泄漏。
怎么想: 示例用 v-if="isShow" 控制 One 是否存在。点击第二个按钮把 isShow 取反为 false,子组件被销毁,执行 beforeDestroy、destroyed。
怎么做: 点击“isShow取反”,观察 one->beforeDestroy、one->destroyed;再点一次让 isShow 变回 true,子组件重新创建,会再次走子组件初始化挂载钩子。
易错点: v-if 为 false 是销毁组件,不是隐藏;销毁钩子只执行一次。挂载阶段钩子也只执行一次,但组件重新创建后会重新执行。
调试与验证方法
为什么需要: 生命周期顺序只看文字容易混,必须用日志验证。
怎么想: 转写稿中通过 console.log 依次验证:beforeCreate 时数据、方法、真实 DOM 都拿不到;created 时数据和方法有了,真实 DOM 还没有;mounted 后才能获取真实 DOM。挂载阶段常用于发请求、操作真实 DOM;beforeCreate 可用于权限判断等初始化前逻辑。
怎么做: 在示例各钩子里打印标识,按“初始化 → 点第一个按钮 → 点第二个按钮”操作,对照日志顺序理解。
易错点: 不要在 mounted 之前操作真实 DOM;不要在 beforeCreate 里依赖 data。更新和销毁日志要结合按钮操作观察,不能只看首次加载。
本节说明
本节标题是“Vue 脚手架”,但转写稿实际在串手写 Vue 的核心链路:Object.assign、视图改数据、数据改视图、响应式、事件解析、订阅者与挂载生命周期。下面按这条链路整理,代码只保留转写稿明确提到的写法。
Object.assign 与原型扩展
为什么需要:手写 Vue 时经常要把多个对象合并,或给原型批量挂方法;不理解 Object.assign 的目标对象、源对象和返回值,就会误以为它“新建了一个对象”。
怎么想:第一个参数是目标对象,其余参数是源对象;源对象属性合并到目标对象,返回值就是目标对象。
怎么做:
js
const obj = { a: 1 };
const obj2 = { b: 2 };
const obj3 = Object.assign(obj, obj2);
// obj 和 obj3 是同一个目标对象
const obj4 = Object.assign({}, obj, obj2); // 合并成新对象给原型加方法时,也可把“原型对象”作为目标对象,把 { fn, m } 作为源对象,阅读性更好。
易错点:Object.assign(obj, obj2) 会改 obj;想得到新对象,第一个参数要写 {}。转写稿反复强调“目标对象受影响,源对象不受影响”,调试时打印 obj === obj3 即可验证。
视图更新数据状态
为什么需要:只把数据渲染到视图还不够,输入框改内容后要能回写数据,否则“视图影响数据”这一环断了。
怎么想:v-model 要干两件事:把数据值赋给输入框 value;给节点加 input 事件,输入时更新对应数据状态。
怎么做:
js
node.addEventListener('input', (e) => {
this.$data[attrValue] = e.target.value;
});这里用箭头函数,this 才指向外部实例;若用普通函数,this 会指向触发事件的元素。
易错点:转写稿提醒不要写死属性名,要用 attrValue 这类变量;this.$data[attrValue] 与 this.$data.attrValue 不同,后者是固定属性名。调试时先打印 this.$data[attrValue],再改输入框看是否同步。
数据改视图与响应式
为什么需要:视图改数据已经通了,但数据变了视图没变,就不算响应式。需要监听数据变化,并通知订阅者更新视图。
怎么想:用 Object.defineProperty 把 $data 里的属性代理到实例上,在 get 里取值,在 set 里改值并通知订阅者。
怎么做:
js
for (let k in this.$data) {
Object.defineProperty(this, k, {
get() { return this.$data[k]; },
set(v) { this.$data[k] = v; /* 通知订阅者 */ }
});
}转写稿的调试结论是:要先调用发布者/代理逻辑,再调用解析器;否则解析器里写 this[attrValue] 时,实例上还没有这些代理属性,只能写 this.$data[...]。
易错点:顺序反了会出现“模板里取不到实例属性”;set 里只改值不通知,视图不会更新。
订阅者与 Watcher
为什么需要:一个数据可能对应多个视图位置,数据一变要执行多个更新动作,不能到处写 document 查找。
怎么想:给每个数据建一个数组,数组里放订阅者;解析模板时把“更新某个节点某属性”的函数 push 进去;数据变化时遍历执行。
怎么做:
js
this.$binding[k] = [];
this.$binding[k].push(function () {
// 更新节点属性
});
// 数据变化后
this.$binding[k].forEach(item => item.update());转写稿把更新行为抽成 Watcher,构造函数接收 node、attr、vm、key,原型上提供 update,把节点属性设置为实例状态值。
易错点:数组里放的是对象就要调 item.update();转写稿调试时出现“item is not a function”,就是因为数组里放成了对象却当函数调用。另外只解析第一层时,间接包裹的元素没反应,需要递归 compile 子元素。
事件解析
为什么需要:模板里写 @click 后,解析器要识别并绑定事件,否则按钮点了没反应。
怎么想:解析节点时判断 node.hasAttribute('@click'),有就 addEventListener('click', ...);事件名从属性里取,再到 methods 中找对应函数。
怎么做:
js
if (node.hasAttribute('@click')) {
const key = node.getAttribute('@click');
node.addEventListener('click', this[key]);
}转写稿先把 config.methods 挂到实例上,并绑定 this,这样 this[key] 才能取到方法。
易错点:不能直接写死 changeSTR;要先判断属性是否存在。普通函数里的 this 指向元素,需要提前绑定实例。
挂载阶段生命周期
为什么需要:要知道实例创建、数据初始化、模板编译、真实 DOM 替换分别发生在什么时候,才能在正确阶段取数据、改数据、发请求。
怎么想:转写稿按顺序验证:beforeCreate → created → beforeMount → mounted。beforeCreate 时 data、methods、refs 都没有;created 时已有 data、methods,但 refs 还没有;beforeMount 可最后调整数据;mounted 后能拿到真实 DOM,适合发 Ajax、操作真实节点。
怎么做:在钩子里打印验证:
js
beforeCreate() { console.log(this.$data, this.fn, this.$refs.h3Ref); }
created() { console.log(this.$data, this.fn, this.$refs.h3Ref); }
mounted() { console.log(this.$refs.h3Ref); }易错点:挂载阶段的钩子只执行一次;beforeMount 改数据不会触发更新,因为视图还没挂载完成;mounted 后才拿得到 refs。转写稿的调试结论是:看到 mounted 输出真实元素,才说明挂载完成。
一、render 在挂载阶段的位置
为什么需要:模板写在 template 或 el 里时,它只是字符串或一段 DOM,浏览器不会自动把 {{ title }} 变成数据。必须经过编译,再由渲染函数把结果放进视图。转写稿里说得很直接:编译模板,然后“通过渲染函数把你的模板放入到视图里面”。
怎么想:把 render 放到生命周期里看。挂载阶段大致是:new Vue → beforeCreate → 初始化 data/methods 等 → created → 判断 el 或 vm.$mount → 有 template 用 template,没有就用 el.outerHTML 作为模板 → 编译模板 → 渲染函数渲染 → beforeMount → 替换 el → mounted。所以 render 不是让你手动 document.querySelector,而是模板编译后的渲染入口。
怎么做:先用钩子顺序验证挂载流程:
js
new Vue({
el: '#app',
data: { title: '挂载阶段' },
beforeCreate() { console.log('beforeCreate') },
created() { console.log('created', this.$data) },
beforeMount() { console.log('beforeMount') },
mounted() { console.log('mounted', this.$refs.h3Ref) }
})模板中可写:
html
<h3 ref="h3Ref">{{ title }}</h3>易错点:beforeCreate 里拿不到 data、methods、$refs;created 有 $data 但还没有真实 DOM;mounted 才能稳定拿到 $refs。转写稿调试时输出 $data、methods、$refs,发现 beforeCreate 全是空的,这是正常现象。另外,挂载阶段的钩子只执行一次,不要把它当成频繁更新的钩子。
二、用 Object.assign 整理渲染前的准备代码
为什么需要:给原型增加多个方法时,如果反复写 Foo.prototype.fn = ...、Foo.prototype.my = ...,代码散、可读性差。render 前的准备逻辑越清晰,越容易排查问题。
怎么想:Object.assign 的第一个参数是目标对象,其余是源对象;源对象属性合并到目标对象,返回值也是目标对象。想合并成新对象,就把 {} 放第一个参数。
怎么做:
js
const obj = { a: 1 }
const obj2 = { b: 2 }
const obj3 = Object.assign(obj, obj2) // obj 被修改,obj3 === obj
const obj4 = Object.assign({}, obj, obj2) // 新对象
Object.assign(Foo.prototype, {
fn() {},
my() {}
})易错点:Object.assign(obj, obj2) 会改 obj;不想污染原对象就用 Object.assign({}, obj, obj2)。转写稿还反复强调点和中括号:obj.name 右边是属性名;obj[key] 中不加引号的 key 是变量。写 this.$data[attrValue] 时,attrValue 是变量,不能写死成某个属性名。
三、视图改数据:input 事件是入口
为什么需要:render 出来的输入框要能反向更新数据状态,否则只能看不能改。
怎么想:v-model 对应两件事:把数据赋给 value;监听 input 事件。事件回调里要更新 this.$data[属性名]。转写稿建议用 addEventListener,不要用 oninput,因为后者容易覆盖。
怎么做:
js
node.addEventListener('input', (e) => {
this.$data[attrValue] = e.target.value
})易错点:普通函数里的 this 指向元素,不是 Vue 实例;可以用箭头函数,或提前 const vm = this。中括号里不要随便加引号:this.$data['STR'] 是固定属性,this.$data[attrValue] 才是按变量取属性。
四、数据改视图:订阅者让 render 结果跟着状态变
为什么需要:只做到“视图改数据”还不够。转写稿调试时输入框内容变了,this.$data.STR 也变了,但下面 DIV 没变,所以不是响应式。需要 setter 通知订阅者,再由订阅者更新节点。
怎么想:用 Object.defineProperty 拦截每个 data 属性;get 返回 $data[key];set 改值后执行 $bindings[key] 里的订阅者。订阅者本质是“拿到节点和属性,把新值写回去”的函数或对象。
怎么做:
js
for (let key in this.$data) {
Object.defineProperty(this, key, {
get() { return this.$data[key] },
set(v) {
this.$data[key] = v
this.$bindings[key].forEach(item => item.update())
}
})
}
this.$bindings[key] = []
this.$bindings[key].push(new Watcher(node, 'value', this, key))易错点:要先建立拦截,再解析模板;否则解析时 this[key] 还不存在。转写稿里点击按钮报 item is not a function,原因是数组里 push 的是 Watcher 对象,不是函数,应调用 item.update()。另外不要用赋值覆盖订阅者数组,应该 push。
五、把 render 和挂载钩子串起来调试
为什么需要:确认 render 何时执行、DOM 何时可用,避免在错误阶段操作元素。
怎么想:beforeMount 在挂载前,mounted 在挂载后;mounted 适合发 Ajax、修改数据状态。转写稿验证顺序是 beforeCreate → created → beforeMount → mounted,并指出 mounted 用得最多。
怎么做:在 mounted 里通过 $refs 取真实元素;在 beforeMount 里做挂载前最后一次数据调整。
易错点:beforeCreate 不要访问 $data;created 不要访问 $el;beforeMount 改数据是给首次挂载用,不要期待它再走一次更新流程;mounted 只执行一次,不要把频繁更新逻辑放进去。
课堂画面






代码示例
示例 1:手写响应式框架 Zhang7(发布者 + 订阅者 + 解析器)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Zhang7 实现响应式</title>
</head>
<body>
<div id="root">
<button @click="changeStr">点我</button>
<input z-model="str" type="text">
<div z-text="str"></div>
<div z-html="str"></div>
<hr/>
<div>
<input type="text" z-model="num">
<div z-text="num"></div>
</div>
</div>
</body>
<script>
function Zhang(config){
// 增加实例属性 $data
this.$data = config.data;
// 增加实例属性 $el
this.$el = document.querySelector(config.el);
// 绑定数据下的订阅者
this.binds = {};
for(let key in config.methods){
this[key] = config.methods[key].bind(this);
}
// 先调用发布者(把 data 的属性代理到实例上,并建立 binds)
this.observe();
// 再调用解析器(解析时要用 this[attrValue] 取值)
this.compile(this.$el);
}
Object.assign(Zhang.prototype, {
// 发布者:1- 侦听数据发生改变 2- 通知订阅者
observe(){
for(let key in this.$data){
this.binds[key] = [];
Object.defineProperty(this, key, {
get(){
return this.$data[key];
},
set(v){
this.$data[key] = v;
this.binds[key].forEach(item => item.update());
}
});
}
},
// 解析器:递归解析模板
compile(el){
const nodes = el.children;
for(let i = 0; i < nodes.length; i++){
// 获取要解析的具体元素
const node = nodes[i];
// 递归解析子元素(解决“间接包裹解析不到”的问题)
this.compile(node);
// 判断元素是否拥有 @click 属性
if(node.hasAttribute("@click")){
const attrValue = node.getAttribute("@click");
// 为 node 增加 click 事件
node.addEventListener("click", this[attrValue]);
}
// 判断元素是否使用了 z-model
if(node.hasAttribute("z-model")){
const attrValue = node.getAttribute("z-model"); // str
// input 的 value 设置为 data 中对应属性值
node.value = this[attrValue];
// 为 node 增加 input 事件,视图 -> 数据
node.addEventListener("input", (e)=>{
// 更新 str 数据状态
this[attrValue] = e.target.value;
});
// 提供一个修改 node 元素 value 属性值的方法(订阅者)
this.binds[attrValue].push(new Watcher(node, "value", this, attrValue));
}
if(node.hasAttribute("z-text")){
const attrValue = node.getAttribute("z-text");
node.innerText = this[attrValue];
this.binds[attrValue].push(new Watcher(node, "innerText", this, attrValue));
}
if(node.hasAttribute("z-html")){
const attrValue = node.getAttribute("z-html");
node.innerHTML = this[attrValue];
this.binds[attrValue].push(new Watcher(node, "innerHTML", this, attrValue));
}
}
}
});
/*
* node:元素
* attr:元素属性名
* zm:实例
* prop:实例属性名
* */
function Watcher(node, attr, zm, prop){
this.node = node;
this.attr = attr;
this.zm = zm;
this.prop = prop;
}
Watcher.prototype.update = function(){
// 将元素下的某个属性的值设置为实例状态中的某个属性值
this.node[this.attr] = this.zm[this.prop];
};
// 数据与订阅者是一对多的关系
const zm = new Zhang({
el: "#root",
data: {
str: "<a>我在尚硅谷学习</a>",
num: 1
},
methods: {
changeStr(){
this.str += "!";
this.num++;
}
}
});
</script>
</html>关键行与易错点
this.observe()必须写在this.compile(this.$el)之前:observe里用Object.defineProperty(this, key, ...)把$data的属性代理到了实例上,解析器里才能写this[attrValue];顺序反过来取到的是undefined。Object.assign(Zhang.prototype, {...})是把整块方法一次性挂到原型上,比连续写Zhang.prototype.compile = ...可读性好;注意它返回的就是第一个参数(目标对象),所以Zhang.prototype会被修改。this.binds[key] = []在observe里初始化:每个数据对应一个数组(一对多),z-model、z-text、z-html分别push各自的Watcher。compile里先递归this.compile(node)再判断自身属性,这样多层嵌套的<div><input z-model></div>也能解析到;递归出口是元素没有子元素时el.children长度为 0。config.methods[key].bind(this)保证@click="changeStr"里的this指向实例,否则this.str是undefined。- 易错点:
z-model="xxx"中的xxx必须是data里已有的键,否则this.binds[attrValue]为undefined,.push会直接报错。
示例 2:挂载阶段生命周期钩子
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>挂载阶段</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
</head>
<body>
<div id="app">
<h3 ref="h3Ref">{{title}}</h3>
</div>
</body>
<script>
// 挂载阶段的钩子函数在整个生命周期中只会执行一次
new Vue({
el: "#app",
data: {
title: "挂载阶段"
},
methods: {
fn(){}
},
beforeCreate(){
// 实例创建完成之前调用:常用于判断用户权限以及设置 Vue.prototype
// Vue.prototype.$axios = axios.create({ baseURL });
// Vue.prototype.$bus = new Vue();
console.group("**********1-beforeCreate**********");
console.log("$data", this.$data); // undefined
console.log("data", this.title); // undefined
console.log("methods", this.fn); // undefined
console.log("ref", this.$refs.h3Ref); // undefined
console.groupEnd();
},
created(){
// 实例创建完成,可以在此处调用属性以及方法
this.title = "学习挂载阶段"; // 修改数据状态,不会更新视图
console.group("**********2-created**********");
console.log("$data", this.$data); // {title:"学习挂载阶段"}
console.log("data", this.title); // 学习挂载阶段
console.log("methods", this.fn); // function(){}
console.log("ref", this.$refs.h3Ref); // undefined
console.groupEnd();
},
beforeMount(){
this.title = "正在学习挂载阶段";
console.group("**********3-beforeMount**********");
console.log("$data", this.$data); // {title:"正在学习挂载阶段"}
console.log("data", this.title); // 正在学习挂载阶段
console.log("methods", this.fn); // function(){}
console.log("ref", this.$refs.h3Ref); // undefined
console.groupEnd();
},
mounted(){
// 可以在此处通过 AJAX 调用数据,并修改数据状态
console.group("**********4-mounted**********");
console.log("$data", this.$data); // {title:"正在学习挂载阶段"}
console.log("data", this.title); // 正在学习挂载阶段
console.log("methods", this.fn); // function(){}
console.log("ref", this.$refs.h3Ref); // h3 元素
console.groupEnd();
}
});
</script>
</html>关键行与易错点
- 执行顺序固定为
beforeCreate → created → beforeMount → mounted,与代码书写顺序无关,只跟生命周期阶段有关。 beforeCreate里this.$data、this.title、this.fn全是undefined:此时数据侦听、方法代理、内部事件都还没初始化;所以这里适合做Vue.prototype.$xxx这类全局挂载。created里能拿到$data、data、methods,但this.$refs.h3Ref仍是undefined——因为模板还没挂载。注意:created里改title不会更新视图(DOM 还没生成)。- 只有
mounted之后才能拿到真实 DOM(this.$refs.h3Ref为h3元素),所以 Ajax 请求、定时器、DOM 操作都写在这里。 - 易错点:挂载阶段四个钩子在实例生命周期中只执行一次,不要指望它们随数据变化重复触发。
示例 3:更新阶段与卸载阶段钩子
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>更新阶段</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
</head>
<body>
<button onclick="fn()">外部销毁</button>
<div id="app">
<button ref="btnRef" @click="num+=1">{{num}}</button>
<button @click="$destroy()">销毁</button>
</div>
</body>
<script>
const vm = new Vue({
el: "#app",
data: {
num: 1
},
beforeCreate(){
console.log("1-beforeCreate");
},
created(){
console.log("2-created");
},
beforeMount(){
console.log("3-beforeMount");
},
mounted(){
console.log("4-mounted");
this.timer = setInterval(()=>{
console.log("interval");
this.num++;
}, 1000);
},
// beforeUpdate、updated 只有数据状态发生更改以后才会执行
beforeUpdate(){
// 视图更新之前,数据已经更新
console.log("1-beforeUpdate", this.num, this.$refs.btnRef.innerText);
},
updated(){
// 视图更新之后,数据和视图都是最新的
console.log("2-updated", this.num, this.$refs.btnRef.innerText);
},
beforeDestroy(){
console.log("1-beforeDestroy", this.num, this.$refs.btnRef);
},
destroyed(){
console.log("2-destroyed", this.num, this.$refs.btnRef);
clearInterval(this.timer);
}
});
function fn(){
vm.$destroy();
}
</script>
</html>关键行与易错点
beforeUpdate打印出的this.num是新值,而this.$refs.btnRef.innerText还是旧值;updated里两者才一致——这是区分两个钩子最直观的验证方式。beforeUpdate / updated只有在数据变化并触发视图重新渲染后才执行,初始化时不执行。this.timer = setInterval(...)挂在实例上(不是data里),避免定时器 ID 被响应式处理。- 卸载阶段必须成对清理:
destroyed里clearInterval(this.timer),否则实例销毁后定时器仍在跑、仍在改this.num。 - 易错点:
$destroy()只是销毁实例(事件监听、子组件、watcher),不会清理由你自己创建的定时器、事件监听或document上的绑定,必须手动在beforeDestroy/destroyed里清。
常见错误
模板里数据全是空白/undefined 现象:
z-text、z-model渲染不出值,this.str为undefined。 原因:构造函数里先调了compile再调observe,此时Object.defineProperty的代理还没建立,实例上根本没有str属性。 修法:this.observe()必须在this.compile(this.$el)之前调用,先建代理再解析模板。包一层 div 后指令全部失效 现象:
z-model、z-text直接放在#root下正常,一旦再套一层<div>就毫无反应。 原因:compile只遍历了el.children这一层,没有向下递归。 修法:在for循环内对每个node递归调用this.compile(node),并注意递归出口(无子元素时自然结束)。同一数据绑定多个元素,只有最后一个更新 现象:
z-text和z-html都绑str,改数据只有z-html那块变。 原因:写成了this.binds[key] = fn(赋值覆盖),而不是push。 修法:observe里先this.binds[key] = []建空数组,编译时统一push,set里forEach遍历执行。报
item.update is not a function现象:一改数据就抛错,视图不更新。 原因:binds[key]里存的是Watcher实例,set里却当成函数直接item()调用。 修法:存对象就调item.update(),存函数就直接item(),两者不能混用。@click方法里this不是实例 现象:点击按钮后this.str报错或改不动数据,this指向 DOM 元素。 原因:addEventListener的回调 this 是节点本身,methods里的方法没有绑定实例。 修法:构造时for...in config.methods逐个bind(this);或在解析@click时用箭头函数包一层再取this[attrValue]。在
created里改数据,页面纹丝不动 现象:created中this.title = 'xx',控制台打印已是新值,视图还是旧值。 原因:created阶段尚未编译挂载,改数据没有订阅者可通知;真正渲染发生在beforeMount与mounted之间。 修法:初始化数据放data,依赖 DOM 或需要异步取数的逻辑放mounted,beforeMount适合做挂载前最后一次数据修正。在
destroyed里清理定时器/操作$refs失败 现象:destroyed中this.$refs.btnRef为undefined,清理逻辑没生效。 原因:卸载完成后实例上的 DOM 引用已被清除。 修法:清理定时器、解绑事件等收尾工作放beforeDestroy,此时$refs仍然可用;destroyed只做纯数据层面的收尾。误以为
mounted会随更新反复执行 现象:在mounted里打日志,期望每次改数据都打印,结果只有一次。 原因:挂载阶段四个钩子(beforeCreate/created/beforeMount/mounted)在整个生命周期中只执行一次,数据变化走的是beforeUpdate/updated。 修法:把"每次更新都要做的事"移到updated(注意防死循环),挂载阶段钩子只放一次性初始化。扩展原型时把已有方法冲掉 现象:给
Zhang.prototype加完方法后,new Zhang报某方法不是函数,甚至constructor丢失。 原因:写成Zhang.prototype = { ... }直接替换了原型对象。 修法:用Object.assign(Zhang.prototype, {...})合并到原原型上;目标对象是第一个参数且会被原地修改并返回,需要新对象时应传字面量{}作目标。
练习
打开
Zhang7.js,把observe()与compile()的调用顺序对调,运行页面并记录三处现象(输入框、z-text、z-html各自表现),然后改回正确顺序,用一句话写出"为什么顺序不能换"。给
Zhang增加z-show指令:值为false时元素隐藏,为true时显示。要求:初始渲染正确,且后续数据变化能实时更新。提示——想一想z-show应该操作哪个属性,以及初始值和Watcher的先后关系。写一个测试页,
data中放num: 1,页面上放一个按钮@click="num+=1",在beforeCreate、created、beforeMount、mounted、beforeUpdate、updated中同时打印this.num和this.$refs.btnRef && this.$refs.btnRef.innerText。连续点三次按钮,画出完整的输出顺序表,并指出哪两个钩子里"数据新、视图旧"。在
created中写this.title = 'A',在beforeMount中写this.title = 'B',data初始值为'C'。预测并验证页面最终显示什么,然后说明:为什么created的修改"看起来无效",而beforeMount的修改"直接生效且不触发更新钩子"。复现并修复内存泄漏:在
mounted中用setInterval每秒this.num++,页面上放一个"销毁"按钮调用$destroy()。观察销毁后控制台是否还在输出,然后把清理逻辑移到beforeDestroy并说明为什么不放destroyed。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | assign | 06:23 |
| 2 | 视图更新数据状态 | 14:38 |
| 3 | 实现响应式 | 28:24 |
| 4 | 增加事件 | 07:28 |
| 5 | 实现代理 | 07:37 |
| 6 | 挂载阶段 | 26:07 |
小结
observe与compile的调用顺序是这套响应式的第一约束:代理先建,模板后解析,否则模板取不到实例属性。- 数据与订阅者是"一对多",
binds[key]必须初始化为数组并用push累积,任何一次赋值覆盖都会让前面的绑定静默失效。 - 订阅者数组里存什么类型,
set里就必须按对应方式调用(item()vsitem.update()),存与用必须成对设计。 - 编译器的递归决定了模板的可用层级,只遍历一层等于把"能不能套 div"这种结构问题变成了功能 bug。
- 挂载阶段的四个钩子一生只跑一次,更新阶段的钩子按数据变化次数跑,卸载阶段各跑一次——用这个次数规律就能快速判断自己把逻辑放错了阶段。
created改数据不触发视图更新是设计使然(还没挂载),mounted才是"DOM 可用 + 视图已渲染"的第一个安全点。beforeUpdate是"数据新、视图旧",updated是"数据视图一致",这两个钩子可以直接当作调试定位手段。- 收尾资源(定时器、事件、订阅)放在
beforeDestroy,此时$refs仍有效;destroyed只适合做不依赖 DOM 的纯数据清理。 - 扩展原型一律用
Object.assign(原型, {...})合并,永远不要用字面量直接替换原型对象。