主题
$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);
})拆成三块理解:
- 第一个参数是要监听的表达式,写字符串
"num",也可以写"obj.userName"这种路径。 - 第二个参数是回调,Vue 会把新值和旧值按顺序传进来,所以回调里不需要自己去
vm.num再取一遍。 - 第三个参数是配置对象,笔记里第二个
$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 这个外层对象并没有被重新赋值,不深度遍历内部属性,就收不到通知。
易错点
- 以为会立即执行一次。
$watch默认只在变化后触发,页面刚打开时<p>是空的。如果想要初始就执行一次,需要额外处理(这是笔记里没涉及的部分,先记住"默认不立即执行")。 - 监听对象忘记
deep。 监听"obj"却改的是obj.userName,不加deep: true回调一次都不会进——这是最常见的坑。 - 路径写错。
$watch("obj.userName")和$watch("obj")是两回事,前者只盯一个属性,后者盯整个对象。 - 在回调里依赖外部
this。 转写稿在讲自定义指令时专门验证过"里面的this是 window",说明 Vue 某些回调里的this不能想当然。$watch回调里最稳妥的写法是直接用参数newValue、oldValue,或者用外面已经存好的vm变量,不要凭感觉写this.num。 - 和
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 混为一谈。
易错点汇总
$el是挂载后的根元素,不是选择器字符串。methods里this.$el可用,是因为this是vm;外部函数要用vm.$el。$el是根元素,不是组件内部所有元素;要操作子元素还需querySelector。- 改
$el.style是直接操作 DOM,不会自动更新data。 - 转写稿视频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:$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 | $data | 03:02 |
| 2 | 手动挂载$mount | 03:18 |
| 3 | $options | 03:58 |
| 4 | 关于指令 | 09:10 |
| 5 | defineProperty | 12:05 |
小结
$watch是命令式监听,适合“变化后做副作用”(请求、DOM、日志);派生状态优先用computed,别用 watch 互相回写。- 监听对象属性有两条路:路径字符串
"obj.key"精确且便宜,deep: true覆盖面广但会遍历整棵子树,能用前者就别用后者。 $el、$data、$options都是“读”的入口,不是业务数据的存放处;$data与传入对象同一引用,改它等于改原对象。$el的存在依赖挂载完成,任何 DOM 操作都要放在mounted或$mount调用之后,否则拿到null。$mount把“何时挂载”从配置期推迟到运行期,是手动挂载与后续生命周期分析的入口,理解它才能看懂beforeMount → mounted的边界。- 只有
data/methods/computed等约定选项会代理到实例,自定义选项必须走$options。 - 自定义指令的钩子里
this是window:只能操作 DOM,不能操作数据;bind/inserted阶段视图尚未渲染完,update阶段当前元素已新、其他节点可能仍旧。 Object.defineProperty的get/set是响应式的底层机制——拦截读写才有“数据变→视图变”;而“视图变→数据变”还得靠事件回写,两者合起来才叫双向绑定,缺一即为单向。