Vue2源码:响应式(一)

197 阅读9分钟

本文地址:www.kilig.xyz/blog?id=687…

之前一直想梳理梳理Vue的源码(之所以梳理Vue源码而不是其他的,一是开发主要用的还是Vue,对内部的一些原理也接触过一些,但都是碎片化的,不够系统,另外还是因为Vue的源码阅读难度相对来说还是低一点,也有很多可以参考的文章文档),迟迟未动笔,趁着最近感觉学习有点无从下手,干脆把这件事做了,毕竟,Vue3都出了

正文

如何阅读Vue源码,网上相关的文章和建议很多,但不一定适合自己,打算将这个系列以通过阅读源码实现一个简单版Vue2的方式长期写下去,后续如果有什么更好的方式,再去变通。

为什么直接从响应式部分的代码入手,一方面Vue的响应式已经老生常谈,相关的文章很多,但很多细节问题有很多分歧,譬如为何 数组通过索引更新数据视图不更新等等,有些东西自己不去实践,谈起来总会底气不足,另一方面的,自己确实也没有什么阅读的思路,从最熟悉的入手,效率也会高一点。

项目环境

打包器,Vue使用的是 rollup ,具体的用法配置之类的就不在这里说了,网上也有很多介绍的文章,总之它就是一个JS 的模块打包器,但更适合应用于类库这种场景。

类型检查,Vue采用了facebook的 Flow 来做静态类型检查。之所以采用 Flow 而非 TypeScript,大概Flow更符合Vue2轻便的特性,同时Flow 也更容易迁移。类型检查这部分内容在阅读过程中不去考虑,尽量去关注一些核心的代码逻辑。

初始化

Vue初始化很简单,就用官网入门教程里的例子 ,简洁明了。

用法如下,传入参数,实例化一个Vue

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<body>
    <div id="app">
        {{ message }}
    </div>
</body>
<script>
    var app = new Vue({
        el: '#app',
        data: {
            message: 'Hello Vue!'
        }
    })
</script>
</html>

 

控制台打印一下上述代码初始化的全局的Vue

这里主要关注一下Vue.$options,这个就是我们实例化Vue时候的传参

入口文件  src/core/instance/index.js


import { initMixin } from './init'
// 声明 构造函数Vue
function Vue (options) {
    // 需通过new 来实例化,否则抛出错误
    if ( !(this instanceof Vue )) {
        throw('Vue is a constructor and should be called with the `new` keyword')
    }
    /* 
        初始化
        Vue.prototype._init 方法定义在 initMixin中
    */
    this._init(options)
}
initMixin(Vue)
export default Vue

这里的代码没有什么好说的,但是在看这部分内容的代码的时候因为这个 _init 方法对实例化对象的流程产生了一些疑问,做了一个思考,可以稍记录一下

这里的 this 是new Vue后的实例本身,那就意味着构造函数在执行时,new的实例已经声明并且分配空间了,而构造函数只是负责为该实例属性做一些赋值操作。

这个属于基础知识,但是之前没有想过这个问题,想当然觉得实例化的流程应该是

调用执行构造函数 =>声明及初始化=>生成实例

回到正题

响应式实现

数据劫持

开始进入数据劫持监听实现

//  src/core/instance/init.js
import { initState } from './state'
export function initMixin( Vue ) {
    Vue.prototype._init = function (options) {
        const vm = this
        vm.$options = options
        // 初始化数据
        initState(vm)
    }
}

 

下面来完成 初始化data 这一步

初始化data,初始化了什么

  • 获取 data

获取我们传给构造函数的data

  • 将data 转化成对象
    一般options中 data定义有两种方式:对象字面量和函数(使用组件时,data必须是函数)
       
     var app = new Vue({
            el: '#app',
            //对象字面量
            data: {
                message: 'Hello Vue!'
            }
        })
        var app = new Vue({
            el: '#app',
            // 函数
            data (){
                return {
                    message: 'Hello Vue!'
                }
            }
        })
    

    至于这里使用组件时,data为什么必须是函数,先不讨论,等后面涉及到的时候再去讨论

  • 劫持data,并对data进行监听



这个过程的代码如下

//  src/core/instance/state.js
import { observe } from '../observe/index'
export function initState(vm) {
    const opts = vm.$options
    if (opts.data) {
         // 初始化 data
       initData(vm)
    }
}
function initData (vm) {
    // 将 data 添加到 vm 上,可通过 vm._data 修改 data
    let data = vm.$options.data
    // 将 data 存储为对象
    data = vm._data = typeof data === 'function'
    ? data.call(vm, vm)
    : data || {}
    // 劫持监听 data 对象 (Vue的响应式原理)
    observe(data)
}

 

实现 observe(使用 Object.defineProperty)

// src/core/observe/index.js
export function observe (){
    return new Observe()
}
class Observe{
    constructor(value){
        // 遍历所有属性
        this.walk(value)
    }
    // 遍历所有属性,并为这些属性添加 getter 和 setter
    walk(obj){
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            // 将该对象的属性定义为响应式数据 (getter,setter)
            defineReactive(obj,keys[i])
        }
    }
}
function defineReactive(obj,key) {
    let val = obj[key]
    // 多层嵌套对象默认深层监听(即递归劫持嵌套对象的所有属性)
    observe(val)
    Object.defineProperty(obj,key,{
        enumerable: true,
        configurable: true,
        get(){
            console.log(`获取了数据${key}:`,val)
            return val
        },
        set(newVal){
            console.log(`更改了数据${key}:`,val)
            if (newVal === val) {
                return
            }else{
                val = newVal
            }
            // 继续劫持数据(当改变该属性的类型,赋值为对象时)
            observe(newVal)
        }
    })
}

 

以上代码,完成了简单的数据类型的劫持。

对数组类型的特殊处理

为什么是简单的数据类型的劫持呢,这里有一个问题(因为刚开始看源码时没有注意到这个细节,所以只简化出了一个这样简单的数据劫持的逻辑),在Vue开发过程中,经常会涉及到属性类型数组 的响应式应用场景。Vue监测不到通过数组索引的方式改变数据的行为,而我们上述实现数据劫持的代码,是可以劫持数组类型的,我们可以写个例子测试一下

function observeArr(obj,key,value) {
	let val = value
	Object.defineProperty(obj, key, {
	    enumerable: true,
	    configurable: true,
	    get() {
	        console.log('读取了值:', value)
	        return value
	    },
	    set(newVal) {
	        if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            } else {
                console.log("更改了值:", newVal)
                value = newVal
            }
        }
    })
}
observeArr(testArr, '2', 9)

所以,Object.defineProperty 是可以劫持类型为 Array 的数据

很明显,Vue对 数组 类型的数据做了特殊处理,并且并未对其设置 setter/getter。为什么做这种处理?

假设我们根据业务需求,定义了这样的 data 类型

data: {
	table1: [ 
	    [   1,
	        [ [2,3,4,5,6],[ [8,9,10], [10,11,12] ] ],
	        13,
	        14
	    ],
	    [15,16]
	]}

数组嵌套数组嵌套数组,无限套娃。或者数组的长度很大(这种场景在开发中会遇到较多),又或者无限套娃的数组的长度很大,多重爆炸。所以通过索引劫持数组是极其极其极其耗费性能(不但要递归遍历数组,还要开辟空间进行劫持)的一件事。所以Vue对数组做了特殊处理,官方说明只能通过以下方法触发响应式

[  'push',  'pop',  'shift',  'unshift',  'splice',  'sort',  'reverse']

为什么这些方法可以触发更新,因为Vue内部重写了这些方法,当调用数组的该方法时,会调用一些方法来实现响应式

但是,并不是只有这些方法会触发响应式(视图更新),以下结构的数组,可以通过直接改变值触发更新

data() {
    return {
      test: [
        {a:1111}
      ]
    }
  }
// 可以通过  test[0].a = newVal  更新视图

之前开发可能没有注意到这个问题。

理清了逻辑,接下来,开始处理属性类型为数组时的数据的劫持的流程

 

先实现 当 数组 的项为object 时的数据劫持。

遍历数组,若数组的某项值为类型为object,添加至响应式数据

这个类型的判断 其实是在 Observe 这个类的 walk 方法中完成的。基本类型 调用Object.keys(),返回空数组,自然不会执行 defineReactive方法将数据添加至响应式数据

另外,在observe函数中进行了一层 类型为 undefinded 和 null 的判断,因为 Object.keys() 遇到 value值为 undefined 和 null 时会报错

在实例化Observe时,判断数据类型,并调用对应的处理方法

// src/core/observe/index.js
import { isObject } from './../../share/util.js'
export function observe (value){
    // 需判断 value 是否为undefined或者null 
    if (!isObject(value)) {
        return
    }
    return new Observe(value)
}
class Observe{
    constructor(value){
        // 判断数据是否为数组
        if (Array.isArray(value)) {
        // 调用数据类型为数组时的处理方法
        this.observeArray(value)
        } else {
        // 遍历所有属性
        this.walk(value)
        }
    }
    // 遍历所有属性,并为这些属性添加 getter 和 setter
    walk(obj){
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            // 将该对象的属性定义为响应式数据 (getter,setter)
            defineReactive(obj,keys[i])
        }
    }
    observeArray (items) {
        for (let i = 0, l = items.length; i < l; i++) {
          observe(items[i])
        }
    }
}
function defineReactive(obj,key) {
    let val = obj[key]
    // 多层嵌套对象默认深层监听(即递归劫持嵌套对象的所有属性)
    observe(val)
    Object.defineProperty(obj,key,{
        enumerable: true,
        configurable: true,
        get(){
            console.log(`获取了数据${key}:`,val)
            return val
        },
        set(newVal){
            console.log(`更改了数据${key}:`,val)
            if (newVal === val) {
                return
            }else{
                val = newVal
            }
            // 继续劫持数据(当改变该属性的类型,赋值为对象时)
            observe(newVal)
        }
    })
}

 

//src/share/util.js


export function isObject (obj){
return obj !== null && typeof obj === 'object'
}


下面实现重写数组方法(当调用数组的需要重写的方法时也通知数据变更)

  • 当类型为数组时,重写该对象的 __proto__ ,将我们上文提及的需重写的改变数组本身的七个方法添加至 __proto__
  • 重写的这个__proto__ 属性为一个对象,该对象的__proto__ 需继续指向 Array,继承数组的其他方法
  • 对于向数组添加元素的方法,需做把添加的数据继续添加至响应式数据的处理

 

这里要特别说明一下,重写  __proto__属性需要通过 Object.defineProperty() 将 __proto__ 设置为不可枚举 属性,如果直接通过 . 的方式,会在进入后面的循环部分时,无限递归

(是的,刚开始当我试着用   .   简化代码来重写 __proto__ 属性时,发生了空间溢出,当时脑子短路,想了很久为什么会这样,并且也没有在网上看到相关的说明)

// src/core/observe/index.js
import { isObject } from './../../share/util.js'
import { def } from '../util/lang.js'
import { arrayMethods } from './array'
export function observe (value){
    // 需判断 value 是否为undefined或者null 
    if (!isObject(value)) {
        return
    }
    return new Observe(value)
}
class Observe{
    constructor(value){
        // 将ob_属性添加至实例
        def(value, '__ob__', this)
        // 判断数据是否为数组
        if (Array.isArray(value)) {
            value.__proto__ = arrayMethods
            // 调用数据类型为数组时的处理方法
            this.observeArray(value)
        } else {
            // 遍历所有属性
            this.walk(value)
        }
    }
    // 遍历所有属性,并为这些属性添加 getter 和 setter
    walk(obj){
        /*Object.keys()的传参为 undefined或者null 时会报错
            在observe函数中已判断过,所以不用再去判断
        */
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            // 将该对象的属性定义为响应式数据 (getter,setter)
            defineReactive(obj,keys[i])
        }
    }
    observeArray (items) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
}
function defineReactive(obj,key) {
    let val = obj[key]
    // 多层嵌套对象默认深层监听(即递归劫持嵌套对象的所有属性)
    observe(val)
    Object.defineProperty(obj,key,{
        enumerable: true,
        configurable: true,
        get(){
            console.log(`获取了数据${key}:`,val)
            return val
        },
        set(newVal){
            console.log(`更改了数据${key}:`,val)
            if (newVal === val) {
                return
            }else{
                val = newVal
            }
            // 继续劫持数据(当改变该属性的类型,赋值为对象时)
            observe(newVal)
        }
    })
}

 

// src/core/observe/array.js
export const arrayMethods = Object.create(Array.prototype)
// 重写以下方法 (这些方法均会改变数组本身)
const methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
]
// 将methodsToPatch里的方法依次添加至 arrayMethods 这个对象
methodsToPatch.forEach(function (method) {
    // 获取数组原来的该方法的实现
    const original = Array.prototype[method]
    arrayMethods[method] = function (...args) {
        console.log(ob)
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                //slice方法第二个参数为添加的元素
                inserted = args.slice(2)
                break
        } //以上方法均会向数组添加元素,新添加的元素需添加至响应式数据
        // 当向数组插入新值时,继续将数据添加至响应式数据
        if (inserted) ob.observeArray(inserted)
        return result
    }
})

 

响应式部分中,数据的劫持的代码基本就是这些,后面还是先看一看视图层部分。内容太多,下篇再写吧