为了Vue源码(2):Vue 响应式原理实现,对象属性劫持

38 阅读3分钟

在上一篇已经讲了使用Rollup搭建开发环境,从本篇开始就可以手撸vue源码了。话不多说从对象属性劫持开始。

最开始学习Vue的时候,先在页面引入vue.js文件,然后创建Vue实例,并接收参数。

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="./vue.js"></script>
  <script>
    const vm = new Vue( // 创建 Vue 的一个实例
      { // 参数
        data() {
          return {
            a: '1',
            b: 2,
            c: 3
          }
        },
        computed: {
          addbc() {
            return b + c;
          }
        },
        methods: {
          addab() {
            return a + b;
          }
        }
      }
    )
  </script>
</body>
</html>

由此看来,vue.js无非是创建了一个Vue的构造函数,且这个构造函数就是对接收的参数做一系列操作。

所以,在这里我们先写一个Vue的构造函数:

// index.js 入口文件
function Vue(options) { // options就是接收的参数,即用户的选项
  // 一系列操作
}
export default Vue

然后我们开始顺一顺构造函数的一系列操作是怎么操作的。首先得初始化操作,也就是把用户的选项挂载到当前实例上。

为了更好的对比,先贴出没有挂载之前的实例(如果不做任何初始化操作,当前实例就什么都没有):

image-20221123151413989.png

// index.js 入口文件
import { initMixin } from "./init"
​
​
function Vue(options) { // options就是用户的选项,new Vue 的时候就会执行这个 Vue 的构造函数
  this._init(options) // 初始化操作
}
initMixin(Vue); // 给Vue扩展init方法export default Vue
// init.js 初始化操作
export function initMixin(Vue) { // 给Vue增加init方法的
  Vue.prototype._init = function(options) { // 用于初始化操作
    const vm = this;
    vm.$options = options; // 将用户的选项挂载到实例上
  }
}

挂载之后的实例,可以看到用户传的参数都放到实例的$options内:

image-20221123152225364.png

挂载完用户的选项之后,接下来就是初始化状态(就是data、props、computed、watch、methods等的初始化)。我们知道Vue的核心特点是响应式的数据变化,也就是数据的取值更改值我们要监控到,然后更新视图,所以本篇先撸一撸data的初始化状态。

通常data可能是个方法,也可能是个对象,所以要想拿到data需要判断下data = typeof data === 'function' ? data.call(vm) : data,然后丢到实例vm上,方便存取。这个时候存取都在vm._data上存取,通常我们使用的时候都直接在vm上存取,所以需要将vm._datavm来代理。

// init.js 初始化操作
import { initState  } from "./state";
​
export function initMixin(Vue) { // 给Vue增加init方法的
  Vue.prototype._init = function(options) { // 用于初始化操作
    const vm = this;
    vm.$options = options; // 将用户的选项挂载到实例上
​
    // 初始化状态(data、computed、watch、props、methods等等, 本篇以data为例)
    initState(vm);
  }
}
// observe/state.js
import { observe } from "./observe/index";
​
export function initState(vm) {
  const opts = vm.$options; // 获取所有的选项
  if(opts.data) {
    initData(vm);
  }
}
​
function proxy(vm, target, key) {
  Object.defineProperty(vm, key, { // vm.age
    get() {
      return vm[target][key]; // vm._data.name
    },
    set(newVal) {
      vm[target][key] = newVal;
    }
  })
}
​
function initData(vm) {
  let data = vm.$options.data; // data可能是函数和对象
  data = typeof data === 'function' ? data.call(vm) : data; // this指向当前实例
​
  vm._data = data; // 将返回的对象放到实例vm上
  // 对数据进行劫持 vue2 里采用了一个 api defineProperty
  observe(data); // 观测data,主要是为了实现响应式
​
  // 将vm._data 用vm来代理(取_data上的属性就可以直接vm.取)
  for(let key in data) {
    proxy(vm, '_data', key);
  }
​
}

代理完了之后就到了最重要的实现响应式,想要实现响应式,就需要观测(监听)data内的所有属性(包括对象的所有属性。

// ./observe/index.js
class Observer {
  constructor(data) {
    // Object.defineProperty只能劫持已经存在的属性,后增的或者删除的Object.defineProperty是不知道的(vue里面会为此单独写一些api:$set $delete)
    this.walk(data);
​
  }
  walk(data) { // 循环对象 对属性一次劫持
    // “重新定义”属性 性能差
    Object.keys(data).forEach(key => defineReactive(data, key, data[key])) // defineReactive 响应式设置
  }
}
​
export function defineReactive(target, key, value) { // 闭包 属性劫持
  observe(value); // 对所有的对象都进行属性劫持
  Object.defineProperty(target, key, {
    get() { // 取值的时候执行get
      console.log("用户取值了")
      return value
    },
    set(newValue) { // 修改的时候执行set
      if(newValue === value) return
      console.log("用户设置值了")
      value = newValue
    }
  })
}
​
export function observe(data) {
  // 对这个对象进行劫持,所以需要判断下data是否是个对象,是对象才能劫持
  if(typeof data != 'object' || data == null) {
     return; // 只对对象进行劫持
  }
  
  // 如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过)
  return new Observer(data);
}