主题
$nextTick与Vue响应式的原理(Lesson7 1/3)
本页对应 Lesson07,共 6 段视频,约 1 小时 48 分。
学习目标
- 能够使用
Object.defineProperty为对象属性定义getter/setter,并准确说明读取、设置属性时拦截函数的执行时机。 - 能够基于
Object.defineProperty实现简易双向绑定:视图输入可更新数据,数据变化可驱动视图更新。 - 能够解释 Vue 实例中
data属性被代理到实例上的机制,并使用遍历方式批量完成属性代理,避免逐个硬编码。 - 能够将属性代理、初始化等逻辑抽取为原型方法,说明拆分函数对可读性、复用性和后续扩展的作用。
- 能够按照“编译模板—生成订阅者—数据变化通知—更新视图”的流程完成初次渲染,并解析
z-model、z-text等指令实现 DOM 更新。
知识点
先校正本节材料边界
转写稿开头的“接着甜婷说”“前婷婷天”应理解为 nextTick;“opject deef property”应校正为 Object.defineProperty;“维本框”是“文本框”;“奥D曼”是 undefined;“文懂/温懂/吴总”多处在说 window。需要说明:本节转写稿主要展开的是 Object.defineProperty 的 get/set、代理、初次渲染和简易双向绑定,没有继续给出 $nextTick 的 API 用法和源码实现。因此下面不编造 $nextTick 的具体调用示例,而是把“为什么必须先理解响应式更新”讲清楚,并整理转写稿中可确认的推导与调试过程。本节笔记未附图片。
为什么需要先理解响应式
转写稿一开始回顾:之前只实现了“伪双向绑定”——在文本框输入内容,下面的 div 会跟着变,也就是“视图变,数据变”;但“数据变,视图变”这一半没有完成。$nextTick 讨论的前提,正是数据变更后视图如何更新、以及什么时候才能拿到更新后的 DOM。如果连 get/set 在什么时候执行都不清楚,就很容易把“数据已经改了”误认为“DOM 已经更新了”。
怎么想:get/set 是拦截点
Object.defineProperty(obj, key, descriptor) 的第一个参数是目标对象,第二个是属性名,第三个是描述符。转写稿重点讲读写拦截:读取属性时执行 get,设置属性时执行 set。为什么需要它?因为只有拦截到“读”和“写”,才能在数据变化时做记录、通知或更新视图。怎么做?用中间变量保存真实值,避免在 set 里再次给同一属性赋值造成递归。易错点是:set 只要发生赋值就执行,不比较新旧值是否相同。
js
const info = { bookName: '天龙八部' }
let _bookName = info.bookName
const logs = [{ id: 1, bookName: _bookName, time: Date.now() }]
Object.defineProperty(info, 'bookName', {
get() {
return _bookName
},
set(v) {
_bookName = v
logs.push({ id: logs.length + 1, bookName: v, time: Date.now() })
}
})转写稿的调试过程是:把 info.bookName 改成“一”,set 执行,日志数组增加一条;再设置一次相同值,set 仍然执行。所以“属性发生改变会执行”这句话不够严谨,准确说是“设置该属性时执行”。记录日志时不要只存字符串,最好存对象,因为对象能同时描述“改成什么”和“什么时候改”。
怎么做:用代理把 data 暴露到实例
转写稿用构造函数模拟 Vue 实例:this.$data = config.data,然后遍历 data 的 key,把每个 key 代理到实例上。为什么需要?因为使用者希望直接写 vm.a,而不是每次写 vm.$data.a。怎么想?实例上的 a 不存真实值,只做“中介”:读 vm.a 时返回 this.$data.a,写 vm.a 时写回 this.$data.a。易错点:get 里不能写 return this[key],否则会递归;动态 key 必须用 this.$data[key],不能用点语法。
js
function Chang(config) {
this.$data = config.data
for (let key in this.$data) {
Object.defineProperty(this, key, {
get() {
return this.$data[key]
},
set(v) {
this.$data[key] = v
}
})
}
}转写稿还调试过“只复制值”的错误写法:this.a = this.$data.a 只是复制,之后改 vm.a 不会影响 $data.a,两者断开了。代理的意义就是保持读写都落到同一份数据上。
初次渲染:编译器解析模板
转写稿把响应式流程拆成编译器、发布者/监听器、订阅者:编译器解析模板,发现 z-model、z-text 等指令后,从 data 取对应值写入 DOM;数据变化时由监听器通知订阅者更新视图。调试细节是:this.$el.children 不是真数组,直接 forEach 不可用,可用 for 循环或 Array.from 转换。易错点是动态属性名要用 getAttribute('z-model'),不能写成固定属性读取。
$nextTick 的边界说明
按转写稿可确认:数据变到视图变需要经过拦截、通知、更新这条链;但本节没有展开“更新是同步还是异步”“更新队列如何清空”“$nextTick 回调何时执行”。因此本节只能得出一个前置结论:不要在刚改完数据时立刻假设 DOM 已经是最新状态。$nextTick 的具体实现与调用示例,应等后续材料再补,避免编造。
从“伪双向绑定”到响应式
为什么需要:前面做的只是“视图变、数据变”,但“数据变、视图也变”没有完成,所以还不是响应式。
怎么想:要监听数据何时被读取、何时被写入,再在写入时更新视图或记录日志。
怎么做:用 Object.defineProperty 拦截属性的 get/set。
易错点:set 里直接写 info.bookName = v 会递归;应使用中间值 _bookName。
js
const info = { bookName: '天龙八部' }
const logArr = [{ id: 1, bookName: info.bookName, time: Date.now() }]
let _bookName = info.bookName
Object.defineProperty(info, 'bookName', {
get() { return _bookName },
set(v) {
_bookName = v
logArr.push({ id: logArr.length + 1, bookName: v, time: Date.now() })
}
})调试时先刷新页面没有执行,点击“更改”后才进入 set;再次把值改成相同内容,set 仍会执行。这说明不是“值变化才执行”,而是“设置属性时执行”。get 则在读取 info.bookName 时执行。
简易版双向绑定
为什么需要:只监听输入框事件,能让 div 跟着变,但数据变时视图不会自动变。
怎么想:把 data.str 作为被拦截属性,在 set 中同时更新输入框和 div。
怎么做:输入事件里写数据,set 里写视图。
易错点:change 要失焦才触发,输入过程应用 input 事件;只写 data.str = value 还不够,必须在 set 中更新 DOM。
html
<input id="ipt"><div id="box"></div>js
const data = { str: '张三' }
const ipt = document.querySelector('#ipt')
const box = document.querySelector('#box')
let _str = data.str
Object.defineProperty(data, 'str', {
get() { return _str },
set(v) {
_str = v
ipt.value = v
box.innerText = v
}
})
ipt.value = data.str
box.innerText = data.str
ipt.addEventListener('input', e => { data.str = e.target.value })data 代理:vm.a 为什么能拿到 $data.a
为什么需要:new Vue({ data }) 后能直接写 vm.a,而不是每次写 vm.$data.a。
怎么想:把 $data 的属性“代理”到实例上。
怎么做:遍历 $data,用 defineProperty 在实例上定义同名属性,get/set 转去读写 $data。
易错点:this[key] = this.$data[key] 只是复制值,不是代理;改 vm.a 不会改 $data.a。
js
function Zhang(options) {
this.$data = options.data
for (let key in this.$data) {
Object.defineProperty(this, key, {
get() { return this.$data[key] },
set(v) { this.$data[key] = v }
})
}
}调试时先输出 vm.$data,再改 vm.a,观察 $data.a 是否同步;若只是赋值复制,两者会脱钩。
编译器、发布者、订阅者的流程
为什么需要:defineProperty 只能监听数据,却不知道模板哪里用了数据。
怎么想:编译器解析模板,发现 z-mod、z-text 等指令;订阅者负责更新视图;发布者监听数据,数据变时通知订阅者。
怎么做:先完成初次渲染,再逐步补依赖收集与通知。
易错点:children 是类数组,for...in 会遍历 length 等;应用 for 循环或转数组。判断指令用 hasAttribute,取值用 getAttribute。
js
Zhang.prototype.compile = function () {
const nodes = this.$el.children
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.hasAttribute('z-mod')) {
const key = node.getAttribute('z-mod')
node.value = this.$data[key]
node.addEventListener('input', e => { this.$data[key] = e.target.value })
}
if (node.hasAttribute('z-text')) {
const key = node.getAttribute('z-text')
node.innerText = this.$data[key]
}
if (node.hasAttribute('z-html')) {
const key = node.getAttribute('z-html')
node.innerHTML = this.$data[key]
}
}
}初次渲染只负责把数据放进视图;后续“数据变、视图变”还要靠 set 中的通知逻辑。这样从 defineProperty 到简易双向绑定,再到代理和模板解析,Vue 响应式的主线就连起来了。
先看本节代码:输入框一变,下面的 div 跟着变
本节笔记保留的原始示例如下。它的效果是:页面打开时,输入框和下面的 div 都显示同一句话;当你在输入框里输入内容时,下面的 div 会同步变化。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">
<input type="text">
<div></div>
</div>
</body>
<script>
const inp = document.querySelector("#root input");
const div = document.querySelector("#root div");
let str = "我是一只小小小小鸟,怎么飞也飞不高!";
inp.value = div.innerHTML = str;
inp.oninput = (e)=>{
div.innerHTML = str = e.target.value;
}
</script>
</html>这段代码里有三个关键动作。第一,先拿到输入框和 div:querySelector("#root input")、querySelector("#root div")。第二,准备一个变量 str 作为“数据”。第三,用连续赋值 inp.value = div.innerHTML = str 做初次渲染:先把 str 赋给 div.innerHTML,再把同一个值赋给 inp.value。事件部分监听 oninput,用户每输入一次,就把 e.target.value 同时写回 div.innerHTML 和 str。所以从现象上看,输入框和下面的内容联动了。
为什么叫“伪双向绑定”
这里要区分两个方向:视图到数据、数据到视图。当前代码做到的是“视图变,数据也变”:输入框内容变化后,oninput 里执行 div.innerHTML = str = e.target.value,str 被更新了,div 也被更新了。但它没有做到“数据变,视图自动变”:如果后面在代码里直接写 str = "新内容",div 和输入框并不会自动跟着变,因为没有任何机制在监听 str 的写入。
转写稿里反复强调:这只实现了“伪双向绑定”,响应式这一部分没有完成。也就是说,它只是手动把一次输入事件里的两个动作写在一起,并不是数据驱动视图。这个判断很重要,因为后面引入 Object.defineProperty,目的就是补上“数据在什么时候被读取、什么时候被写入”的监听能力。
怎么想:先找到“数据变化”的拦截点
如果想让数据变化驱动视图,首先要回答一个问题:程序怎么知道数据变了?普通变量 let str 被重新赋值时,我们没有办法在赋值的一瞬间插入自己的逻辑。于是思路转向对象属性:把数据放到对象属性上,用 Object.defineProperty 拦截这个属性的读取和写入。转写稿里说得很明确:get 在读取属性时执行,set 在设置属性时执行。只要在 set 里拿到新值,就可以顺手更新视图;在 get 里返回当前值,保证读取行为正常。
这里还要注意一个调试细节:转写稿中验证过,set 只要发生设置就会执行,不管新值和旧值是否一样。因此不能想当然地认为“值没变就不会进 set”。另外,如果在 set 里直接给同一个属性赋值,容易造成递归;常见做法是引入一个中间值保存真实数据,get 返回中间值,set 更新中间值并更新视图。这个推导正是从“伪双向绑定”走向“响应式”的关键一步。
怎么做:在现有结构上补“数据到视图”
以本节代码为基础,可以保留输入框、div、初始渲染和 oninput,然后把 str 从普通变量改造成被拦截的对象属性。视图到数据的部分仍然由 oninput 负责;数据到视图的部分交给 set。这样两个方向才形成闭环。转写稿中还提到简易版双向绑定的做法:初始时把数据写到 input.value 和 div.innerHTML,输入时更新数据和 div,数据被设置时再反写 input.value 与 div.innerHTML。这比本节代码多出来的,正是对数据写入的拦截。
易错点
第一,事件用 oninput 而不是只依赖失焦;转写稿里对比过,输入过程中要能实时触发。第二,innerHTML 写入的是 HTML 内容,当前示例是纯文本,所以看不出差异;如果内容来自用户输入,要考虑转义问题。第三,连续赋值 inp.value = div.innerHTML = str 的执行顺序是从右往左,先赋给 div.innerHTML,再赋给 inp.value,不要写反。第四,初次渲染不能漏,否则页面打开时输入框和 div 没有初始值。第五,使用 Object.defineProperty 时,get/set 的触发时机要分清:读属性走 get,写属性走 set;set 不比较新旧值。第六,不要在 set 里直接对同一属性递归赋值,否则容易栈溢出。把这些点处理好,本节这个“伪双向绑定”才能作为后续响应式原理的起点。
本节要解决的问题
普通对象赋值、读值就是直接操作属性,中间没有“插话”的机会。但很多需求恰恰需要在读写时插话:读取时格式化内容,写入时记录日志,数据变化时更新视图,或者让 vm.a 代理到 data.a。Object.defineProperty 就是把这些属性变成“可拦截”的入口。
怎么想:不再让属性直接暴露,而是用 get 接管读取,用 set 接管写入。怎么做:Object.defineProperty(对象, "属性名", { get(){...}, set(v){...} })。易错点:get 里不能直接 return obj.userName,set 里不能直接 obj.userName = v,否则会再次触发拦截,形成递归。
最小读写拦截:先看笔记代码
笔记里的代码先保存原值 _userName,再拦截 obj.userName:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
<script>
// 拦截器(拦截方法):是Object对象下的一个方法
let obj = {
userName:"zhangsan"
}
let _userName = obj.userName;
// obj.userName = "lisi";
// console.log(obj.userName);// lisi
Object.defineProperty(obj,"userName",{
// 读
get(){
console.log("get")
return "《"+_userName+"》";
},
// 写
set(v){
// console.log("set",v)
_userName = v;
}
})
console.log(obj.userName);// 读取数据
obj.userName = "lisi";
console.log(obj.userName);// 《lisi》
</script>
</html>推导过程:第一次 console.log(obj.userName) 会执行 get,返回 《zhangsan》;执行 obj.userName = "lisi" 时执行 set,把 lisi 存进中间变量 _userName;再读时 get 返回 《lisi》。转写稿里特别调试到一个点:set 只要发生赋值就会执行,不管新值和旧值是否一样,所以后面做日志时要注意是否去重。
易错点:中间变量 _userName 必须存在;set 里写 _userName = v,不能写 obj.userName = v;get 返回 _userName,不能返回 obj.userName。
从格式化到操作日志
为什么需要:用户可能多次修改同一个数据,需要记录“改成了什么、什么时候改的”。怎么想:不要在按钮点击处手写记录,而是把记录逻辑放进 set,这样无论从哪里修改属性都会留下日志。怎么做:准备 logArr,在 set(v) 中 push 一个对象,包含 id、bookName、time;读取时返回当前值或最后一条记录。转写稿中的调试过程是:输入内容为空时提示“请输入内容”;有内容时执行 info.bookName = 输入值;点击“更改”后查看 logArr,能看到初始记录和新增记录。易错点:日志数组里不要只存一个值,最好存对象;时间用 Date 生成;记录逻辑放在 set 中比散落在各个按钮里更稳定。
简易双向绑定
为什么需要:之前的“伪双向绑定”只做到了视图变、数据变,数据变时视图不一定变。怎么想:视图变时用 oninput 更新数据;数据变时在 set 中同时更新 input.value 和 div.innerText。怎么做:给 data.str 定义 get/set,get 返回中间变量 _str,set 中先更新 _str,再更新两个 DOM。调试顺序:先设置初始值,让输入框和 div 都有内容;输入时触发 oninput,div 跟着变;再执行 data.str = "新值",输入框和 div 也应同步。易错点:set 中更新 DOM 时不要再触发输入事件;get 必须返回中间变量,不能返回被拦截属性本身。
代理与批量拦截
为什么需要:Vue 实例里能直接写 vm.a,同时又能通过 vm.$data.a 访问原数据,改 vm.a 时 $data.a 也要变。怎么想:把 $data 当作真实数据仓库,把实例上的属性“代理”到 $data。怎么做:用 for...in 遍历 $data,对每个 key 执行 Object.defineProperty(this, key, { get(){ return this.$data[key] }, set(v){ this.$data[key] = v } })。易错点:直接写 this[key] = this.$data[key] 只是复制值,改 vm.a 不会改 $data.a;循环里要用 this.$data[k],不能写 this.$data.k;set 中写 this.$data[k] = v,不要写 this[k] = v,否则递归。转写稿后段还把初始化、代理逻辑抽到原型方法中,并用 compiler 解析模板、watcher 订阅、监听器通知,完成初次渲染;本节先把 get/set 的执行时机和代理关系吃透。
小结
记住两个时机:读取属性执行 get,设置属性执行 set。调试时先看 get/set 是否触发,再看中间变量是否更新,最后看 DOM 或日志是否同步。Object.defineProperty 的价值不只是“改一个值”,而是把读写变成可控入口。
从“改数据”到“留痕迹”:为什么需要 defineProperty
这一节做的不是完整双向绑定,而是一个小应用:对象 info 里有一个 bookName,用户通过输入框和按钮修改它,同时要把每次修改记录下来。普通赋值 info.bookName = bookName 只能得到“最新值”,拿不到“历史操作”。如果每次都在按钮点击里手动写日志,那么以后页面里多几个入口,每个入口都要重复写一遍,代码会散、容易漏。
所以这里要解决的是:当 info.bookName 被读取或被设置时,自动执行一段逻辑。Object.defineProperty 的 get 和 set 正好承担这个职责:读取 info.bookName 时走 get,设置 info.bookName = xxx 时走 set。
先把需求想清楚:日志数组与最新值
转写稿里明确提到,日志不能只存一个“改后的值”,还要存“什么时间改的”,所以每条日志用对象描述多个属性:id、bookName、addTime。日志用数组 logArr 保存,因为每改一次就要增加一条。
初始时手动放一条记录,表示 bookName 的初始值“天龙八部”。这样 get 读取时可以直接取 logArr 的最后一条,而不是再去读 info.bookName。这一点很关键:如果在 get 里写 return info.bookName,会再次触发 get,形成递归;在 set 里直接再写 info.bookName = bookName 也会再次触发 set。代码里注释掉的 _bookName 是一种中间值方案,最终采用的是“从日志数组最后一条取最新值”的方案。
怎么做:用 get/set 拦截 bookName
保留本节笔记代码:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<input type="text"><button>更改</button>
</body>
<script>
const inp = document.querySelector("input");
const btn = document.querySelector("button");
let info = {
bookName:"天龙八部"
}
// 存储日志
let logArr = [{
id:1,
bookName:info.bookName,
addTime:Date.now()
}];
// let _bookName = info.bookName;
Object.defineProperty(info,"bookName",{
get(){
// return _bookName;
return logArr[logArr.length-1].bookName;
},
// 当对象info下的bookName属性时执行。
set(bookName){
// _bookName = bookName;
logArr.push({
id:logArr.length+1,
bookName,
addTime:Date.now()
})
}
})
btn.onclick = function(){
const bookName = inp.value.trim();
if(bookName){
info.bookName = bookName;
logArr.unshift()
}else alert("请输入内容!");
}
</script>
</html>get 负责“读”:谁读取 info.bookName,就返回 logArr 最后一条的 bookName。set 负责“写”:谁设置 info.bookName,就把新值、编号、时间 push 进 logArr。按钮点击时先 trim() 去掉首尾空格,非空才赋值;赋值的瞬间触发 set,日志自动增加。
调试过程:确认 get/set 何时执行
转写稿里反复确认了两个时机:读取 info.bookName 时执行 get;设置 info.bookName 时执行 set。可以在 set 里临时 console.log,点击“更改”后看是否执行;也可以在读取 info.bookName 时看 get 是否返回最新日志。还要注意:set 不是“值变了才执行”,而是“只要发生设置就执行”,即使新值和旧值一样,也会走 set。这解释了为什么重复点击也可能新增记录。
易错点
第一,get 里不要直接返回 info.bookName,set 里不要直接再赋值 info.bookName,否则会重复触发访问器。第二,日志的“最新值”从 logArr[logArr.length-1] 取,set 里先 push,get 才能读到刚写入的记录。第三,id: logArr.length+1 在初始一条后第一次设置会得到 2;本节没有删除操作,所以这样写能跑,但删除后可能重复。第四,代码里的 logArr.unshift() 没有传参数,实际不会产生有效日志,真正记录在 set 中完成,这一行是冗余/笔误,理解时应删除或忽略。第五,输入为空时用 alert 提示,不能把空字符串写进日志。
本节目标:从“伪双向”到简易双向绑定
为什么需要:上一版只能做到“视图变,数据跟着变”,例如在输入框输入时更新 data.str;但反过来在控制台改 data.str,页面并不会自动更新。这不是完整的双向绑定,也谈不上响应式。
怎么想:要让“数据变 → 视图变”,就必须知道数据什么时候被写入。Object.defineProperty 可以拦截对象属性的读取和写入,正好用来监听 data.str。
怎么做:用 get 返回内部值 _str,用 set 接收新值 v,在 set 里同时更新 _str、输入框和 div。
易错点:转写稿里“deef property”是口误,正确是 Object.defineProperty;set 里不能写 data.str = v,否则会递归触发自身。必须用中间变量 _str 存真实值。
用 get/set 拦截 data.str
为什么需要:如果直接写 data.str = "新值",普通对象属性不会通知页面。
怎么想:把 data.str 变成“被拦截的属性”:读取时走 get,写入时走 set。
怎么做:先保存 let _str = data.str;,再定义:
js
Object.defineProperty(data, "str", {
get() {
return _str;
},
set(v) {
_str = inp.value = div.innerText = v;
}
});这样执行 data.str = "abc" 时,set 会把 abc 同步到 inp.value 和 div.innerText,实现数据驱动视图。
易错点:get 必须 return _str,否则初始渲染读到 undefined;set 里的赋值链从右往左执行,先更新 div,再更新 input,最后更新 _str,顺序不影响本节结果,但不要漏掉 _str。
初次渲染:数据先显示到页面
为什么需要:页面刚打开时,输入框和 div 还没有内容,需要先用 data.str 初始化。
怎么想:读取 data.str 会触发 get,返回 _str 的初始值。
怎么做:
js
inp.value = div.innerText = data.str;易错点:这行要放在 Object.defineProperty 之后,才能验证 get 是否正常;如果 get 写错,页面会显示 undefined。转写稿中调试时也强调:先看初始值有没有渲染,再看输入和赋值是否联动。
输入事件:视图改数据,再由 set 驱动视图
为什么需要:用户在输入框打字时,需要把输入值写回 data.str,否则数据不是最新。
怎么想:监听 input 事件,拿到 this.value,赋给 data.str;赋值会触发 set,set 再同步 div 和 input。
怎么做:
js
inp.oninput = function(){
data.str = div.innerText = this.value;
}这里 div.innerText = this.value 是立即更新视图,data.str = ... 会触发 set 再同步一次。转写稿中调试时验证:输入内容后下面 div 跟着变;在控制台执行 data.str = "新值",输入框和 div 也应同时变。
易错点:要用 input 事件而不是 change,change 往往要等失焦;this.value 指向当前输入框,不要误写成 data.value;选择器 #root input 中间有空格,表示 #root 后代中的 input。
完整代码与检查点
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">
<input type="text">
<div></div>
</div>
</body>
<script>
const inp = document.querySelector("#root input");
const div = document.querySelector("#root div");
const data = {
str:"我确定你就是那只匹着羊皮的狼!"
};
let _str = data.str;
Object.defineProperty(data,"str",{
get(){
return _str;
},
set(v){
_str = inp.value = div.innerText = v;
}
})
inp.value = div.innerText= data.str;
inp.oninput = function(){
data.str = div.innerText = this.value;
}
</script>
</html>检查点:初始值是否显示;输入时 div 是否变化;控制台改 data.str 时两处是否同步;set 是否只改 _str 而没有递归。这样就是基于 Object.defineProperty 的简易版双向绑定。
为什么需要代理
前面的“伪双向绑定”只能做到:输入框内容变了,页面跟着变;但数据变了,视图并不会自动更新。要让数据成为唯一源头,就必须先解决一个问题:new Zhang(config) 之后,为什么 zm.age 能读到 config.data.age,修改 zm.age 又能改回 config.data.age?
转写稿里先试过一种“写死”的方案:在构造函数里直接写 this.A = config.data.A。这样 zm.A 确实有值,但它只是把值复制了一份;改 zm.A 不会影响 config.data.A,改 config.data.A 也不会影响 zm.A。所以不能靠复制值,而要让实例属性成为访问 $data 的“中介”。这个中介就是代理。
怎么想:用 defineProperty 做读写拦截
Object.defineProperty(obj, key, { get, set }) 可以拦截某个属性的读取和设置。把同名属性定义在实例 this 上:
- 读取
this[key]时走get,返回this.$data[key]; - 设置
this[key] = v时走set,执行this.$data[key] = v。
这样实例上的属性和 $data 中的属性就联动了。转写稿里反复强调:get 在读取时执行,set 在设置时执行;设置时不管新值旧值是否相同都会触发 set。
怎么做:先实现,再拆分优化
未优化版本把所有逻辑塞在构造函数里:
js
// config.data中的属性通过defineProperty作为实例的属性。
// 未优化:
// function Zhang(config){
// this.$data = config.data;
// for(let key in config.methods){
// this[key] = config.methods[key].bind(this);
// }
//
// for(let key in this.$data){
// Object.defineProperty(this,key,{
// get(){
// return this.$data[key]
// },
// set(v){
// this.$data[key] = v;
// }
// })
// }
// }
// 优化
function Zhang(config){
this.$data = config.data;
this.initMethods(config);
this.initData();
}
Zhang.prototype.initMethods = function(config){
for(let key in config.methods){
this[key] = config.methods[key].bind(this);
}
}
Zhang.prototype.initData = function(){
for(let key in this.$data){
this.proxy(key);
}
}
Zhang.prototype.proxy = function(key){
Object.defineProperty(this,key,{
get(){
return this.$data[key]
},
set(v){
this.$data[key] = v;
}
})
}优化点是把“初始化方法”“初始化数据”“单个属性代理”拆到原型上。转写稿的解释是:行为写到函数里,函数放到原型上,便于复用和后续扩展;一个函数里代码太长可读性差。initData 遍历 this.$data,对每个 key 调用 this.proxy(key),proxy 内部用闭包拿到 key,再用 defineProperty 定义到实例上。
方法绑定与验证
笔记里的 HTML 用来验证两件事:代理是否生效,方法解构后 this 是否还指向实例。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="lib/Zhang3.js"></script>
</head>
<body>
<div id="root"></div>
</body>
<script>
const zm = new Zhang({
data:{
age:12,
userName:"zhangsan"
},
methods:{
fn(){
console.log(this,this.age,this.userName);
}
}
});
// zm.fn();
const fn = zm.fn;
fn();
console.log(zm)
</script>
</html>initMethods 里执行 this[key] = config.methods[key].bind(this),所以 const fn = zm.fn; fn() 时,fn 里的 this 仍然是 zm,能读到 this.age 和 this.userName。如果不 bind,解构调用时 this 就不是实例,读不到代理属性。
易错点
this.$data = config.data要先执行,否则initData遍历不到数据。set(v)里必须写this.$data[key] = v,不能写this[key] = v,否则会再次触发set,造成递归。get返回this.$data[key],不要返回this[key],否则同样递归。defineProperty的第二个参数要用变量key,不能写字符串"key"。- 方法要
bind(this),否则解构后this丢失;转写稿里的调试正是用const fn = zm.fn; fn()验证这一点。 - 控制台查看
zm时,代理属性会显示get/set,这是正常现象。
从“伪双向绑定”到响应式
这一节要把上一节的“伪双向绑定”补成真正的响应式:不仅视图改能改数据,数据改也能驱动视图。转写稿里先验证了 Object.defineProperty 的 get/set 执行时机:读取 info.bookName 走 get,设置 info.bookName = v 走 set;并且只要执行设置就会触发,不管新值旧值是否相同。由此可以做一个“代理”:实例上的属性读写都转到 $data 上,并在 set 里通知订阅者。为什么需要?因为只有拦截到数据变化,才能通知视图更新。怎么想?把数据与订阅者做成一对多:一个数据可能被 z-model、z-text、z-html 多处使用,每处生成一个 Watcher。怎么做?observe 初始化 binds[key] = [],用 defineProperty(this, key, { get, set });set 中先改 $data,再遍历 binds[key] 调 update()。易错点:binds[key] 必须先初始化为数组;set 里不要只改实例属性而忘了 $data;事件方法要用 bind(this),否则 @click 里 this 不是实例。
发布者 observe 与订阅者 Watcher
observe 是发布者:侦听数据变化并通知订阅者。Watcher 是订阅者:保存 node、attr、zm、prop,update 时执行 this.node[this.attr] = this.zm[this.prop]。为什么需要?因为 compile 解析模板时只知道“这里用了哪个数据”,但数据变化后由谁改 DOM?交给 Watcher。怎么想?解析到 z-model 时,先把 node.value 设为初始值,监听 input 把输入写回实例数据,同时 new Watcher(node,"value",this,attrValue) 塞进 binds[attrValue];z-text 对应 innerText,z-html 对应 innerHTML。易错点:z-model 的 input 回调里要用箭头函数或提前绑定,保证 this 是实例;Watcher 的 prop 是字符串,读取 zm[prop] 才能走代理 get。
编译器 compile 解析指令
compile(el) 取 el.children 遍历元素子节点,先递归 this.compile(node),再判断当前节点是否有 @click、z-model、z-text、z-html。为什么需要?因为模板里的指令要变成初始渲染 + 订阅。怎么想?用 hasAttribute 判断是否存在,用 getAttribute 取属性值;@click 的方法名在构造函数里已通过 config.methods[key].bind(this) 挂到实例上。怎么做?@click 用 addEventListener("click", this[attrValue]);z-model 设置初值并监听 input;z-text/z-html 设置初值并注册 Watcher。易错点:children 只含元素节点,不含文本节点;从 this.$el.children 开始解析,所以根元素自身的指令不会处理;递归顺序是先子后当前,调试时可用 console.log(node) 确认节点。
完整代码:Zhang7.js
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
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>运行与调试要点
打开页面后,z-text 显示文本,z-html 渲染成链接;点击按钮执行 changeStr,str 和 num 被修改,set 通知对应 binds 中的所有 Watcher,输入框、文本、HTML 同步更新。调试时先看 new Zhang 后实例是否有 $data、$el、binds,再看 observe 是否给每个 data 属性建立 binds[key],最后看 compile 是否按 @click、z-model、z-text、z-html 注册订阅者。易错点:hasAttribute 判断“有没有”,getAttribute 取“值”;z-model 初始值要同步;set 中先更新 $data 再通知;Watcher.update 中属性名必须用字符串。
课堂画面






代码示例
示例一:用 Object.defineProperty 拦截属性的读写
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
<script>
// 拦截器(拦截方法):是Object对象下的一个方法
let obj = {
userName:"zhangsan"
}
let _userName = obj.userName;
// obj.userName = "lisi";
// console.log(obj.userName);// lisi
Object.defineProperty(obj,"userName",{
// 读
get(){
console.log("get")
return "《"+_userName+"》";
},
// 写
set(v){
// console.log("set",v)
_userName = v;
}
})
console.log(obj.userName);// 读取数据
obj.userName = "lisi";
console.log(obj.userName);// 《lisi》
</script>
</html>要点与易错点
Object.defineProperty(对象, "属性名", { get, set })可以对对象下的属性进行拦截,控制属性的读写、删除、枚举(遍历)、修改。get在读取obj.userName时执行,set在赋值obj.userName = xxx时执行;只要触发赋值就执行set,不管新旧值是否相同。- 必须在
defineProperty之前用一个中间变量(这里的_userName)保存原值,否则get里再写return obj.userName会无限递归爆栈——这是最容易踩的坑。 - 这种写法的属性不会出现在
for...in里(默认enumerable:false),需要遍历时要显式配置enumerable:true。
示例二:简易版双向绑定(数据 ↔ 视图)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">
<input type="text">
<div></div>
</div>
</body>
<script>
const inp = document.querySelector("#root input");
const div = document.querySelector("#root div");
const data = {
str:"我确定你就是那只匹着羊皮的狼!"
};
let _str = data.str;
Object.defineProperty(data,"str",{
get(){
return _str;
},
set(v){
_str = inp.value = div.innerText = v;
}
})
inp.value = div.innerText = data.str;
inp.oninput = function(){
data.str = div.innerText = this.value;
}
</script>
</html>要点与易错点
- 这里实现了“数据变 → 视图变”(
set里同时更新inp.value和div.innerText)和“视图变 → 数据变”(oninput里改data.str),所以是双向;而 11-1 的伪双向绑定只有视图驱动数据,数据变不会驱动视图。 - 初始渲染必须手动执行一次
inp.value = div.innerText = data.str;,因为get/set只负责后续的拦截,不会自动把初始值写进 DOM。 - 用
oninput而不是onchange:onchange要等失去焦点才触发,输入过程中视图不跟着变。 - 注意
_str中间变量的存在,get返回_str而不是data.str,避免自引用死循环。 - 这种写法只能处理一个数据、一个视图,硬编码了
inp和div,所以下一节才要用“代理 + 编译 + 订阅者”来做通用版本。
示例三:简易响应式框架(编译器 + 发布者 + 订阅者)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</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>
/* ================= Zhang7.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(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];
}
/* ================= 页面使用 ================= */
// 数据与订阅者是一对多的关系。
// 编译器(compile):编译模板
// 发布者(observe):负责侦听数据,一旦数据发生改变会通知订阅者
// 产生订阅者(Watcher):生成订阅者。
// 由编译器(解析器)对模板进行解析,解析的过程中一旦发现有对应的(数据,函数,过滤器等)信息,
// 那么会将对应的数据增加一个订阅者(Watcher)。
// 一旦数据发生改变,侦听器(observe)会通知对应数据的所有订阅者,订阅者负责更新视图。
const zm = new Zhang({
el: "#root",
data: {
str: "<a>我在尚硅谷学习</a>",
num: 1
},
methods: {
changeStr() {
this.str += "!";
this.num++;
}
}
})
</script>
</html>要点与易错点
- 三段式结构:
observe是发布者(用defineProperty把$data的属性代理到实例上,并在set里通知订阅者);compile是解析器(递归扫描模板,识别@click、z-model、z-text、z-html);Watcher是订阅者(只负责把zm[prop]写回node[attr])。 this.binds[key] = []必须写在for...in循环内部、每个 key 各建一个数组,否则所有属性会共用同一个订阅者列表,改num会把str的视图也一起更新。this.binds[key]要在compile里 push 之前就存在,所以observe()必须在compile()之前调用;顺序颠倒会报Cannot read properties of undefined。methods里必须.bind(this),否则@click里this指向node,this.str拿到undefined;同理node.addEventListener("click", this[attrValue])传的是绑定后的函数,不能写成this[attrValue]()。compile里的递归调用this.compile(node)放在判断之前,才能解析到<div><input z-model="num">…</div>这种嵌套结构;只扫一层的话内层指令不生效。z-text用innerText(不解析标签),z-html用innerHTML(会渲染<a>标签),这两个不能混用,否则页面会显示成纯文本或把字符串当 HTML 解析。Watcher.update里用this.zm[this.prop]而不是this.zm.$data[this.prop],因为observe已经通过代理把$data的属性挂到了实例上——这就是 12-5 里proxy那段的作用。
常见错误
现象:修改
this.a后this.$data.a不变,或修改this.$data.a后视图不更新。
原因:只复制了值,没有在代理set中写回$data;或监听的是实例属性,却绕过实例直接改$data。
修法:set(v){ this.$data[key] = v; this.binds[key].forEach(w => w.update()) },并统一通过this.a修改。现象:数据变了,页面不变。
原因:set中只更新了数据,忘记通知订阅者。
修法:在set中写回$data后,遍历this.binds[key]调用每个Watcher.update()。现象:
Maximum call stack size exceeded。
原因:在set中写this[key] = v,再次触发同一个 setter,形成递归。
修法:set中写底层数据this.$data[key] = v,或写中间变量_key = v。现象:
children.forEach is not a function。
原因:el.children是HTMLCollection,不是真数组。
修法:用Array.from(el.children)、[...el.children]或普通for循环。现象:
@click方法中this是undefined或window,不是实例。
原因:methods未绑定实例,或把方法解构后直接调用。
修法:构造时this[key] = config.methods[key].bind(this),事件绑定使用已绑定方法。现象:
z-model初始值能显示,输入后其他视图不同步。
原因:只做了初始赋值,没有监听input,也没有注册Watcher。
修法:绑定input事件更新数据,并向this.binds[attrValue]推入对应Watcher。现象:修改数据后立刻读取 DOM,拿到旧文本。
原因:Vue 的 DOM 更新是异步队列。
修法:使用await this.$nextTick()或this.$nextTick(cb)后再读取 DOM。
练习
属性日志:用
Object.defineProperty拦截info.bookName,set时向logArr追加{ id, bookName, addTime },get返回最后一条记录的bookName。
验收:每次通过info.bookName = xxx修改,日志长度递增,读取得到最新值。简易双向绑定:实现
data.str与input.value、div.innerText双向同步。
验收:输入框输入时data.str和div同步;执行data.str = "新值"时输入框和div同步。实例代理:实现
Zhang({ data, methods }),把data属性代理到实例上,并让methods中的this永远指向实例。
验收:zm.age与zm.$data.age双向一致;解构const fn = zm.fn; fn()后this仍是zm。最小响应式闭环:在
observe的 setter 中通知binds[key],在compile中解析z-model、z-text、z-html、@click,并用Watcher更新视图。
验收:点击按钮修改str/num后,输入框、文本节点、HTML 节点全部同步;控制台无递归报错。$nextTick修复:写一个按钮,点击后修改msg,并立即读取 DOM 文本。先用同步读取观察旧值,再用$nextTick获取新值。
验收:同步读取得到旧值;await this.$nextTick()后读取得到新值。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | Object.defineProperty的应用 | 19:21 |
| 2 | 简易版双向绑定 | 10:48 |
| 3 | 关于data | 03:33 |
| 4 | 实现代理 | 28:52 |
| 5 | 优化 | 15:53 |
| 6 | 完成初次渲染 | 30:04 |
小结
- 响应式的闭环是“拦截读写 → 建立依赖 → 数据变更通知 → Watcher 更新视图”,缺一环都会出现“数据变页面不变”。
Object.defineProperty的get/set必须避免自递归,set要写底层数据并通知订阅者。- 实例属性代理不等于
$data内部属性自动响应式,统一修改入口比事后补救更重要。 - 编译期负责把指令和数据建立订阅关系,运行期 setter 负责触发已建立的订阅。
- 一个数据可以对应多个
Watcher,数据变更时必须通知该数据的全部订阅者。 methods在构造阶段绑定this,是事件回调和解构调用不丢实例的前提。$nextTick用于等待 DOM 更新,数据修改后不要同步断言 DOM。- 简化实现与 Vue 仍有差距,学习重点应放在“代理、依赖、通知、更新”这条主链上。