Skip to content

$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 就不是实例,读不到代理属性。

易错点 ​

  1. this.$data = config.data 要先执行,否则 initData 遍历不到数据。
  2. set(v) 里必须写 this.$data[key] = v,不能写 this[key] = v,否则会再次触发 set,造成递归。
  3. get 返回 this.$data[key],不要返回 this[key],否则同样递归。
  4. defineProperty 的第二个参数要用变量 key,不能写字符串 "key"。
  5. 方法要 bind(this),否则解构后 this 丢失;转写稿里的调试正是用 const fn = zm.fn; fn() 验证这一点。
  6. 控制台查看 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 中属性名必须用字符串。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:用 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 那段的作用。

常见错误 ​

  1. 现象:修改 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 修改。

  2. 现象:数据变了,页面不变。
    原因:set 中只更新了数据,忘记通知订阅者。
    修法:在 set 中写回 $data 后,遍历 this.binds[key] 调用每个 Watcher.update()。

  3. 现象:Maximum call stack size exceeded。
    原因:在 set 中写 this[key] = v,再次触发同一个 setter,形成递归。
    修法:set 中写底层数据 this.$data[key] = v,或写中间变量 _key = v。

  4. 现象:children.forEach is not a function。
    原因:el.children 是 HTMLCollection,不是真数组。
    修法:用 Array.from(el.children)、[...el.children] 或普通 for 循环。

  5. 现象:@click 方法中 this 是 undefined 或 window,不是实例。
    原因:methods 未绑定实例,或把方法解构后直接调用。
    修法:构造时 this[key] = config.methods[key].bind(this),事件绑定使用已绑定方法。

  6. 现象:z-model 初始值能显示,输入后其他视图不同步。
    原因:只做了初始赋值,没有监听 input,也没有注册 Watcher。
    修法:绑定 input 事件更新数据,并向 this.binds[attrValue] 推入对应 Watcher。

  7. 现象:修改数据后立刻读取 DOM,拿到旧文本。
    原因:Vue 的 DOM 更新是异步队列。
    修法:使用 await this.$nextTick() 或 this.$nextTick(cb) 后再读取 DOM。

练习 ​

  1. 属性日志:用 Object.defineProperty 拦截 info.bookName,set 时向 logArr 追加 { id, bookName, addTime },get 返回最后一条记录的 bookName。
    验收:每次通过 info.bookName = xxx 修改,日志长度递增,读取得到最新值。

  2. 简易双向绑定:实现 data.str 与 input.value、div.innerText 双向同步。
    验收:输入框输入时 data.str 和 div 同步;执行 data.str = "新值" 时输入框和 div 同步。

  3. 实例代理:实现 Zhang({ data, methods }),把 data 属性代理到实例上,并让 methods 中的 this 永远指向实例。
    验收:zm.age 与 zm.$data.age 双向一致;解构 const fn = zm.fn; fn() 后 this 仍是 zm。

  4. 最小响应式闭环:在 observe 的 setter 中通知 binds[key],在 compile 中解析 z-model、z-text、z-html、@click,并用 Watcher 更新视图。
    验收:点击按钮修改 str/num 后,输入框、文本节点、HTML 节点全部同步;控制台无递归报错。

  5. $nextTick 修复:写一个按钮,点击后修改 msg,并立即读取 DOM 文本。先用同步读取观察旧值,再用 $nextTick 获取新值。
    验收:同步读取得到旧值;await this.$nextTick() 后读取得到新值。

原视频对照 ​

#视频时长
1Object.defineProperty的应用19:21
2简易版双向绑定10:48
3关于data03:33
4实现代理28:52
5优化15:53
6完成初次渲染30:04

小结 ​

  1. 响应式的闭环是“拦截读写 → 建立依赖 → 数据变更通知 → Watcher 更新视图”,缺一环都会出现“数据变页面不变”。
  2. Object.defineProperty 的 get/set 必须避免自递归,set 要写底层数据并通知订阅者。
  3. 实例属性代理不等于 $data 内部属性自动响应式,统一修改入口比事后补救更重要。
  4. 编译期负责把指令和数据建立订阅关系,运行期 setter 负责触发已建立的订阅。
  5. 一个数据可以对应多个 Watcher,数据变更时必须通知该数据的全部订阅者。
  6. methods 在构造阶段绑定 this,是事件回调和解构调用不丢实例的前提。
  7. $nextTick 用于等待 DOM 更新,数据修改后不要同步断言 DOM。
  8. 简化实现与 Vue 仍有差距,学习重点应放在“代理、依赖、通知、更新”这条主链上。