手写 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 对象,基本也就不会再遇到修改数据无法触发响应式的问题了。

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

相关推荐
知识分享小能手5 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
我命由我123458 小时前
前端开发问题:SyntaxError: “undefined“ is not valid JSON
开发语言·前端·javascript·vue.js·json·ecmascript·js
海天胜景8 小时前
vue3 当前页面方法暴露
前端·javascript·vue.js
天天向上102410 小时前
Vue 配置打包后可编辑的变量
前端·javascript·vue.js
芬兰y10 小时前
VUE 带有搜索功能的穿梭框(简单demo)
前端·javascript·vue.js
好果不榨汁10 小时前
qiankun 路由选择不同模式如何书写不同的配置
前端·vue.js
写不出来就跑路11 小时前
基于 Vue 3 的智能聊天界面实现:从 UI 到流式响应全解析
前端·vue.js·ui
1undefined212 小时前
element中的Table改造成虚拟列表,并封装成hooks
前端·javascript·vue.js
paopaokaka_luck12 小时前
基于SpringBoot+Vue的非遗文化传承管理系统(websocket即时通讯、协同过滤算法、支付宝沙盒支付、可分享链接、功能量非常大)
java·数据库·vue.js·spring boot·后端·spring·小程序
用户38022585982413 小时前
vue3源码解析:依赖收集
前端·vue.js