Skip to content

$watch与$el(Lesson6 3/3) ​

本页对应 Lesson06,共 5 段视频,约 31 分钟。

学习目标 ​

  • 能在 Vue 实例中通过 $data 读取配置数据,并通过控制台验证其与配置对象中 data 的引用关系,演示从外部修改数据后视图同步更新。
  • 能使用 $mount 完成手动挂载,说明 $el 指向的挂载根元素,并对比 el 配置项与手动挂载的执行效果。
  • 能通过 $options 读取实例配置项及自定义属性,并区分 data、methods、filters 等选项在实例上的暴露方式。
  • 能编写一个自定义指令,说明其钩子函数在挂载完成前、数据更新后等时机的执行顺序,并验证指令内 this 的指向及 DOM 获取结果。
  • 能使用 Object.defineProperty 为对象属性定义 get/set 拦截,演示读取和赋值时的拦截行为,并解释其与 Vue 响应式原理的关系。
  • 能区分单向数据绑定与伪双向绑定,说明数据驱动视图与视图反向修改数据的差异,并指出仅靠 DOM 事件同步数据为何不等于真正双向绑定。

知识点 ​

为什么需要 $watch ​

在 Vue 里,数据一变,视图就跟着变,这是模板帮我们做的事。但有些时候,数据变化后要执行的动作不在模板里:比如把新值写到一个 Vue 管不到的 DOM 上、打日志、发请求、做二次计算。模板插值只能渲染,管不了这些"副作用"。

这一节笔记里就有一个典型场景:<p></p> 这个标签写在 #app 外面,Vue 根本不会去渲染它。可我们又希望 num 一变,<p> 里的文字就跟着变。$watch 就是干这个的——监听某个数据,变化时执行我自己的回调。

转写稿里反复强调 $data 的作用:"可以通过 vm.$data 获取到配置对象里面的数据状态",甚至可以在外部 vm.$data.a++ 直接改数据。既然数据能被外部改、能被拦截,那"数据变了通知我一声"就是顺理成章的需求,$watch 就是官方给的这声通知。

怎么想:$watch 的参数结构 ​

先看笔记里的调用形式:

js
vm.$watch("num", function(newValue, oldValue){
	p.innerText = newValue;
	console.log("num", newValue, oldValue);
})

拆成三块理解:

  1. 第一个参数是要监听的表达式,写字符串 "num",也可以写 "obj.userName" 这种路径。
  2. 第二个参数是回调,Vue 会把新值和旧值按顺序传进来,所以回调里不需要自己去 vm.num 再取一遍。
  3. 第三个参数是配置对象,笔记里第二个 $watch 用了 { deep: true },表示"对象内部的属性变了也要通知我"。

关键点在于:$watch 是实例方法,必须挂在 vm 上调用,它不是写在配置对象里的选项(配置对象里那个叫 watch,是另一回事,注意区分)。

怎么做:完整代码与逐段说明 ​

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="num++">{{num}}</button>
	<input type="text" v-model="obj.userName">
</div>

<p></p>
</body>
<script>
	const p =document.querySelector("p");
	const vm = new Vue({
		el:"#app",
		data:{
			num:1,
			obj:{
				userName:"laowangtou"
			}
		}
	});
	vm.$watch("num",function(newValue,oldValue){
		p.innerText = newValue;
		console.log("num",newValue,oldValue);
	})
	vm.$watch("obj",function(newValue,oldValue){
		console.log("obj",newValue.userName)
	},{
		deep:true
	})
	
</script>
</html>

执行过程是这样的:

  • 页面加载后 num 是 1,但 <p> 是空的——因为没有初始触发,$watch 只在数据"变化"时才回调。
  • 点按钮触发 @click="num++",num 从 1 变 2,回调执行,newValue 是 2、oldValue 是 1,于是 p.innerText 被写成 2。
  • 在输入框里改 obj.userName,因为 obj 这个对象本身的引用没变,只是内部属性变了,所以必须加 deep: true 才能触发回调。回调里打印 newValue.userName,能看到最新输入。

这里有个容易忽略的细节:p 是在 new Vue 之前用 document.querySelector("p") 拿到的,因为 <p> 在 #app 外面,Vue 编译模板时不会碰它,所以这个引用不会失效。如果 <p> 写在 #app 内部,就要考虑 Vue 重新渲染带来的影响。

为什么它能监听到:顺着转写稿的线索推导 ​

转写稿后半段讲了 Object.defineProperty,说它是"拦截器":读数据时走 get,设置数据时走 set,还现场演示过——读取 obj.userName 会进 get,赋值 obj.userName = "李四" 会进 set,并强调"不读的话 get 就执行不到"。

顺着这个思路推导就很清楚了:Vue 在把 data 变成响应式数据时,给每个属性装上了这样的 get/set。set 被触发的那一刻,Vue 就知道"谁变了、变成什么了",然后把变化前后的值一起交给 $watch 注册的回调。所以回调能同时拿到 newValue 和 oldValue,并不是 Vue 记住了历史,而是赋值那一刻新旧值都在手上。

这也解释了为什么 deep: true 是必要的:obj.userName 的 set 只会通知"userName 变了",obj 这个外层对象并没有被重新赋值,不深度遍历内部属性,就收不到通知。

易错点 ​

  1. 以为会立即执行一次。 $watch 默认只在变化后触发,页面刚打开时 <p> 是空的。如果想要初始就执行一次,需要额外处理(这是笔记里没涉及的部分,先记住"默认不立即执行")。
  2. 监听对象忘记 deep。 监听 "obj" 却改的是 obj.userName,不加 deep: true 回调一次都不会进——这是最常见的坑。
  3. 路径写错。 $watch("obj.userName") 和 $watch("obj") 是两回事,前者只盯一个属性,后者盯整个对象。
  4. 在回调里依赖外部 this。 转写稿在讲自定义指令时专门验证过"里面的 this 是 window",说明 Vue 某些回调里的 this 不能想当然。$watch 回调里最稳妥的写法是直接用参数 newValue、oldValue,或者用外面已经存好的 vm 变量,不要凭感觉写 this.num。
  5. 和 vm.$data、vm.$options 搞混。 转写稿讲过:vm.$data 拿的是配置里的数据状态,vm.$options 拿的是整个配置对象(包括 data、methods、filters,以及你自己随手加的 userName 这类自定义属性,但普通自定义属性不会被挂到实例上,实测输出是 undefined)。$watch 是监听行为,三者职责不同,别混着用。

先看笔记代码:$el 指向什么 ​

笔记里的完整代码如下,先保留原样:

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">
	{{num}}
	<button @click="my">app->{{num}}</button>
</div>
<button onclick="fn()">点我</button>
</body>
<script>
	const vm = new Vue({
		el:"#app",
		data:{
			num:100
		},
		methods:{
			my(){
				// console.log(this===vm)
				this.$el.style.color = "pink";
			}
		}
	})
	function fn(){
		// $el:vm所挂载的元素。
		console.log(vm.$el === document.querySelector("#app"));// true
		vm.$el.style.color="red";
	}
</script>
</html>

运行后可以观察两个动作:点击 app->{{num}} 按钮,my 方法里的 this.$el.style.color = "pink" 会把 #app 这个根元素文字改成粉色;点击外部“点我”,fn 里先输出 vm.$el === document.querySelector("#app"),结果为 true,再把根元素改成红色。由此可知:$el 是当前 Vue 实例所挂载的根 DOM 元素。转写稿里讲 $data、$options 时都强调它们以 $ 开头,是 Vue 实例暴露出来的成员;$el 同理,是实例上的一个 DOM 引用。

为什么需要 $el ​

Vue 主推数据驱动,但实际开发仍有直接操作 DOM 的需求,例如改根元素样式、测量尺寸、给第三方库指定容器。若没有 $el,就只能写 document.querySelector("#app"),一旦挂载点变化,选择器就可能失效。$el 的好处是:它直接指向“这个实例当前挂载的根元素”,不需要再猜选择器。

怎么想:把 $el 理解成“实例的根 DOM 引用”,它不是响应式数据,也不是选择器字符串。怎么做:在 methods 里写 this.$el,在实例外部写 vm.$el。易错点:不要把它当成 "#app" 这样的字符串;改 $el.style 不会触发 num 的数据更新。

在 methods 内使用:this.$el ​

笔记中 my() 里写 this.$el.style.color = "pink",前提是 this 指向 Vue 实例。可以打开注释 console.log(this===vm) 验证,输出 true。因为 methods 里的方法会被代理到实例上,普通函数调用时 this 就是 vm,所以 this.$el 等价于 vm.$el。

怎么做:先确认 this 是实例,再操作 this.$el。易错点:外部 fn() 是普通函数,this 不是 vm,所以必须写 vm.$el,不能写 this.$el。转写稿视频3也演示过:在方法里输出 this,得到的是 Vue 实例,这能辅助理解 this.$el 的来源。

在实例外使用:vm.$el 与选择器对比 ​

fn 中先执行 console.log(vm.$el === document.querySelector("#app")),输出 true,说明 $el 和 document.querySelector("#app") 指向同一个根元素;随后 vm.$el.style.color="red" 直接改样式。为什么需要:原生事件、外部脚本里也能拿到挂载根元素。怎么做:把 new Vue 的返回值保存为 vm,再用 vm.$el。易错点:不要重复 new Vue;比较元素用 ===;$el 是元素对象,不是选择器。

与挂载的关系:$el 不是凭空出现的 ​

转写稿视频2的调试过程很关键:new Vue({ el:"#app" }) 是指定挂载;把 el 删掉后,页面会显示 {{name}},说明实例还没挂载;点击按钮执行 vm.$mount('#app') 后才渲染。这说明 $el 和挂载点绑定,先有挂载,才有对应的根元素引用。转写稿里的“door model”是 $mount 的同音,正确写法是 $mount。

怎么做:需要延迟挂载时,先 const vm = new Vue({...}) 保存实例,条件满足时再 vm.$mount('#app')。易错点:没挂载就急着操作 vm.$el;把 $mount 写成 $el;把 $data、$options、$mount、$el 混为一谈。

易错点汇总 ​

  1. $el 是挂载后的根元素,不是选择器字符串。
  2. methods 里 this.$el 可用,是因为 this 是 vm;外部函数要用 vm.$el。
  3. $el 是根元素,不是组件内部所有元素;要操作子元素还需 querySelector。
  4. 改 $el.style 是直接操作 DOM,不会自动更新 data。
  5. 转写稿视频4里自定义指令的 el 参数表示“当前元素”,不要和 vm.$el 混淆;视频5的 defineProperty 讲的是拦截读写,也不是 $el。

知识点一:$data 用来拿到配置的数据状态 ​

为什么需要:实例创建后,我们经常要在控制台、外部函数或调试代码里访问 data 中配置的 a、b、c。如果只能通过模板插值看值,排查问题会很不方便,所以需要有一个实例属性能直接拿到这份数据状态。

怎么想:在 Vue 2 中,new Vue({ data }) 里的 data 会被挂到实例的 $data 上。转写稿里反复说的“do data / 多少 data”,实际应纠正为 $data;牛 will 是 new Vue,西塔 是 data。可以把它理解成:vm.$data 就是实例当前使用的数据对象。

怎么做:先声明 data,再传给 Vue,最后在控制台展开 vm.$data,能看到 a、b、c。本节完整代码如下:

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">
	<p>a:{{a}}</p>
	<p>b:{{b}}</p>
	<p>c:{{c}}</p>
</div>
<button onclick="fn()">调用我</button>
</body>
<script>
	// 通过$data获取配置的数据状态
	let data = {
		a:1,
		b:2,
		c:3
	}
	const vm = new Vue({
		el:"#app",
		data
	})
	console.log(vm.$data === data);// true
	function fn(){
		vm.$data.a++;
	}
</script>
</html>

易错点:$data 是实例属性,不要写成 vm.data;转写稿里的“处”应纠正为 true,“案钮”是“按钮”,“调用文”是按钮文字“调用我”,“FN”是函数名 fn。

知识点二:$data 与传入的 data 是同一个对象 ​

为什么需要:只看 vm.$data 里有 a、b、c 还不够,我们要确认它到底是“复制了一份数据”,还是“就是原来那份数据”。这关系到外部修改是否会影响实例。

怎么想:转写稿里的推导很直接:先声明 let data = {...},再 new Vue({ data }),然后比较 vm.$data === data。如果结果是 true,说明两者引用地址相同,不是拷贝。

怎么做:在控制台执行 console.log(vm.$data === data),输出 true。转写稿中“我 VM 多 datata 是不是和这个 data 一样,最终得到的也是处”,这里的“处”就是 true。因此可以确认:vm.$data 拿到的就是配置对象里的那份数据。

易错点:=== 比较的是引用,不是比较内容;不要因为页面上显示 a:1 就认为只是值相同,示例要证明的是同一对象。

知识点三:在外部通过 $data 修改数据会驱动视图 ​

为什么需要:实际开发中,数据修改不一定都写在模板事件里,也可能在外部函数、调试代码中发生。需要验证 vm.$data.a++ 是否能触发页面更新。

怎么想:转写稿中把按钮写在 #app 外面,并设置 onclick="fn()",fn 是全局函数,内部执行 vm.$data.a++。因为 vm 也在全局作用域,所以按钮虽然不在 Vue 挂载区域内,仍然能操作实例数据。

怎么做:点击“调用我”按钮,fn 执行 vm.$data.a++,页面中的 a:{{a}} 会从 1 变成 2。转写稿里说“我不仅外部可以调用你的数据,还可以修改你里面的数据”,指的就是这个过程。

易错点:按钮事件要写 fn();如果只写 fn,不会按预期调用。本例修改的是已经存在的属性 a,所以能触发更新;不要把它误解成“任意新增属性都一定响应”。

调试过程与转写稿纠正 ​

转写稿的调试顺序是:先运行页面看到 a、b、c;再在控制台展开 vm.$data,确认里面有 a、b、c;然后用 vm.$data === data 得到 true;最后点击外部按钮,观察 a 自增。这里要把“do data / 多少 data”统一纠正为 $data,把“画载”纠正为“挂载”,把“牛 will”纠正为 new Vue。本节聚焦 $data,$mount、$options、自定义指令和 defineProperty 是相邻内容,不混入本节代码,避免初学时把实例属性、挂载方式和响应式原理混在一起。

先看本节完整代码:$mount 手动挂载入口 ​

本节笔记里的核心代码保留了三种写法:按钮触发手动挂载、创建实例后立即挂载、以及链式调用 $mount。

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">
	{{num}}
</div>
</body>
<script>
	// $mount:可以实现手动挂载
	// const vm = new Vue({
	// 	data:{
	// 		num:100
	// 	}
	// })
	// function fn(){
	// 	vm.$mount("#app")
	// }
	
	
	// 1
	// const vm = new Vue({
	// 	data:{
	// 		num:100
	// 	}
	// })
	// vm.$mount("#app");
	
	// 2
	new Vue({
		data:{
			num:100
		}
	}).$mount("#app");
</script>
</html>

为什么需要 $mount:把“什么时候挂载”交给代码 ​

为什么需要:用 el: "#app" 时,new Vue 一创建就指定了挂载点,属于自动挂载;但实际开发中有时要先创建实例,等某个条件满足后再挂载。转写稿里说得很直白:$mount 的主要作用就是实现手动挂载,也就是“根据我的需要,我决定要不要挂载你”。

怎么想:先对比“有 el”和“没有 el”的表现。如果写了 el,Vue 会接管 #app,{{num}} 被渲染成 100;如果删掉 el,页面会原样显示 {{num}},说明实例还没有和模板关联。

怎么做:先创建实例但不写 el,再在合适的时机调用 vm.$mount("#app")。转写稿中的调试过程是:删掉 el 后看到双花括号里的 num,点击“挂载”按钮后页面显示 100,说明挂载成功。

易错点:$mount 接收的是选择器字符串,例如 "#app",不要写成 DOM 对象或赋值语句;没有调用 $mount 前,模板不会被 Vue 接管,看到 {{num}} 是正常现象。

怎么做一:按钮触发 vm.$mount("#app") ​

为什么需要:手动挂载最常见的场景就是“点一下再挂载”,用按钮把挂载时机交给用户或业务逻辑。

怎么想:按钮要写在 #app 外面,点击按钮执行一个函数,在函数里调用实例的 $mount。转写稿中特别提到,按钮是在 id 为 app 的外面写的。

怎么做:

js
const vm = new Vue({
	data: {
		num: 100
	}
})

function fn() {
	vm.$mount("#app")
}

调试时先看到页面上的 {{num}},点击“挂载”后变成 100。这说明 $mount("#app") 的效果和 el: "#app" 一致。

易错点:函数里要调用 vm.$mount("#app"),不是给 $mount 赋值;按钮不要写在 #app 内部,否则挂载接管后容易把按钮一起替换掉,调试时反而不直观。

怎么做二:创建后立即挂载与链式写法 ​

为什么需要:如果不需要按钮控制,也可以创建实例后立刻手动挂载;链式写法只是把两步合成一步,效果不变。

怎么想:new Vue(...) 的结果就是 Vue 实例,所以可以先保存为 vm,再执行 vm.$mount("#app");也可以直接在实例后面点 $mount。转写稿说:这种写法实现的效果和 el: "#app" 是一模一样的。

怎么做:

js
const vm = new Vue({
	data: {
		num: 100
	}
})
vm.$mount("#app");

或者:

js
new Vue({
	data: {
		num: 100
	}
}).$mount("#app");

易错点:链式写法不要漏掉 $mount 后面的括号和选择器;不要误以为链式写法改变了挂载原理,它仍然是手动挂载,只是写法更紧凑。

调试结论与易错点归纳 ​

调试过程可以概括为:先去掉 el,页面显示 {{num}};调用 vm.$mount("#app") 后显示 100;按钮触发、创建后立即挂载、链式挂载三种写法效果一致。由此可以确认:$mount 是实例上的手动挂载入口,用来指定挂载到哪个选择器。

易错点主要有三个:第一,$mount 是方法调用,不是配置项;第二,选择器要写字符串 "#app";第三,手动挂载前模板不会自动渲染。转写稿还提醒,后面学生命周期时会继续看到“手动挂载以后会怎么样、没有手动挂载又会怎么样”,本节先把 $mount 的用法和效果掌握清楚即可。

现象与需求:this.userName 为什么取不到 ​

为什么需要:Vue 实例上能直接访问 data 里的 a,也能访问 methods 里的 fn,但配置对象里额外写的 userName 并不会自动变成实例属性。初学时很容易以为“写在 new Vue 的配置里,就能用 this.xxx 取到”,结果一调试就是 undefined。

怎么想:先区分“Vue 认识的配置项”和“自定义配置项”。data、methods 等会被 Vue 按自己的规则处理,所以 this.a、this.fn 可用;而 userName 只是额外挂在配置对象上的自定义属性,Vue 不会把它代理到实例本身。转写稿里的调试过程就是先输出 this.a、this.fn,再输出 this.userName,发现前两个正常,最后一个取不到。

怎么做:先在 fn 中分别打印 this.a、this.fn、this.userName,确认现象;再把 this.userName 改成 this.$options.userName。转写稿中“us name/user name”对应代码里的 userName,“do options/door options”应纠正为 $options。

易错点:不要把自定义配置项当成 data 使用;this.userName 不是 "lisi",this.$options.userName 才是。若需要驱动视图的数据,仍应放进 data,本节只演示“读取配置项”。

用 $options 读取自定义配置项 ​

为什么需要:有时初始化时确实想把一些参数放在配置对象里,例如 userName: "lisi"。当实例内部需要读取这些初始化配置时,就需要一个入口,$options 就是实例上保存配置选项的对象。

怎么想:$options 可以理解为“当前实例的配置选项集合”。从本节演示看,配置对象里写的 userName 被保存进 $options,所以可以通过 this.$options.userName 取到。转写稿中还提到展开 $options 能看到 data、userName、methods、filters 等配置项,说明它是从配置对象这一层去访问,而不是从实例代理属性这一层去访问。

怎么做:保留下面这段代码,把 fn 里的输出改成 this.$options.userName,点击按钮即可在控制台看到 lisi。

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="fn">{{a}}</button>
</div>
</body>
<script>
	// 可以通过$options获取自定义的属性。
	new Vue({
		el:"#app",
		data:{
			a:1
		},
		userName:"lisi",
		methods:{
			fn(){
				// console.log(this.a)
				// console.log(this.fn)
				// console.log(this.userName)
				console.log(this.$options.userName);
			}
		}
	})
</script>
</html>

易错点:$options 不是 data 的替代品。常规数据访问仍用 this.a,方法调用仍用 this.fn;只有要读配置对象里的自定义项时,才用 this.$options.userName。另外,转写稿里“对. s”应理解为 data,“runner”应理解为 render 一类选项,不要被同音转写带偏。

调试与边界:$options 和 this.a、this.fn 的区别 ​

为什么需要:只看结论容易记混:什么时候用 this.a,什么时候用 this.$options.userName。通过控制台对比,才能把“实例代理属性”和“配置选项”区分开。

怎么想:点击按钮后,fn 里的 this 是当前 Vue 实例。this.a 走的是 data 代理,this.fn 走的是 methods 代理;this.userName 没有这层代理,所以是 undefined;this.$options.userName 走的是配置对象,所以是 "lisi"。转写稿中“输出一下这点 dollar options,看这个 op,里面有 data、userName、methods、filters”就是在做这个验证。

怎么做:调试时先打印 this.$options 整体,展开查看有哪些配置项;再单独打印 this.$options.userName。这样既能确认自定义项存在,也能确认访问路径。

易错点:不要因为 $options 能读到 userName,就把所有数据都塞到自定义选项里;本节只是“了解型”用法。面试和实际业务中更常见的是 data、methods、computed、filters 等标准配置。记住一句话:标准数据用 this.a,自定义配置用 this.$options.userName。

本节范围与口误纠正 ​

本节围绕 Vue2 的响应式基础展开:$data、$mount、$options、自定义指令的执行时机,以及 Object.defineProperty 的读写拦截。转写稿里的“do data”“door options”“底翻 property”等是口误,分别指 $data、$options、Object.defineProperty。本节笔记未附图片,下面保留并整理转写稿中的代码片段。

$data:实例上的数据状态 ​

为什么需要:调试或外部脚本想查看、修改组件数据时,需要知道数据挂在哪里。
怎么想:vm.$data 就是配置对象里 data 的那份数据,实例通过它暴露数据状态。
怎么做:

js
const vm = new Vue({
  el: '#app',
  data: { a: 1, b: 2, c: 3 }
})
console.log(vm.$data) // { a, b, c }

转写稿中用按钮在 #app 外部调用 vm.$data.a++,页面上的 a 会更新,说明外部不仅能读,还能改。
易错点:不要把 vm.$data 和 vm.data 混为一谈;实例上访问数据通常写 vm.a,而 $data 是数据对象本身。比较引用时,vm.$data 与传入的 data 对象应是同一份数据。

$mount:手动挂载 ​

为什么需要:有时不想在 new Vue 时立刻写 el,而是满足条件后再挂载。
怎么想:el 是指定挂载点;删掉 el 后模板不会自动编译,页面会看到 {{name}} 原样文本。
怎么做:

js
const vm = new Vue({
  data: { name: 100 }
})
// 此时页面可能显示 {{name}}
vm.$mount('#app')

也可以写成 new Vue({ data }).$mount('#app')。转写稿中按钮点击后执行 vm.$mount('#app'),页面才渲染出 name。
易错点:$mount 要指定目标选择器;未挂载前不要以为模板已经编译。后面学生命周期时,还要区分“是否手动挂载”对流程的影响。

$options:初始化选项的存放处 ​

为什么需要:想了解 data、methods、filters 以及自定义选项最终放在哪里。
怎么想:实例初始化时的选项会集中在 vm.$options;但自定义选项不会自动代理到 vm 上。
怎么做:

js
const vm = new Vue({
  el: '#app',
  data: { a: 1 },
  methods: { fn() { console.log(this) } },
  userName: 'Vue4'
})
console.log(vm.$options.userName) // Vue4
console.log(vm.userName)          // undefined

转写稿中 data 里的 a、methods 里的 fn 可按 Vue 规则访问;而随手加的 userName 只能从 $options 取。
易错点:不要以为写在配置对象里的每个属性都会变成实例属性;computed 放进去的是函数,实例上拿到的是计算结果,不是函数本身。

自定义指令:执行时机与 DOM 操作 ​

为什么需要:需要对真实 DOM 做底层操作时,自定义指令是一个入口。
怎么想:指令钩子执行时,this 不是 Vue 实例而是 window,所以不建议在指令里操作数据;它更适合操作 DOM。更关键的是要看清钩子执行时视图是否已经更新。
怎么做:

js
Vue.directive('demo', {
  bind(el) {
    console.log(this) // window
  },
  update(el) {
    console.log(el.innerText)
  }
})

模板可写成:

html
<input v-model="bookName">
<p>{{ bookName }}</p>
<div v-demo>{{ bookName }}</div>

转写稿中 bookName 初始为“天龙八部”,输入“一”后,update 里通过 el.innerText 读到的仍是旧内容,说明指令所在元素的视图尚未完成更新;不要把它当成“更新完成回调”。
易错点:不要在指令钩子里用 this 取实例数据;不要依赖指令钩子判断最终 DOM 已更新。转写稿也提醒:这类需求很多时候用过滤器更合适。

Object.defineProperty:响应式的拦截基础 ​

为什么需要:Vue2 的响应式、双向绑定都借助它拦截对象属性的读写。
怎么想:它像“拦截器”:读取属性走 get,设置属性走 set。
怎么做:

js
let obj = { userName: '张三' }
let value = obj.userName
Object.defineProperty(obj, 'userName', {
  get() {
    return value
  },
  set(v) {
    value = v
  }
})
obj.userName = '李四'
console.log(obj.userName) // 李四

转写稿还演示了在 get 里加书名号:读取时返回 《张三》,设置后再读就是 《李四》。
易错点:get 里不要直接返回 obj.userName,否则会递归;要用闭包变量保存原值。还要区分“伪双向绑定”:输入框改数据、下面视图跟着变,但控制台改数据时输入框不一定变,这只是单向演示,不是完整双向绑定。响应式强调的是数据变化驱动视图更新。

先厘清:伪双向绑定不是“真双向” ​

本节笔记的代码块为空,下面代码均按转写稿中的示例整理。转写稿反复强调一个区分:响应式是数据变化驱动视图变化;双向绑定是视图能写回数据、数据也能驱动视图;而“伪双向绑定”往往只靠事件把输入框的值写回变量,再手动更新页面,并没有真正拦截数据的读写。调试时在控制台改数据,页面不变,就说明它不是真双向。

html
<input id="input">
<div id="app"></div>
js
// 转写稿中的思路:输入时改数据并更新视图;控制台改数据时视图不跟着变

为什么需要:先分清概念,后面学 defineProperty 才知道 Vue 2 为什么能“像双向”。怎么想:看数据变时视图是否自动变,视图变时数据是否被写回。怎么做:用输入事件改变量,再手动写 DOM;再到控制台改变量观察视图。易错点:把“输入框能改页面”误认为双向绑定;忽略“控制台改数据页面不动”这一反证。

$data:实例里能拿到配置的 data ​

转写稿示例:new Vue({ data: { a, b, c } }) 后,vm.$data 能展开出 a、b、c。还用 vm.$data === data 验证引用地址一致,结果为 true,说明 $data 就是配置对象里的数据状态。随后在外部按钮里执行 vm.$data.a++,页面值会变。

为什么需要:外部需要访问和修改实例数据。怎么想:先证明 $data 与声明 data 是否同一份。怎么做:console.log(vm.$data === data);按钮中写 vm.$data.a++。易错点:以为 $data 是拷贝;修改时写错层级,如 vm.a 与 vm.$data.a 混淆。

$mount:手动挂载 ​

转写稿中先写 el: '#app' 是自动挂载;删掉 el 后页面显示双花括号 {{ name }},说明还没关联。再通过按钮调用 vm.$mount('#app'),页面才渲染出 name。也演示了 new Vue(...).$mount('#app') 的链式写法,效果一样。

为什么需要:有时要根据条件决定何时挂载。怎么想:没有 el 就还没有挂载,需要指定挂载点。怎么做:const vm = new Vue({...}); vm.$mount('#app')。易错点:忘记传选择器;以为删掉 el 数据就没了,其实只是未挂载;后面生命周期会再遇到 $mount。

$options:取配置项与自定义属性 ​

转写稿在配置里写 data、methods、filters,还随手加了 userName。实例上直接 this.userName 取不到,得到 undefined;但 this.$options.userName 能取到。methods 里的 fn、data 里的 a 会按各自逻辑进入实例,不是配置里写什么就原样挂什么;计算属性放的是函数执行结果。

为什么需要:想访问原始配置或自定义选项。怎么想:实例属性有固定入口,配置项走 $options。怎么做:console.log(this.$options.userName)。易错点:把自定义配置当实例属性直接用;以为 $options 里所有东西都会自动变成响应式数据。

自定义指令:钩子执行时机要看调试 ​

转写稿用 v-book 观察:指令里 this 是 window,所以不建议在指令里操作数据;它更适合操作 DOM。初次执行时,el.innerText 能拿到“天龙八部”,但用 document.querySelector 查页面时,视图还没渲染完,拿到的是双花括号。数据更新后指令再次执行,但此时视图还未更新,document 拿到的仍是旧内容;而 el.innerText 是当前元素内容,若写成 el.innerText = '《' + el.innerText + '》',会基于当前内容不断追加书名号。

为什么需要:需要在元素层面做 DOM 增强。怎么想:区分“挂载完成前”和“数据更新后、视图更新前”。怎么做:在指令钩子里打印 this、el.innerText、document.querySelector(...).innerText 对比。易错点:在指令里用 this 取 Vue 数据;把 document 查询结果当成已更新视图;反复拼接导致内容累积。

defineProperty:伪双向绑定背后的拦截器 ​

转写稿把 Object.defineProperty 称为拦截器:读取属性走 get,设置属性走 set;不读取就不触发 get。示例用 _userName 保存原值,get 返回带书名号的值,set 把新值处理后存回 _userName,于是再读 obj.userName 得到“《李四》”。

js
let obj = { userName: '张三' };
let _userName = obj.userName;
Object.defineProperty(obj, 'userName', {
  get() { return '《' + _userName + '》'; },
  set(newValue) { _userName = '《' + newValue + '》'; }
});
obj.userName = '李四';
console.log(obj.userName); // 《李四》

为什么需要:让普通属性读写可被监听,这是 Vue 2 响应式的基础。怎么想:在 set 里知道数据变了,再通知视图;在 get 里收集依赖。怎么做:用 defineProperty 定义 get/set,在 set 中更新内部值并触发更新逻辑。易错点:把 defineProperty 当成“自动双向绑定”,其实还要配合事件和视图更新;设置属性不会触发 get;用 _userName 保存原值避免 get/set 递归。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例 1:$watch —— 侦听数据变化(含深度侦听) ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>$watch</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<button @click="num++">{{num}}</button>
	<input type="text" v-model="obj.userName">
</div>

<p></p>
</body>
<script>
	const p = document.querySelector("p");
	const vm = new Vue({
		el: "#app",
		data: {
			num: 1,
			obj: {
				userName: "laowangtou"
			}
		}
	});

	// 1. 侦听基本类型
	vm.$watch("num", function (newValue, oldValue) {
		p.innerText = newValue;
		console.log("num", newValue, oldValue);
	});

	// 2. 侦听对象:必须加 deep
	vm.$watch("obj", function (newValue, oldValue) {
		console.log("obj", newValue.userName);
	}, {
		deep: true
	});
</script>
</html>

关键行与易错点

  • vm.$watch("num", cb) 的第一个参数是字符串形式的表达式,回调拿到 (newValue, oldValue),这是实例方法,不在 new Vue({...}) 的配置项里。
  • 侦听对象内部属性变化时,$watch("obj", cb) 默认不触发,必须传第三个参数 { deep: true } 才能侦听到 obj.userName 的改动。
  • 深度侦听时 newValue 和 oldValue 指向同一个对象引用,所以打印 newValue.userName 才有意义,不要去比较 newValue === oldValue。
  • $watch 返回一个取消侦听函数,需要停止侦听时调用它即可。

示例 2:实例属性 $el / $data / $mount / $options ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>实例属性</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<!-- $el -->
<div id="app">
	{{num}}
	<button @click="my">app->{{num}}</button>
</div>
<button onclick="fnEl()">点我 $el</button>
<hr>

<!-- $data -->
<div id="app2">
	<p>a:{{a}}</p>
	<p>b:{{b}}</p>
	<p>c:{{c}}</p>
</div>
<button onclick="fnData()">调用我 $data</button>
<hr>

<!-- $mount -->
<div id="app3">
	{{num2}}
</div>
<button onclick="fnMount()">挂载 $mount</button>
<hr>

<!-- $options -->
<div id="app4">
	<button @click="fnOpt">{{a2}}</button>
</div>

<script>
	// ===== $el:vm 所挂载的元素 =====
	const vm = new Vue({
		el: "#app",
		data: {
			num: 100
		},
		methods: {
			my() {
				this.$el.style.color = "pink";
			}
		}
	});

	function fnEl() {
		console.log(vm.$el === document.querySelector("#app")); // true
		vm.$el.style.color = "red";
	}

	// ===== $data:配置的数据状态 =====
	let data = {
		a: 1,
		b: 2,
		c: 3
	};
	const vm2 = new Vue({
		el: "#app2",
		data
	});
	console.log(vm2.$data === data); // true

	function fnData() {
		vm2.$data.a++;
	}

	// ===== $mount:手动挂载 =====
	const vm3 = new Vue({
		data: {
			num2: 100
		}
	});

	function fnMount() {
		vm3.$mount("#app3");
	}

	// ===== $options:获取自定义属性 =====
	new Vue({
		el: "#app4",
		data: {
			a2: 1
		},
		userName: "lisi",           // 自定义选项
		methods: {
			fnOpt() {
				console.log(this.$options.userName); // lisi
			}
		}
	});
</script>
</body>
</html>

关键行与易错点

  • $el 就是实例挂载的根元素,vm.$el === document.querySelector("#app") 为 true;在 methods 里 this.$el 直接指向它,this === vm。
  • $data 就是你传进 data 的同一个对象(vm2.$data === data 为 true),所以从外部改 data.a 或 vm2.$data.a 都会驱动视图更新。
  • $mount("#app") 是手动挂载,等价于在配置项里写 el: "#app";不写 el 也不调 $mount 时,页面上原样显示 {{num2}} 这样的双花括号。
  • 同一个实例重复 $mount 会报警告,实际项目里要加条件判断;new Vue({...}).$mount("#app") 链式写法效果一致。
  • $options 用于取自定义选项:userName 写在配置对象顶层,并不会变成 this.userName(是 undefined),只能通过 this.$options.userName 拿到。

示例 3:Object.defineProperty 的 get / set 拦截 ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>defineProperty 拦截器</title>
</head>
<body>
<button onclick="read()">读取 obj.userName</button>
<button onclick="write()">设置 obj.userName = 李四</button>
<script>
	let obj = {
		userName: "张三"
	};

	// 先把原值保存一份,避免 get / set 里自己读自己造成死循环
	let _userName = obj.userName;

	Object.defineProperty(obj, "userName", {
		// 读取数据时触发
		get() {
			console.log("get 被调用(读取数据时)");
			return "《" + _userName + "》";
		},
		// 设置数据时触发
		set(v) {
			console.log("set 被调用,参数为:", v);
			_userName = v;
		}
	});

	function read() {
		console.log(obj.userName);
	}

	function write() {
		obj.userName = "李四";
	}
</script>
</body>
</html>

关键行与易错点

  • Object.defineProperty(obj, "属性名", 配置对象) 第一个参数是被拦截的对象,第二个是要拦截的属性,第三个是拦截配置(最常用 get 和 set)。
  • get 只在读取时执行,set 只在赋值时执行;赋值不会触发 get,读取也不会触发 set。
  • get 必须 return 值,否则读到的就是 undefined——这是把原来的 userName 拦下来后忘记返回的典型坑。
  • set(v) 的参数就是新赋的值,所以要落到一个中间变量(这里是 _userName)上;如果写成 obj.userName = v 会再次触发 set,直接死循环。
  • 这就是 Vue 2 响应式的基础:数据被 get 收集依赖、被 set 通知更新,因此修改数据能驱动视图。

常见错误 ​

1. 监听对象内部属性变化时,回调不触发

  • 现象:vm.$watch("obj", fn) 后修改 obj.userName,控制台毫无反应。
  • 原因:$watch 默认只监听引用变化,obj.userName 改动不改变 obj 的引用地址。
  • 修法:第三个参数传 { deep: true };若只关心某个字段,直接监听路径字符串 "obj.userName",比深度监听更省性能。

2. $watch 里改数据,改出了死循环

  • 现象:页面卡死或控制台刷屏,浏览器提示“Maximum call stack”。
  • 原因:在 num 的 watch 回调里又去写 this.num,触发自身再次执行。
  • 修法:回调里只做 DOM 副作用/发请求/打日志,不回写被监听的那个值;确需联动就改用 computed 或换一个字段承接。

3. 自定义指令里操作 this 数据,报 undefined

  • 现象:指令钩子里 this.xxx 拿不到,this 打印出来是 window。
  • 原因:指令钩子不是 Vue 实例方法,没有绑定实例上下文。
  • 修法:指令内只碰 el、binding 两个参数做 DOM 操作;要读写数据请在组件方法/computed 里做,通过 binding.value 把值传进去。

4. 指令里用 document.querySelector 拿兄弟节点,拿到旧值

  • 现象:binding 更新钩子里 document.querySelector("p").innerText 还是上一次的文本,但 el.innerText 已经是最新的。
  • 原因:update 钩子执行时,当前指令所在元素已按新数据渲染,但同级/其他节点的视图尚未完成更新。
  • 修法:只信任 el 及其子树,不要在钩子里跨节点查询 DOM;要跨节点取值请用 nextTick。

5. $el 在 created 阶段就使用,报 null

  • 现象:this.$el.style.color 抛 Cannot read property 'style' of undefined。
  • 原因:created 时尚未挂载,$el 还不存在;且使用 $mount 手动挂载时更晚。
  • 修法:DOM 相关逻辑放到 mounted,或在 this.$nextTick() 里执行;$mount 之后的代码要在挂载调用之后写。

6. 以为 $data 是拷贝,改它不影响原对象

  • 现象:vm.$data === data 为 true,外部改 data.a 页面跟着变,误以为是“数据被污染”。
  • 原因:$data 就是传入的同一个对象引用,Vue 只在其上做了响应式改造,没有克隆。
  • 修法:需要隔离时传入深拷贝(如 JSON.parse(JSON.stringify(data)) 或工具函数),不要指望框架帮你复制。

7. 自定义属性挂在选项上,却用 this.userName 读

  • 现象:this.userName 是 undefined,但配置对象里明明写了 userName: "lisi"。
  • 原因:只有 data、methods、computed 等约定选项会被代理到实例上,随意添加的字段不会。
  • 修法:走 this.$options.userName 读取;能放进 data 的就别放自定义选项。

练习 ​

练习 1(watch 精度) 给出 data: { user: { name: "a", age: 1 } },要求:改 name 时打印新值,改 age 时不触发。分别用“路径监听”和“deep + 手动比较”两种写法实现,并说明哪种更省。

练习 2($mount 时机) 用 new Vue({ data: { num: 100 } }) 创建实例,点击按钮才 $mount("#app")。要求:挂载前页面上能看到 {{num}} 原文,挂载后正常显示;并回答挂载前 vm.$el 是什么。

练习 3($options 校验) 在 Vue 配置里自定义 version: "1.0"、author: "lisi" 两个选项,写一个方法在点击时输出 "1.0 by lisi",要求不能把这两个字段塞进 data。

练习 4(指令钩子时序) 写一个全局指令 v-log,在 bind、inserted、update 三个钩子里分别打印 el.innerText 和一个用 document.querySelector 取到的兄弟节点文本。运行后解释:为什么两个值不一致?哪个钩子拿到的兄弟节点文本才是最新的?

练习 5(defineProperty 拦截) 用 Object.defineProperty 给对象属性加 get/set 拦截,实现:读出来的值自动加前缀 "Hi, ",写入时若值长度小于 2 则拒绝写入并保留旧值。要求不修改原属性名。

原视频对照 ​

#视频时长
1$data03:02
2手动挂载$mount03:18
3$options03:58
4关于指令09:10
5defineProperty12:05

小结 ​

  1. $watch 是命令式监听,适合“变化后做副作用”(请求、DOM、日志);派生状态优先用 computed,别用 watch 互相回写。
  2. 监听对象属性有两条路:路径字符串 "obj.key" 精确且便宜,deep: true 覆盖面广但会遍历整棵子树,能用前者就别用后者。
  3. $el、$data、$options 都是“读”的入口,不是业务数据的存放处;$data 与传入对象同一引用,改它等于改原对象。
  4. $el 的存在依赖挂载完成,任何 DOM 操作都要放在 mounted 或 $mount 调用之后,否则拿到 null。
  5. $mount 把“何时挂载”从配置期推迟到运行期,是手动挂载与后续生命周期分析的入口,理解它才能看懂 beforeMount → mounted 的边界。
  6. 只有 data / methods / computed 等约定选项会代理到实例,自定义选项必须走 $options。
  7. 自定义指令的钩子里 this 是 window:只能操作 DOM,不能操作数据;bind/inserted 阶段视图尚未渲染完,update 阶段当前元素已新、其他节点可能仍旧。
  8. Object.defineProperty 的 get/set 是响应式的底层机制——拦截读写才有“数据变→视图变”;而“视图变→数据变”还得靠事件回写,两者合起来才叫双向绑定,缺一即为单向。