手写 Vue2.0 响应式源码 - 理解为何有时修改数据无法触发响应式更新

前言

我们用 Vue2 开发时可能经常会苦恼:为什么有时候明明 修改了对象上的某个属性值 却没有触发响应式更新,特别是当对象嵌套层级比较多时更加是一头雾水。当遇到这种情况的时候很可能会造成的一个现象就是 $set方法的滥用。现在,我们通过手写 Vue2 源码的方式来理解 Vue2 的响应式系统,从根本上解决这个问题。

正文

准备工作:声明 data 对象和渲染函数

js 复制代码
//页面结构
<h1></h1>

// 初始化 data 对象
const data = {
    message: 'Hello World!',
    obj: {
        foo: 'aaa',
        bar: 'bbb'
    },
    num: 123,
    list: ['A', 'B', { c: 'C' }]
}

//渲染函数
function render() {
    document.querySelector('h1').textContent = JSON.stringify(data)
}

render() // 先渲染一遍

最简单的响应式

响应式的核心是通过 Object.defineProperty 方法重写 data 对象上属性的存取函数来监听属性的读写,并在读写时执行相应的逻辑(更新视图、收集依赖等...)

js 复制代码
// 遍历 data 对象的所有属性,并重新定义属性的存值函数和取值函数
const keys = Object.keys(data)
for(let i = 0; i < keys.length; i++) {
  const key = keys[i]
  let val = data[key]
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    // 属性的取值函数
    get() {
      return val
    },
    // 属性的存值函数
    set(newVal) {
      if(val === newVal) return
      val = newVal
      // 当修改属性值时触发渲染函数
      render()
    }
  })
}

思考:

  • 因为只能对 data 对象上已有的属性进行重写,所以当新增或删除属性时无法通过 set 函数触发视图更新
  • 只遍历了 data 对象上第一层属性,当修改嵌套对象的属性值时无法触发视图更新,如:data.obj.foo = 'AAA' 就无法触发

通过递归的方式层层遍历改善响应式

js 复制代码
// 递归的话就必须有函数,所以要对上面的逻辑进行封装
function observe(data) {
    // 判断 data 为对象或数组
    if(Object.prototype.toString.call(data) === '[object Object]' || Array.isArray(data)) {
        const keys = Object.keys(data)
        for(let i = 0; i < keys.length; i++) {
            const key = keys[i]
            let val = data[key]
            // 递归调用observe
            observe(val)
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get() {
                    // val 变量被闭包保存了
                    return val
                },
                set(newVal) {
                    if(val === newVal) return
                    val = newVal
                    // 将得到的新值也要进行响应式监听
                    observe(val)
                    render()
                }
          })
        }
    }
}

思考 (重要)

  • 这种方式无论嵌套对象的层级有多深,都能够将所有子对象的属性进行响应式监听,但是!!!当你通过 data.obj.xx = xx 这种方式为 data.obj 添加原本不存在的属性时,对这个 xx 属性的读写是无法触发响应式的!!!!所以你应该先把数据处理好后再赋值给 data 上已存在的属性 例子:
js 复制代码
const data = {
    a: 'some value'
}
observe(data)
let obj = {
  x: 'x',
  outer: {
    y: 'y',
    inner: {
      z: 'z'
    }
  }
}
data.a = obj // data.a.x、 data.outer.y、data.outer.inner.z 都能触发响应式
data.a = { food: 'pizza', drink: 'milk tea' } // data.a.food、data.a.drink 都能触发响应式
data.a.location = 'cinema' // 无法触发响应式,因为 data.a 上原本不存在 location属性
                           // 没有通过 Object.defineProperty 监听 location 的读写
  • 这种递归监听的方式虽然无论属性值是对象还是数组都进行了观测,但假如数组中包含成千上万个元素,为每一个数组下标都添加 getset 方法对于性能来说是承担不起的,所以数组需要进行额外的响应式处理。
  • 数组类型的数据通过 push、pop 等方法改变成员时无法触发响应式,所以数组需要额外的响应式监听处理

数组的响应式观测

为了更贴近 Vue2 的源码,我们需要对上面的逻辑进行进一步封装,并对数组的响应式监听做特殊处理

js 复制代码
function observe(value) {
  if (Object.prototype.toString.call(value) === '[object Object]' || Array.isArray(value)) {
    // 引入 Observer 类来帮我们处理响应式
    return new Observer(value)
  }
}

// observer 类
class Observer {
  constructor(value) {
     // 先将 Observer 实例定义到 value 上,后面数组触发响应式时会用到
     Object.defineProperty(value, '__ob__', {
       value: this,
       enumerable: false,  // 设置属性不可遍历,这样 Object.keys、in 循环就读取不到该属性
       configurable: true,
       writable: true
    })
    if (Array.isArray(value)) {
      // 通过替换原型链来改写数组的 push、pop 等方法
      value.__proto__ = arrayMethods  // arrayMethods 详细定义在下方代码中
      this.observeArray(value)
    } else {
      const keys = Object.keys(value)
      for (let i = 0; i < keys.length; i++) {
        const key = keys[i]
        defineReactive(value, key)
      }
    }
  }
  // 对数组的元素进行响应式监听
  observeArray(value) {
    for (let i = 0; i < value.length; i++) {
      observe(value[i])
    }
  }
}

// 重写对象属性的存取函数
function defineReactive(obj, key) {
  let val = obj[key]
  observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      return val
    },
    set(newVal) {
      if (val === newVal) return
      val = newVal
      observe(val)
      render()
    }
  })
}

const arrayProto = Array.prototype
// 让 arrayMethods 继承数组的方法 
const arrayMethods = Object.create(arrayProto)
// 要改写的方法列表
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(method => {
    const origin = arrayProto[method]
    arrayMethods[method] = function (...args) {
        const ob = this.__ob__
        const result = origin.apply(this, args)
        // 被插入的数据
        let inserted
        switch (method) {
          case 'push':
          case 'unshift':
            inserted = args
            break;
          case 'splice':
            inserted = args.slice(2)
            break
        }
        // 为数组中所有新增的元素监听响应式
        if (inserted) ob.observeArray(inserted)
        // 触发响应式
        render()
        return result
    }
})

思考 (重要)

  • 现在通过调用数组方法 pushpop 改变数组成员可以触发响应式,但是通过下标直接修改数组元素还是不能触发响应式,如:data.list[0] = 'abc'
  • 假如数组元素是对象,对元素上原本不存在的属性值进行读写不能触发响应式。我们应该先将数组中的每个元素格式化成我们想要的数据结构后,再赋值给响应式对象上的属性。例如:
js 复制代码
const data = {
    list: []
}
observe(data)
data.list = [{id: 1, title: 'first'}, {id: 2, title: 'second'}]
data.list[0].id += 2 //可以触发响应式
data.list[1].content = '222' // 无法触发响应式

// 假如我们从接口获取到数组 list,并想要为 list 中每个元素添加上 disabled 属性值时

// 错误做法
const res = [{id: 1, title: 'first'}, {id: 2, title: 'second'}]
data.list = res
data.list.forEach(item => item.disabled = false) // 修改 disabled 无法触发响应式

// 正确做法
const res = [{id: 1, title: 'first'}, {id: 2, title: 'second'}]
res.forEach(item => item.disabled = false)
data.list = res // 修改 disabled 可以触发响应式

总结

我觉得对于我们这种 Vue2 框架使用者来说,最重要的就是理解它的响应式系统。如果把 Vue2 比作一辆汽车,那么响应式系统就是它的发动机,只要我们以正确的方式踩下油门(触发响应式),那么框架就会帮助我们做好一切工作(如更新视图等等...)。 如果能够理解以上所有的代码 (当然,源码中的响应式逻辑复杂得多,我这里保留了其中我觉得最核心的部分),在开发时通过正确的方式改写实例的 data 对象,基本也就不会再遇到修改数据无法触发响应式的问题了。

最后的最后,感谢大家观看这边文章!!!

相关推荐
轻口味29 分钟前
Vue 3 新特性与最佳实践之Vue 3 最佳实践总结与开发技巧
vue.js
马玉霞29 分钟前
vue3的生命周期
vue.js
汪洪墩1 小时前
使用Mars3d加载热力图的时候,出现阴影碎片
开发语言·前端·javascript·vue.js·cesium
夏沫mds2 小时前
Hyperledger Fabric食品溯源
运维·vue.js·go·vue·区块链·gin·fabric
Humbunklung3 小时前
DeepSeek辅助写一个Vue3页面
前端·javascript·vue.js
Hilaku5 小时前
尤雨溪都没告诉你的 setup() 技巧
前端·javascript·vue.js
Hilaku6 小时前
Vue 项目不要再用 Pinia 了,组合式 API + ref() 才是王道
前端·javascript·vue.js
bitbitDown7 小时前
我朋友小伍的 Pinia 困惑:把所有产品数据放一起不行吗?
前端·javascript·vue.js
bo521007 小时前
Element Plus 虚拟树形组件子节点点击导致勾选框自动勾选问题排查与解决
vue.js·element