vue2源码学习--01数据响应式

141 阅读6分钟
前言
  • 虽然工作中经常用vue,但对核心原理的理解仅存在一些面试八股文,为加深理解开始手写源码。
  • 本系列目标是学习vue2源码,基本手写实现核心api并打包出能使用的vuejs文件
1. 构建工具选择rollup具体配置文件如下 rollup.config.js
import babel from 'rollup-plugin-babel'
import resolve from '@rollup/plugin-node-resolve'
 export default {
    input: './src/index.js',
    output: {
        file: './dist/vue.js',
        name: 'Vue',
        format: 'umd', // esm es6 commonjs iife自执行函数 umd统一模块规范
        sourcemap: true
    },
    plugins: [
        babel({
            exclude: 'node_modules/**'
        }),
        resolve()
    ]
}

packge.json 各依赖版本

{
  "name": "study",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "rollup -cw --bundleConfigAsCjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.22.5",
    "@babel/preset-env": "^7.22.5",
    "rollup": "^3.25.2",
    "rollup-plugin-babel": "^4.4.0"
  },
  "dependencies": {
    "@rollup/plugin-node-resolve": "^15.1.0"
  }
}

2.正式开始

src目录下新建index.js作为入口 dist目录或者随便其他地方新建html文件 引入打包的vue.js 方便我们调试

1、首先vue2没有采用class 而是采用构造函数,为了方便解耦_init是通过 init.js内导出的initMixin方法注入的
import { initMixin } from "./init";
function Vue(options) {
    this._init(options)
}
initMixin(Vue)
export default Vue
2、新建init.js 初始化时在vue的原型上加_init方法,该方法调用initState方法将为data上的属性进行劫持
import {initState} from './state';
export function initMixin(Vue) {
    Vue.prototype._init = function(options) {
        const vm = this;
        vm.$options = options(此处先这么写不考虑mixin,后续再修改)
        initState(vm);
    }
}
3、新建state.js并完善initState方法
import { observe } from "./observe/index";
export function initState(vm) {
    const opts = vm.$options; // 获取所有选项,目前先完成data
    if(opts.data) {
        initData(vm)
    }
}
function initData(vm) {
    let data = vm.$options.data
    data = typeof data === 'function'? data.call(vm): data
    vm._data = data
    // 数据劫持
    observe(data)
    // 将vm._data 用vm来代理 
    for (let key in data) {
    // 因为属性都写再data里 访问的时候应该是this.data.属性
    // 为方便我们使用vue进行第一次劫持将data上的属性通过this直接访问
        proxy(vm, '_data', key)
    }
}
function proxy (vm, target, key) {
    Object.defineProperty(vm, key, {
        get() {
            return vm[target][key]
        },
        set(newValue) {
            vm[target][key] = newValue
        }
    })
}
4、实现observe

新建observe文件夹内部新建index.js
这块涉及的东西很多 包括对数组的处理 dep类 watcher类 所以建一个文件夹 方便管理

// 两个问题 1、如果传过来的不是对象或者是个空直接return
// 2、如果已经代理过了就不要进行重复代理(此处关于__ob__先不写,__ob__用处很大 不单单用在此处)
export function observe(data) {
    // 对这个对象进行劫持
    if(typeof data !== 'object' || data === null) {
        return;
    }
    // 如果被劫持,就不需要再劫持,(判断是否已被劫持)
    return new Observer(data)
}

写Observer类
先不考虑数组,逻辑很简单就是循环每个属性,循环执行defineReactive,入参三个分别是data,当前的枚举的key,当前枚举的value

class Observer {
    constructor(data) {
    // 此处先不考虑数组
       this.walk(data)
    }
    walk(data) { //循环对象 依次劫持
        Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
    }
}

再看defineReactive 传进来的属性可能为对象
例如{a:{b:1,c:2}}传进来的key是a value是{b:1,c:2}要对内部属性也进行劫持所以进行递归

export function defineReactive(target, key, value) { 
    // 递归判断
    observe(value)
    Object.defineProperty(target, key, {
        get() {
            // 可以进行调试
            console.log('被读取了')
            return value
        },
        set(newValue) { 
            if(newValue === value) return 
            console.log('被修改了')
            //设置的新值也要进行劫持
            observe(newValue)
            value = newValue 
        }
    })
}

到这已经实现的数据的劫持,数据被读取时会触发get,数据更新的时候会触发set 后续只要在get的时候记住哪里用到该属性,在set的时候通知或者调用使用该属性的方法即可

5、对数组的处理

数组的数据很有可能是后端返回的,即存在数量级很大的风险,如果对数组不单独处理仍然循环遍历每一项对每一项进行劫持加上get和set方法的话,对性能的损耗是非常大的,所以要单独处理,不对每一项进行劫持,这也是为什么数组通过数组下标直接修改值不会触发视图更新的原因

// 对上边的Observer类进行修改
class Observer {
    constructor(data) {
        if(Array.isArray(data)) {
            this.observeArray(data)
        } else {
            this.walk(data)
        }
    }
    walk(data) { //循环对象 依次劫持
        Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
    }
    observeArray(data) {
    // 遍历数组每一项直接执行observe,在observe中判断到是普通属性会直接return
        data.forEach(item => observe(item))
    }
}

虽然不不支持数组下标直接更改值,但是如果调用了改变数组的值得方法要能获得感知
就是所谓的vue重写的改变数组的方法

import { newArrayProto } from "./array"
...
    if(Array.isArray(data)) {
        data.__proto__ = newArrayProto
        this.observeArray(data)
    } else {
        this.walk(data)
    }

在observe下新建array.js,我们要做的是当调用改变数组的7种方法时,我们能获取到更新,并且对新的值进行数据劫持,此时为了代码的整洁我们是在新的文件里写的,所以获取不到Observer类里的observeArray方法,如果为了这个方法再在array.js里引入这个类就得不偿失了,所以骚操作来了。
我们__ob__来了
前文说了对属性进行劫持要考虑两个问题,第二个问题已经劫持的属性不要再进行劫持了我们还没有解决。 所以我们回到Observer类里 constructor里最上边加上这个,此时只要进行代理过的数据,他都会有个__ob__属性 该属性就是Observer类,并且我们可以直接通过原数组的__ob__拿到数据劫持的方法

    Object.defineProperty(data, '__ob__', {
        value: this,
        enumerable: false
    })

在observe方法里我们第二个问题也迎刃而解

    export function observe(data) {
        // 对这个对象进行劫持
        if(typeof data !== 'object' || data === null) {
            return;
        }
        // 如果被劫持,就不需要再劫持,(判断是否已被劫持)
        if(data.__ob__ instanceof Observer) {
            return data.__ob__;
        }
        return new Observer(data)
    }

来到array.js现在我们获取到push unshift splice的参数后 即新增的属性后就可以直接调用原数组上__ob__上的observeArray方法对新增属性进行劫持

// 保存数组原有方法,所谓的AOP面向切面编程,核心思想是在不改变原有代码的情况下,增加新的功能。
let oldArrayProto = Array.prototype; // 数组原有的方法

// 导出新对象 在上边我们把data.__proto__指向了该对象所以调用方法的时候会直接调用该对象的方法
export let newArrayProto = Object.create(oldArrayProto)

let methods = [
    'push', // 尾部添加
    'pop',  // 尾部删除
    'shift', // 头部删除
    'unshift', // 头部添加
    'splice', // 增删该都可
    'reverse', // 倒转
    'sort', // 排序
]
methods.forEach(method => {
    newArrayProto[method] = function(...args) {
        const result = oldArrayProto[method].call(this,...args)
        let inserted;
        let ob = this.__ob__
        switch (method) {
            // 要对新增的属性进行数据劫持,此处都是在获取新加的属性,目的是给新属性进行劫持
            case 'push':
            case 'unshift':
                inserted = args
                break;
            case 'splice': // arr.splice(0,1, 替换的数据)
                inserted = args.slice(2) //(索引2 ---最后)
            default:
                break;
        }
        if(inserted) {
            ob.observeArray(inserted)
        } 
        return result
    }  
})

此时数组对数组的处理已经大致完成了,后续只要在数组通过这7种方法进行修改的时候我们通知更新即可。
下一篇文章先写render函数转虚拟dom再生成真实dom,不直接写依赖收集和watcher更新是因为没有render函数 依赖也收集不了,更不能通知更新了,所以对data的处理先到这。