手抄Vue(一)—— 简单实现数据响应

Vue 中可以用 $watch 实例方法观察一个字段,当该字段的值发生变化时,会执行指定的回调函数(即观察者),实际上和 watch 选项作用相同。如下:

vm.$watch('box', () => {
    console.log('box变了')
})
vm.box = 'newValue' // 'box变了'

以上例切入,我想实现一个功能类似的方法 myWatch

如何知道我观察的属性被修改了?

—— Object.defineProperty 方法

该方法可以为指定对象的指定属性设置 getter-setter 函数对,通过这对 getter-setter 可以捕获到对属性的读取和修改操作。示例如下:

const data = {
  box: 1
}
Object.defineProperty(data, 'box', {
  set () {
    console.log('修改了 box')
  },
  get () {
    console.log('读取了 box')
  }
})

console.log(data.box) // '读取了 box'
                      // undefined
data.box = 2   // '修改了 box'
console.log(data.box) // '读取了 box'
                      // undefined

如此,便拦截到了对 box 属性的修改和读取操作。

resundefineddata.box = 2 的修改操作也无效。

getset 函数功能不健全

故修改如下:

const data = {
  box: 1
}
let value = data.box
Object.defineProperty(data, 'box', {
  set (newVal) {
    if (newVal === value) return
    value = newVal
    console.log('修改了 box')
  },
  get () {
    console.log('读取了 box')
    return value
  }
})

console.log(data.box) // '读取了 box'
                      // 1

data.box = 2 // '修改了 box'
console.log(data.box) // '读取了 box'
                      // 2

有了这些, myWatch 方法便可实现如下:

const data = {
  box: 1
}
function myWatch(key, fn) {
  let value = data[key]
  Object.defineProperty(data, key, {
    set (newVal) {
      if (newVal === value) return
      value = newVal
      fn()
    },
    get () {
      return value
    }
  })
}
myWatch('box', () => {
    console.log('box变了')
})

data.box = 2 // 'box变了'

但存在一个问题,不能给同一属性添加多个依赖(观察者):

myWatch('box', () => {
  console.log('我是观察者')
})
myWatch('box', () => {
  console.log('我是另一个观察者')
})

data.box = 2 // '我是另一个观察者'

后面的依赖(观察者)会将前者覆盖掉。

如何能够添加多个依赖(观察者)?

—— 定义一个数组,作为依赖收集器:

const data = {
  box: 1
}
const dep = []
function myWatch(key, fn) {
  dep.push(fn)
  let value = data[key]
  Object.defineProperty(data, key, {
    set (newVal) {
      if (newVal === value) return
      value = newVal
      dep.forEach((f) => {
        f()
      })
    },
    get () {
      return value
    }
  })
}

myWatch('box', () => {
  console.log('我是观察者')
})
myWatch('box', () => {
  console.log('我是另一个观察者')
})

data.box = 2 // '我是观察者'
             // '我是另一个观察者'

修改 data.box 后,两个依赖(观察者)都执行了。

若上例 data 对象需新增两个能够响应数据变化的属性 foo bar

const data = {
  box: 1,
  foo: 1,
  bar: 1
}

只需执行以下代码即可:

myWatch('foo', () => {
  console.log('我是foo的观察者')
})
myWatch('bar', () => {
  console.log('我是bar的观察者')
})

但问题是,不同属性的依赖(观察者)都被收集进了同一个 dep,修改任何一个属性,都会触发所有的依赖(观察者):

data.box = 2 // '我是观察者'
             // '我是另一个观察者'
             // '我是foo的观察者'
             // '我是bar的观察者'

我想可以这样解决:

const data = {
  box: 1,
  foo: 1,
  bar: 1
}
const dep = {}
function myWatch(key, fn) {
  if (!dep[key]) {
    dep[key] = [fn]
  } else {
    dep[key].push(fn)
  }
  let value = data[key]
  Object.defineProperty(data, key, {
    set (newVal) {
      if (newVal === value) return
      value = newVal
      dep[key].forEach((f) => {
        f()
      })
    },
    get () {
      return value
    }
  })
}

myWatch('box', () => {
  console.log('我是box的观察者')
})
myWatch('box', () => {
  console.log('我是box的另一个观察者')
})
myWatch('foo', () => {
  console.log('我是foo的观察者')
})
myWatch('bar', () => {
  console.log('我是bar的观察者')
})

data.box = 2 // '我是box的观察者'
             // '我是box的另一个观察者'
data.foo = 2 // '我是foo的观察者'
data.bar = 2 // '我是bar的观察者'

但实际上这样更好些:

const data = {
  box: 1,
  foo: 1,
  bar: 1
}
let target = null
for (let key in data) {
  const dep = []
  let value = data[key]
  Object.defineProperty(data, key, {
    set (newVal) {
      if (newVal === value) return
      value = newVal
      dep.forEach(f => {
        f()
      })
    },
    get () {
      dep.push(target)
      return value
    }
  })
}
function myWatch(key, fn) {
  target = fn
  data[key]
}
myWatch('box', () => {
  console.log('我是box的观察者')
})
myWatch('box', () => {
  console.log('我是box的另一个观察者')
})
myWatch('foo', () => {
  console.log('我是foo的观察者')
})
myWatch('bar', () => {
  console.log('我是bar的观察者')
})

data.box = 2 // '我是box的观察者'
             // '我是box的另一个观察者'
data.foo = 2 // '我是foo的观察者'
data.bar = 2 // '我是bar的观察者'

声明 target 全局变量作为依赖(观察者)的中转站,myWatch 函数执行时用 target 缓存依赖,然后调用 data[key] 触发对应的 get 函数以收集依赖,set 函数被触发时会将 dep 里的依赖(观察者)都执行一遍。这里的 get set 函数形成闭包引用了上面的 dep 常量,这样一来,data 对象的每个属性都有了对应的依赖收集器。

且这一实现方式不需要通过 myWatch 函数显式地将 data 里的属性一一转为访问器属性。

但运行以下代码,会发现仍有问题:

console.log(data.box)
data.box = 2 // '我是box的观察者'
             // '我是box的另一个观察者'
             // '我是bar的观察者'

四个 myWatch 执行完之后 target 缓存的值变成了最后一个 myWatch 方法调用时所传递的依赖(观察者),故执行 console.log(data.box) 读取 box 属性的值时,会将最后缓存的依赖存入 box 属性所对应的依赖收集器,故而再修改 box 的值时,会打印出 '我是bar的观察者'

我想可以在每次收集完依赖之后,将全局变量 target 设置为空函数来解决这问题:

const data = {
  box: 1,
  foo: 1,
  bar: 1
}
let target = null
for (let key in data) {
  const dep = []
  let value = data[key]
  Object.defineProperty(data, key, {
    set (newVal) {
      if (newVal === value) return
      value = newVal
      dep.forEach(f => {
        f()
      })
    },
    get () {
      dep.push(target)
      target = () => {}
      return value
    }
  })
}
function myWatch(key, fn) {
  target = fn
  data[key]
}
myWatch('box', () => {
  console.log('我是box的观察者')
})
myWatch('box', () => {
  console.log('我是box的另一个观察者')
})
myWatch('foo', () => {
  console.log('我是foo的观察者')
})
myWatch('bar', () => {
  console.log('我是bar的观察者')
})

经测无误。

但开发过程中,还常碰到需观测嵌套对象的情形:

const data = {
  box: {
    gift: 'book'
  }
}

这时,上述实现未能观测到 gift 的修改,显出不足。

如何进行深度观测?

——递归

通过递归将各级属性均转为响应式属性即可:

const data = {
  box: {
    gift: 'book'
  }
}
let target = null
function walk(data) {
  for (let key in data) {
    const dep = []
    let value = data[key]
    if (Object.prototype.toString.call(value) === '[object Object]') {
      walk(value)
    }
    Object.defineProperty(data, key, {
      set (newVal) {
        if (newVal === value) return
        value = newVal
        dep.forEach(f => {
          f()
        })
      },
      get () {
        dep.push(target)
        target = () => {}
        return value
      }
    })
  }
}
walk(data)
function myWatch(key, fn) {
  target = fn
  data[key]
}

myWatch('box', () => {
  console.log('我是box的观察者')
})
myWatch('box.gift', () => {
  console.log('我是gift的观察者')
})

data.box = {gift: 'basketball'} // '我是box的观察者'
data.box.gift = 'guitar'

这时 gift 虽已是访问器属性,但 myWatch 方法执行时 data[box.gift] 未能触发相应 getter 以收集依赖, data[box.gift] 访问不到 gift 属性,data[box][gift] 才可以,故 myWatch 须改写如下:

function myWatch(exp, fn) {
  target = fn
  let pathArr,
      obj = data
  if (/\./.test(exp)) {
    pathArr = exp.split('.')
    pathArr.forEach(p => {
      obj = obj[p]
    })
    return
  }
  data[exp]
}

如果要读取的字段包括 . ,那么按照 . 将其分为数组,然后使用循环读取嵌套对象的属性值。

这时执行代码后发现,data.box.gift = 'guitar' 还是未能触发相应的依赖,即打印出 '我是gift的观察者' 这句信息。调试之后找到问题:

myWatch('box.gift', () => {
  console.log('我是gift的观察者')
})

执行以上代码时,pathArr['box', 'gift'],循环内 obj = obj[p] 实际上就是 obj = data[box],读取了一次 box,触发了 box 对应的 getter,收集了依赖:

() => {
  console.log('我是gift的观察者')
}

收集完将全局变量 target 置为空函数,而后,循环继续执行,又读取了 gift 的值,但这时,target 已是空函数,导致属性 gift 对应的 getter 收集了一个“空依赖”,故,data.box.gift = 'guitar' 的操作不能触发期望的依赖。

以上代码有两个问题:

  • 修改 box 会触发“我是gift的观察者”这一依赖
  • 修改 gift 未能触发“我是gift的观察者”的依赖

第一个问题,读取 gift 时,必然经历读取 box 的过程,故触发 box 对应的 getter 无可避免,那么,box 对应 getter 收集 gift 的依赖也就无可避免。但想想也算合理,因为 box 修改时,隶属于 boxgift 也算作修改,从这一点看,问题一也不算作问题,划去。

第二个问题,我想可以这样解决:

function myWatch(exp, fn) {
  let pathArr,
      obj = data
  if (/\./.test(exp)) {
    pathArr = exp.split('.')
    pathArr.forEach(p => {
      target = fn
      obj = obj[p]
    })
    return
  }
  target = fn
  data[exp]
}

data.box.gift = 'guitar' // '我是gift的观察者'
data.box = {gift: 'basketball'} // '我是box的观察者'
                                // '我是gift的观察者'

保证属性读取时 target = fn 即可。

那么:

const data = {
  box: {
    gift: 'book'
  }
}
let target = null
function walk(data) {
  for (let key in data) {
    const dep = []
    let value = data[key]
    if (Object.prototype.toString.call(value) === '[object Object]') {
      walk(value)
    }
    Object.defineProperty(data, key, {
      set (newVal) {
        if (newVal === value) return
        value = newVal
        dep.forEach(f => {
          f()
        })
      },
      get () {
        dep.push(target)
        target = () => {}
        return value
      }
    })
  }
}
walk(data)
function myWatch(exp, fn) {
  let pathArr,
      obj = data
  if (/\./.test(exp)) {
    pathArr = exp.split('.')
    pathArr.forEach(p => {
      target = fn
      obj = obj[p]
    })
    return
  }
  target = fn
  data[exp]
}

myWatch('box', () => {
  console.log('我是box的观察者')
})
myWatch('box.gift', () => {
  console.log('我是gift的观察者')
})

现在我想,假如我有以下数据:

const data = {
  player: 'James Harden',
  team: 'Houston Rockets'
}

执行以下代码:

function render() {
  document.body.innerText = `The last season's MVP is ${data.player}, he's from ${data.team}`
}
render()
myWatch('player', render)
myWatch('team', render)

data.player = 'Kobe Bryant'
data.team = 'Los Angeles Lakers'

是不是就可以将数据映射到页面,并响应数据的变化?

执行代码发现,data.player = 'Kobe Bryant' 报错,究其原因,render 方法执行时,会去获取 data.playerdata.team 的值,但此时,targetnull,那么读取 player 时对应的依赖收集器 dep 便收集了 null,导致 playersetter 调用依赖时报错。

那么我想,在 render 执行时便主动去收集依赖,就不会导致 dep 里收集了 null

细看 myWatch,这方法做的事情其实就是帮助 getter 收集依赖,它的第一个参数就是要访问的属性,要触发谁的 getter,第二个参数是相应要收集的依赖。

这么看来,render 方法既可以帮助 getter 收集依赖(render 执行时会读取 player team),而且它本身就是要收集的依赖。那么,我能不能修改一下 myWatch 的实现,以支持这样的写法:

myWatch(render, render)

第一个参数作为函数执行一下便有了之前第一个参数的作用,第二个参数还是需要被收集的依赖,嗯,想来合理。

那么,myWatch 改写如下:

function myWatch(exp, fn) {
  target = fn
  if (typeof exp === 'function') {
    exp()
    return
  }
  let pathArr,
      obj = data
  if (/\./.test(exp)) {
    pathArr = exp.split('.')
    pathArr.forEach(p => {
      target = fn
      obj = obj[p]
    })
    return
  }
  data[exp]
}

但,对 team 的修改未能触发页面更新,想来因为 render 执行读取 player 收集依赖后 target 变为空函数,导致读取 team 收集依赖时收集到了空函数。这里大家的依赖都是 render,故可将 target = () => {} 这句删去。

myWatch 这样实现还有个好处,假如 data 中有许多属性都需要通过 render 渲染至页面,一句 myWatch(render, render) 便可,无须如此这般繁复:

myWatch('player', render)
myWatch('team', render)
myWatch('number', render)
myWatch('height', render)
...

那么最终:

const data = {
  player: 'James Harden',
  team: 'Houston Rockets'
}
let target = null
function walk(data) {
  for (let key in data) {
    const dep = []
    let value = data[key]
    if (Object.prototype.toString.call(value) === '[object Object]') {
      walk(value)
    }
    Object.defineProperty(data, key, {
      set (newVal) {
        if (newVal === value) return
        value = newVal
        dep.forEach(f => {
          f()
        })
      },
      get () {
        dep.push(target)
        return value
      }
    })
  }
}
walk(data)
function myWatch(exp, fn) {
  target = fn
  if (typeof exp === 'function') {
    exp()
    return
  }
  let pathArr,
      obj = data
  if (/\./.test(exp)) {
    pathArr = exp.split('.')
    pathArr.forEach(p => {
      target = fn
      obj = obj[p]
    })
    return
  }
  data[exp]
}
function render() {
  document.body.innerText = `The last season's MVP is ${data.player}, he's from ${data.team}`
}

myWatch(render, render)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,874评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,102评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,676评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,911评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,937评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,935评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,860评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,660评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,113评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,363评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,506评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,238评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,861评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,486评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,674评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,513评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,426评论 2 352

推荐阅读更多精彩内容

  • 这方面的文章很多,但是我感觉很多写的比较抽象,本文会通过举例更详细的解释。(此文面向的Vue新手们,如果你是个大牛...
    Ivy_2016阅读 15,385评论 8 64
  • 前言 Vue.js 的核心包括一套“响应式系统”。 “响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码...
    NARUTO_86阅读 37,471评论 8 86
  • Vue 数据响应依赖于Object.defineProperty,这也是Vue不支持IE8的原因。Vue通过设定对...
    Pamcore阅读 1,451评论 1 1
  • 不知道你有没有这样一种感觉,对一些事情,其实一开始接触的时候 ,是不怎么上心的。可是时间久了,你就会发现日子越来越...
    莫小逆阅读 732评论 0 5
  • 我与娃姥爷在育儿上,一直有着巨大的分歧。 从两个月左右开始,姥爷就认为孩子哭闹是因为想要人陪了,一定要不停的跟他说...
    张小猴_2016阅读 329评论 1 1