Vue 源码初探(一)响应式原理

728 阅读12分钟

Vue 源码初探(一)响应式原理

流程图

初始化Vue构造函数.png

环境搭建

mkdir vue
cd vue 
yarn init

安装babel

  1. 运行以下命令安装所需的包(package):
yarn add @babel/core @babel/cli @babel/preset-env -D

2.在项目的根目录下创建一个命名为 babel.config.json 的配置文件(需要 v7.8.0 或更高版本),并将以下内容复制到此文件中:

{
  "presets": [
    [
      "@babel/env",
      {
        "targets": {
          "edge": "17",
          "firefox": "60",
          "chrome": "67",
          "safari": "11.1"
        },
        "useBuiltIns": "usage",
        "corejs": "3.6.5"
      }
    ]
  ]
}

上述浏览器列表仅用于示例。请根据你所需要支持的浏览器进行调整。参见 此处 以了解 @babel/preset-env 可接受哪些参数。

上面内容来自于babel官网

  1. 运行此命令将 src 目录下的所有代码编译到 lib 目录:
./node_modules/.bin/babel src --out-dir lib

你可以利用 npm@5.2.0 所自带的 npm 包运行器将 ./node_modules/.bin/babel 命令缩短为 npx babel

现在我们执行以下操作

cd src 
touch index.js

//在index.js

function Vue() {
  console.log('hiiiihi')
}

export default Vue

执行

npx babel src --out-dir lib
Successfully compiled 1 file with Babel (493ms).

结果如下

1621735316535.png

rollup 搭建

yarn add rollup -D

添加配置文件rollup.config.js

import { babel } from '@rollup/plugin-babel';

export default {
  input: 'src/index.js',
  output: {
    file: 'dist/vue.js',
    //IIFE  自执行函数,   UMD = AMD + CMD
    format: 'umd',
    name: 'Vue', //umd模块需要配置name,会将导出的模式放到window上
    sourcemap: true
  },
  plugins: [
    babel({ 
      exclude: 'node_modules/**' //去掉node_modules下面的所有文件夹,不进行编译
    })
  ]
}

Vue 的初始化流程

下面我们正式开始编写,我们打开Vue.js的官网,把里面的内容抄过来,如下代码

<body>
  <div id="app">
    {{ message }}
  </div>
  <script src="./vue.js"></script>
  <!--<script src="https://cdn.jsdelivr.net/npm/vue"></script>-->
  <script>
    var app = new Vue({
      el: '#app',
      data: {
        message: 'Hello Vue!'
      }
    })
  </script>
</body>

接着我们把引入cdn的vue类库换成我们自己编写的代码。

//vue.js

function Vue(options) {
  console.log(options)
  // this._init(options)
}
Vue.prototype._init = function(){
  console.log('初始化Vue 流程入口')
}
export default Vue

1621735316535.png

现在就以上代码,我们用到的js的 构造函数 原型 (prototype) new。关于这些知识,在开始之前,我们先简单的瞄一眼 ^_*

  1. prototype 是构造函数的一个属性。
  2. 可以通过new 构造函数创建实例对象。
  3. 每个实例对象都可以调用构造函数上面prototype的方法或者属性。
//就上面代码来说。通过new Vue()  可以创建 app实例对象,
//在new Vue() 的时候可以通过this._init() 调用实例构造函数上面的方法

new 的过程

我们先来讲讲new Vue()的过程

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
  4. 执行构造函数内部的代码(给新对象添加属性)
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
function Fn(name, age) {
  this.name = name
  this.age = age
}

Fn.prototype.xxx = function(){}

function myNew(Obj) {
  //创建一个新的对象
  var a = {}
  var arg = Array.from(arguments)
  //取出第一个参数, 就是要实例化额对象的构造函数
  var constructor = arg.shift()
  //构造函数上面会有一个 prototype的属性存放着这类对象的共有属性和方法
  //这句话相当于 a.__proto__ = constructor.prototype
  Object.setPrototypeOf(a, constructor.prototype)
  // 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
  var result = constructor.apply(a, arg)
  // 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
  return typeof result === 'object' ? result : a
}


var  t =  myNew(Fn, '天明', 16)
console.log(t)

查看控制台输出

1621785075184.png

好了,看到这里我们再,继续写下面的代码。嗨,既然已经写到这块了,不妨把下面这段代码的内存图也一并奉上叭

var app = new Vue() 

1621866581371.png

推荐阅读,大家可以点击去看一看,并亲手实验一下里面的代码,相信会有不少的收获,比如笔者在看着几篇文章的同时,可能还会不经意的看到其他的一些问题、比如

1. Function.__proto__ === Function.prototype    // true

2. Math 没有prototype 属性

JS 的 new 到底是干什么的?

JS 中 proto 和 prototype 存在的意义是什么?

什么是 JS 原型链?

en .看了以上的东西,我们来做一道题吧。

写一下Foo和obj的原型链向上查找直到结束的整个过程?当给x赋值后,分别值为多少?

function Foo() {}; 
let obj = new Foo();
// obj 原型链
obj.__proto__ === Foo.prototype
Foo.prototype.__proto__ ===  Object.prototype
Object.prototype.__proto__ === null
// Foo 原型链
Foo.__proto__ === Function.prototype
Function.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

Function.prototype.x = 100;
Foo.x; // 100
obj.x; // undefined

//本题考查 实例的原型链和构造函数的原型链
//说明了构造函数的原型链和实例的原型链的开端是不一样的,虽然最后的结局是一样的。

好。我们继续上面的代码,为了分离代码,我们开始这样构建代码, 我们提取一个单独的文件将Vue当做函数的参数传递到函数内部,在函数内部给其原型上面扩展方法。

//init.js

export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    console.log('初始化Vue 流程入口')
    console.log(options)
  }
}

//index.js

import { initMixin } from "./init"

function Vue(options) {
  this._init(options)
}
initMixin(Vue)

export default Vue

Vue的对象劫持

上面我们写到给Vue原型添加_init方法,下面我们开始初始化Vue的状态 initState() 方法

import { initState } from "./state"

export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    //这里的 this 其实就是外面new 出来的实例
    const vm = this

    //把用户的选项放到vm 上,这样在其他的方法中都可以获取到了

    vm.$options = options  //为了后续方法,都可以获取到$options选项

    //options中包含了很多的选项  el data props 

    initState(vm)
  }
}

//state.js

我们把当前实例vm 传递给initState() 从中就可以获取到用户传递的配置信息,想想大家开始写Vue的时候,在new Vue() 这个构造函数的时候传入的这个对象,我们现在要做的就是,拿到这个对象里面的不同的key 比如 data 、props 、computed、methods、等等,对这些参数做一些处理,让其在某些时刻发生作用。下面我们开始处理data属性。

import { observe } from "./observe"
import { isFunction } from "./utils"

export function initState(vm) {
  const opts = vm.$options

  //如果用户传递了data属性
  if(opts.data) {
    initData(vm)
  }
}


function initData(vm) {
  let data = vm.$options.data
  //检测,用户传递的data的类型   如果是函数的话,需要取函数的返回值当做data  
  //用call 是为了获取vm 上面data 
  data = isFunction(data) ? data.call(vm) : data

  //数据合法,开始监测
  //需要将data 变成响应式的,用Object.defineProperty, 重写data中的所有属性

  observe(data)
}

现在我们已经确保data是一个对象了,{} 或者 [] ,现在我们姑且只认为data的类型只能是{},我们开始把data传入observe() 中让他具有响应式。所谓的响应式,目前我们先理解成,当我们写在data下面的属性在被获取、或者被设置的时候,可以告诉我们。用到的API,就是Object.defineProperty(),更多关于这个的讲解大家可以移步

MDN Object.defineProperty()

import { isObject } from "../utils";

//观察者对象
class Observer {
  constructor(data) {
    this.walk(data)
  }

  walk(data) {
    Object.keys(data).forEach((key)=>{
      let value = data[key]
      defineReactive(data, key, value)
    })
  }
}

/**
 * 
 * @param {当前对象} data 
 * @param {要监测的key} key 
 * @param {当前对象key的value, 被监测之前的值} value 
 */
function defineReactive(data, key, value){
  //如果当前对象的key 再是一个对象就进行深层监测
  observe(value)
  Object.defineProperty(data, key,{
    get() {
      return value
    },
    set(newValue) {
      //如果新值和老值相等 就不做处理
      if(value === newValue) return
      value = newValue
    }
  })
}


export function observe(data){
  //判断data 必须是一个对象
  if(!isObject(data)) {
    return
  }

  //开始观测 ,这里目前return 或者不return 都可以
  return new Observer(data)
}

我们在使用的地方打印当前new 出来的实例, 可以看到他们的每个属性都拥有了一个get 和set方法。

1622259247273.png

性能优化

写到这块,我们是不是可以想一想一些关于性能优化的一些技巧。

  1. 不要把所有的数据都写在data里面,比如一些不会对ui页面产生影响或者说是不用进行监测的数据。
  2. 不要写数据的时候层次过深,尽量扁平化数据。
  3. 不要频繁的获取数据,会触发多次get方法,造成一定的性能损耗。
  4. 如果数据不需要响应式,可以使用Object.freeze进行属性的冻结。

MDN Object.freeze

这里有可能你会看见几个名词、可枚举、可配置、可修改、后两者很好理解其作用和用途、但是前者、不知道大家看到这个名词的时候,有没有想过问什么或有这个属性的出现呢?他有什么作用呢?如果想知道请观众老爷移步

for in 和 for of 的区别?

数组的变化侦测

上面我们用defineReactive 对data下面的所有对象属性进行了观测,接下来我们给data里面写一个数组看看会发生什么?

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!',
    arr: [1, 2, 3]
  }
})

console.log(app)

1622303845079.png

可以看到数组的每一项都被进行了观测,我们写下以下代码,进行测试

//尝试调用app上面的$options 里面的data.arr[0]
console.log(app.$options.data.arr[0])

//同时我们在defineReactive get()里面打印一句话

function defineReactive(data, key, value){
  //如果当前对象的key 再是一个对象就进行深层监测
  observe(value)
  Object.defineProperty(data, key,{
    get() {
      console.log('call get')
      return value
    },
    set(newValue) {
      //如果新值和老值相等 就不做处理
      if(value === newValue) return
      value = newValue
    }
  })
}

我们可以看到,在我们读取属性的时候会进入get方法。

1622304678276.png

至于为什么Vue会选择重写数组方法,大家先没有必要较真,我在这边直接写我获取到的答案了,具体的大家有兴趣可以进入链接查看详细内容

主要有两点原因吧:

  1. Vue 的响应式是通过 Object.defineProperty() 实现的,这个 api 不能监听数组长度的变化,也就不能监听数组的新增。
  2. Vue 无法检测通过数组索引改变数组的操作,这不是 Object.defineProperty() api 的原因,而是 尤大认为性能消耗与带来的用户体验不成正比 ,对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如1000条、10000条。 那么就的给每一个数组的项进行设置setget方法进行劫持,这样性能会很差。

好,我们继续,开始数组的原数组的方法。

import { isArray, isObject } from "../utils";
import { arrayMethods } from "./array";

class Observer {
  constructor(data) {
     //给每一个监测的对象添加一个__ob__的属性指向自己,可以用来判断当前对象是否已经监测过了。
    // data.__ob__ = this
    //这个属性不能在循环的时候被遍历到
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false
    })
    if(isArray(data)) {
      //如果当前dada的属性的类型是数组,就重写当前数组的原型方法
      data.__proto__ = arrayMethods
    }else {
      this.walk(data)
    }
  }
}

我们再来验证一下data.__proto__ = arrayMethods 这句话,我们做以下测试,可以看见用户传递的data里面的数组的__proto__指向了我们重写的那个原型对象。

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!',
    arr: [1, 2, 3]
  }
})
console.log(app)

class Observer {
  constructor(data) {
    //给每一个监测的对象添加一个__ob__的属性指向自己,可以用来判断当前对象是否已经监测过了。
    // data.__ob__ = this
    //这个属性不能在循环的时候被遍历到
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false
    })
    if(isArray(data)) {
      console.log(data)
      //如果当前dada的属性的类型是数组,就重写当前数组的原型方法
      data.__proto__ = arrayMethods
      this.observeArray(data)
    }else {
      this.walk(data)
    }
  }

1622381406202.png

//获取数组老的原型对象
let oldArrayProtoMethods = Array.prototype

//用数组原始的原型对象,创建一个新的对象
//作用就是 arrayMethods.__proto__ === oldArrayProtoMethods
export let arrayMethods = Object.create(oldArrayProtoMethods)

//把数组的可以改变原始数组的方法进行重写
let methods = [
  'push',
  'pop',
  'splice',
  'sort',
  'shift',
  'unshift',
  'reverse'
]


methods.forEach((method)=>{
  //给当前对象添加上要被重写的属性方法,对象查找属性是先从自身查找,然后再到原型链上去查找
  //这样既可以保证改变原数组的方法被重写,又可以在原型上面查找到 concat、slice。等方法
  //arrayMethods.push = function(){}
  //arrayMethods.pop = function(){}
  //arrayMethods.splice = function(){}
  //...
  arrayMethods[method] = function(...args){ 
    console.log('调用了数组被重写的方法')
    //先调用数组的原始方法,完成用户的当前操作
    const result = oldArrayProtoMethods[method].apply(this, args)
    const ob = this.__ob__

    let inserted
    //在新增数组的时候,对新增的内容进行响应式。
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break;
      case 'splice':
        // agrs[2] 表示新增的元素
        inserted = args[2]
        break;
    }

    if(inserted) ob.observeArray(inserted)
    return result
  }
})

测试

1622305640758.png

1622305666857.png

这样我们就又换了一种方式实现了,data里面数组的监听,是只能通过数组的这7个api调用的形式才可以触发监听。

Vue 实例取值的代理

一开始vm实例和用户传递的data是没有关系的,为了外面的vm实例能获取到data 就给vm添加一个属性_data,
这样就可以用 vm._data进行数据的访问了,但是Vue的作者希望可用直接通过vm.message访问到传递给构造函
数的参数里面的data的属性,我们就要再做一层代理

在没把data代理给vm实例之前,我们打印一下app实例

console.log(app)

1622339701881.png

在添加完代理之后我们再打印, 里面的data上的属性直接可以在app实例上面找见了。

function proxy(vm, source, key) {
  Object.defineProperty(vm, key, {
    get(){
      console.log('get-------')
      return vm[source][key]
    },
    set(newValue){
      vm[source][key] = newValue
    }
  })
}

function initData(vm) {
  let data = vm.$options.data
  //检测,用户传递的data的类型   如果是函数的话,需要取函数的返回值当做data  
  //用call 是为了获取vm 上面data 
  data = vm._data = isFunction(data) ? data.call(vm) : data

  //数据合法,开始监测
  //需要将data 变成响应式的,用Object.defineProperty, 重写data中的所有属性

  observe(data)
  //把_data上面的竖向全部代理给vm 实例
  for(let key in data) {
    proxy(vm, '_data', key)
  }
}

1622339810269.png

Vue 的实现流程

  1. new Vue的时候会调用_init 方法,进行初始化操作。
  2. 会将用户的选项放到 vm.$options 上。
  3. 搜索当前用户传递的属性上面检查有没有data 数据 ,有的话 进行initState
  4. 有data 判断data 是不是一个函数, 如果是函数取返回值 initData
  5. observe 去观测data中的数据
  6. 为了让实例vm 也能取到data中的数据 设置vm._data = data 这样用户就能够取到data了。vm. _data
  7. 用户觉得vm. _data麻烦,想直接vm.message,Vue就 对vm._data 进行数据代理。
  8. 如果更新对象不存在的属性,会导致视图不更新,如果是更新数组索引和长度也不会触发视图更新。
  9. 如果是替换成新的对象,新对象会被进行劫持,如果是数组存放新内容,既调用push unshift splice 新增的内容也会被劫持。通过__ob__属性标识当前对象有没有被劫持过(在被Vue劫持过的属性会有一个__ob__的属性)。

系列文章链接(持续更新中...)

Vue 源码初探(一)响应式原理

Vue 源码初探(二)模板编译

Vue 源码初探(三)单组件挂载(渲染)

Vue 源码初探(四)依赖(对象)收集的过程

Vue 源码初探(五)对象异步更新nextTick()