响应式系统(一)

前言 Vue的响应式系统还是有点大的,我们可以通过一个小栗子,由浅入深的走下设计思路
最终版demo
正文

const data = https://www.it610.com/article/{ key:'value' }function $watch(exp, fn) { // ... }$watch('key', () => { console.log('data.key被修改了') })

如上所示,我们需要完成这么个功能,其实倒也不难,难的是一些情况的处理。比如重复依赖、深度观测、数组观测等。我们先不考虑这些问题,按最简单的一步一步的来
首先我们需要监测到这个数据什么时候被修改了,所幸这个ES有现成的Object.defineProperty
Object.defineProperty(data, 'key', { get() { console.log('读取了data.key') }, set() { console.log('设置了data.key') } })

可见我们实现了对data.key的拦截。那么如何使得传入的函数和属性关联(收集依赖)、修改属性的时候触发传入的函数(触发依赖),很显然get收集依赖,set触发依赖。所以我们先整个容器,在get时把依赖收集到这个容器即可,set时就可以遍历这个容器触发依赖
// 容器 const dep = [] Object.defineProperty(data, 'key', { get() { dep.push(fn) }, set() { dep.forEach(fn => fn()) } })

代码来看很简单,只是有个问题,这个fn也就是观察者如何来
其实我们$watch第二参数就是fn那么现在就需要俩者关联起来
这个也简单,因为我们调用$watch时是知道观测哪个属性的,那么我们可以在$watch里访问这个属性,这样子就可以触发这个属性的get函数以达到依赖收集的目的
const data = https://www.it610.com/article/{ key:'value' }const dep = [] Object.defineProperty(data, 'key', { get() { dep.push(Target) }, set() { dep.forEach(fn => fn()) } }) let Target = null function $watch(exp, fn) { // 将fn赋值给Target使得get函数可以取到 Target = fn // 访问属性触发get函数 data[exp] }$watch('key', () => { console.log('data.key被修改了') })

迄今为止,我们可得到这个简单版本,修改data.key可见fn被触发
不过既然是简单版本自然有很多问题:
  1. 这里只是针对data.key,那么data.xx呢,所以得做个遍历,而且还得考虑到深层
function walk(data) { for (const key in data) { if (data.hasOwnProperty(key)) { const val = data[key] const dep = [] if(Reflect.toString.call(val) === '[object Object]') { walk(val) } Object.defineProperty(data, key, { get() { dep.push(Target) }, set() { dep.forEach(fn => fn()) } }) } } } walk(data)

这里简单遍历一下对象,判断下若子项是对象那么递归处理即可
  1. 这里get函数没有return,导致data.xx === undefined
function walk(data) { for (const key in data) { if (data.hasOwnProperty(key)) { let val = data[key] const dep = [] if(Reflect.toString.call(val) === '[object Object]') { walk(val) } Object.defineProperty(data, key, { get() { dep.push(Target) return val }, set(nVal) { if(nVal === val) { return } const oVal = val val = nVal dep.forEach(fn => fn(nVal, oVal)) } }) } } } walk(data)

这里简单的处理了val的问题,且将新旧值传入fn,使得fn内可以取值
  1. 这里我们揭示下$watch函数的作用
let Target = null function $watch(exp, fn) { // 将fn赋值给Target使得get函数可以取到 Target = fn // 访问属性触发get函数 data[exp] }

其实我们可以可见它就干了两个事:
  • 传入观察者fn,而且这个观察者被收集还通过了Target
  • 触发get
    首先我们联想下Vue.$watch,这个exp既可以是xx.xx.xx也可以是函数,也就是这个其实只要是能触发你要观察的对象的get函数就行,不限制你用什么手段,所以可以进行简单改造
let Target = null function $watch(exp, fn) { // 将fn赋值给Target使得get函数可以取到 Target = fn // 访问属性触发get函数 if(typeof exp === 'function') { exp() return } let obj = data const pathArr = exp.split('.') pathArr.forEach((path) => { obj = obj[path] }) }$watch('key', (nVal, oVal) => { console.log('data.key被修改了', nVal, oVal) }) $watch('a.b', (nVal, oVal) => { console.log('data.a.b被修改了', nVal, oVal) }) const render = () => { document.write(`data.a.b: ${data.a.b} ---- data.key: ${data.key}
`) } $watch(render, render)

我们看$watch(render, render),这个render函数里面访问了data数据,所以自然也能触发get函数,那么自然也就能收集依赖,触发依赖的话重新执行render函数,这也就是Vue页面渲染思路
  1. 我们可见每次访问数据时都会触发get函数,也就会有重复收集依赖问题
    我们这里就简单处理下
function walk(data) { // ... Object.defineProperty(data, key, { get() { Target && dep.push(Target) return val }, set(nVal) { // ... } }) // ... } let Target = null function $watch(exp, fn) { // 将fn赋值给Target使得get函数可以取到 Target = fn // 访问属性触发get函数 if (typeof exp === 'function') { exp() Target = null return } let obj = data const pathArr = exp.split('.') pathArr.forEach((path) => { obj = obj[path] }) Target = null }

我们在$watch里触发完了get函数之后把Target置空,get函数里就可以通过判断Target来收集依赖
当然这里也仅仅是简单处理,若是对同个属性多次调用$watch观测也是会重复的,当然还有很多其他的诸如数组观测问题也就懒得处理了
最终版demo
后言 【响应式系统(一)】由上文可见实现响应式系统大致也就这几步:
  1. 观测数据,将其转化为响应式对象
  2. 暴露$watch方法,接收exp、fn参数,确定要监听的属性以及接收数据变化的回调函数
  3. get收集依赖,set触发依赖,也就是get里收集fnset里触发fn

    推荐阅读