Skip to content

实现响应式与生命周期(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>

易错点小结 ​

  1. beforeCreate 中 $data、data、methods、$refs 都拿不到。
  2. created 中数据和方法可用,但 $refs 仍为 undefined。
  3. created 中改数据不会更新视图,因为还没挂载。
  4. beforeMount 中改数据,挂载时会用最新值;但此时仍拿不到真实 DOM。
  5. mounted 中才能通过 $refs 拿到真实元素,常用于 DOM 操作和 AJAX 初始化。
  6. 挂载阶段四个钩子只执行一次,后续更新阶段不会重复执行。

更新阶段在生命周期中的位置 ​

转写稿里反复讲“生命周期是从实例创建到销毁的过程”,并把钩子函数按阶段串起来:挂载阶段执行 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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 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/[email protected]/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/[email protected]/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 里清。

常见错误 ​

  1. 模板里数据全是空白/undefined 现象:z-text、z-model 渲染不出值,this.str 为 undefined。 原因:构造函数里先调了 compile 再调 observe,此时 Object.defineProperty 的代理还没建立,实例上根本没有 str 属性。 修法:this.observe() 必须在 this.compile(this.$el) 之前调用,先建代理再解析模板。

  2. 包一层 div 后指令全部失效 现象:z-model、z-text 直接放在 #root 下正常,一旦再套一层 <div> 就毫无反应。 原因:compile 只遍历了 el.children 这一层,没有向下递归。 修法:在 for 循环内对每个 node 递归调用 this.compile(node),并注意递归出口(无子元素时自然结束)。

  3. 同一数据绑定多个元素,只有最后一个更新 现象:z-text 和 z-html 都绑 str,改数据只有 z-html 那块变。 原因:写成了 this.binds[key] = fn(赋值覆盖),而不是 push。 修法:observe 里先 this.binds[key] = [] 建空数组,编译时统一 push,set 里 forEach 遍历执行。

  4. 报 item.update is not a function 现象:一改数据就抛错,视图不更新。 原因:binds[key] 里存的是 Watcher 实例,set 里却当成函数直接 item() 调用。 修法:存对象就调 item.update(),存函数就直接 item(),两者不能混用。

  5. @click 方法里 this 不是实例 现象:点击按钮后 this.str 报错或改不动数据,this 指向 DOM 元素。 原因:addEventListener 的回调 this 是节点本身,methods 里的方法没有绑定实例。 修法:构造时 for...in config.methods 逐个 bind(this);或在解析 @click 时用箭头函数包一层再取 this[attrValue]。

  6. 在 created 里改数据,页面纹丝不动 现象:created 中 this.title = 'xx',控制台打印已是新值,视图还是旧值。 原因:created 阶段尚未编译挂载,改数据没有订阅者可通知;真正渲染发生在 beforeMount 与 mounted 之间。 修法:初始化数据放 data,依赖 DOM 或需要异步取数的逻辑放 mounted,beforeMount 适合做挂载前最后一次数据修正。

  7. 在 destroyed 里清理定时器/操作 $refs 失败 现象:destroyed 中 this.$refs.btnRef 为 undefined,清理逻辑没生效。 原因:卸载完成后实例上的 DOM 引用已被清除。 修法:清理定时器、解绑事件等收尾工作放 beforeDestroy,此时 $refs 仍然可用;destroyed 只做纯数据层面的收尾。

  8. 误以为 mounted 会随更新反复执行 现象:在 mounted 里打日志,期望每次改数据都打印,结果只有一次。 原因:挂载阶段四个钩子(beforeCreate/created/beforeMount/mounted)在整个生命周期中只执行一次,数据变化走的是 beforeUpdate/updated。 修法:把"每次更新都要做的事"移到 updated(注意防死循环),挂载阶段钩子只放一次性初始化。

  9. 扩展原型时把已有方法冲掉 现象:给 Zhang.prototype 加完方法后,new Zhang 报某方法不是函数,甚至 constructor 丢失。 原因:写成 Zhang.prototype = { ... } 直接替换了原型对象。 修法:用 Object.assign(Zhang.prototype, {...}) 合并到原原型上;目标对象是第一个参数且会被原地修改并返回,需要新对象时应传字面量 {} 作目标。

练习 ​

  1. 打开 Zhang7.js,把 observe() 与 compile() 的调用顺序对调,运行页面并记录三处现象(输入框、z-text、z-html 各自表现),然后改回正确顺序,用一句话写出"为什么顺序不能换"。

  2. 给 Zhang 增加 z-show 指令:值为 false 时元素隐藏,为 true 时显示。要求:初始渲染正确,且后续数据变化能实时更新。提示——想一想 z-show 应该操作哪个属性,以及初始值和 Watcher 的先后关系。

  3. 写一个测试页,data 中放 num: 1,页面上放一个按钮 @click="num+=1",在 beforeCreate、created、beforeMount、mounted、beforeUpdate、updated 中同时打印 this.num 和 this.$refs.btnRef && this.$refs.btnRef.innerText。连续点三次按钮,画出完整的输出顺序表,并指出哪两个钩子里"数据新、视图旧"。

  4. 在 created 中写 this.title = 'A',在 beforeMount 中写 this.title = 'B',data 初始值为 'C'。预测并验证页面最终显示什么,然后说明:为什么 created 的修改"看起来无效",而 beforeMount 的修改"直接生效且不触发更新钩子"。

  5. 复现并修复内存泄漏:在 mounted 中用 setInterval 每秒 this.num++,页面上放一个"销毁"按钮调用 $destroy()。观察销毁后控制台是否还在输出,然后把清理逻辑移到 beforeDestroy 并说明为什么不放 destroyed。

原视频对照 ​

#视频时长
1assign06:23
2视图更新数据状态14:38
3实现响应式28:24
4增加事件07:28
5实现代理07:37
6挂载阶段26:07

小结 ​

  • observe 与 compile 的调用顺序是这套响应式的第一约束:代理先建,模板后解析,否则模板取不到实例属性。
  • 数据与订阅者是"一对多",binds[key] 必须初始化为数组并用 push 累积,任何一次赋值覆盖都会让前面的绑定静默失效。
  • 订阅者数组里存什么类型,set 里就必须按对应方式调用(item() vs item.update()),存与用必须成对设计。
  • 编译器的递归决定了模板的可用层级,只遍历一层等于把"能不能套 div"这种结构问题变成了功能 bug。
  • 挂载阶段的四个钩子一生只跑一次,更新阶段的钩子按数据变化次数跑,卸载阶段各跑一次——用这个次数规律就能快速判断自己把逻辑放错了阶段。
  • created 改数据不触发视图更新是设计使然(还没挂载),mounted 才是"DOM 可用 + 视图已渲染"的第一个安全点。
  • beforeUpdate 是"数据新、视图旧",updated 是"数据视图一致",这两个钩子可以直接当作调试定位手段。
  • 收尾资源(定时器、事件、订阅)放在 beforeDestroy,此时 $refs 仍有效;destroyed 只适合做不依赖 DOM 的纯数据清理。
  • 扩展原型一律用 Object.assign(原型, {...}) 合并,永远不要用字面量直接替换原型对象。