手动实现一个vue响应式

235 阅读5分钟

vue2的响应式原理可以用一句话来简述,那就是结合数据劫持和发布/订阅模式实现的。

那么是怎么对数据进行劫持的呢?发布/订阅模式又是什么?

1、数据劫持

数据劫持是通过对象下的一个静态方法defineProperty来实现的。

Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

Object.defineProperty(obj, prop, descriptor)

a、obj是要定义属性的对象。
b、prop是要定义或修改的属性的名称或 Symbol。
c、descriptor要定义或修改的属性描述符。

该方法允许精确地添加或修改对象的属性。通过赋值操作添加的普通属性是可以枚举的,在枚举对象属性时会被枚举到(for...inObject.keys方法),可以改变这些属性的值,也可以删除这些属性。这个方法允许修改默认的额外选项(或配置)。

对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符时一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由getter函数和setter函数所描述的属性。一个描述符只能是这两者其中之一,不能同时是两者。

我们先来看看数据描述符

const obj = {
    name:'张三'
}

Object.defineProperty(obj,'age',{
    value:"18",  // 该属性对应的值。可以是任何有效的javascript值(数值,对象,函数等),默认为undefined
    writable:false, // 该属性是否可写,若为false,则该属性不能被改变
    enumerable:false, // 该属性是否可枚举
    configurable:false // 该属性是否可配置
})
obj.name = '李四'
obj.age = '20岁'
console.log(obj) // {name: "李四", age: "18"}
console.log(Object.getOwnPropertyDescriptor(obj,'name')) // {value: "李四", writable: true, enumerable: true,configurable: true}
console.log(Object.keys(obj)) // ["name"]

delete obj.name
delete obj.age
console.log(obj) // {age: "18"}

obj下的name属性是采用赋值操作创建的,默认是可写,可枚举,可配置,所以我们可以对该属性重新赋值,可以枚举,也可以对其进行删除。age属性是我们通过Object.defineProperty()来创建的,我们将它设置为不可写,不可枚举,不可配置,所以我们后续对它进行的操作都无用。

通过上述例子我们了解了数据描述符的作用。那么存取描述符是用来做什么的呢?

const obj = {
    name: '张三'
}
Object.defineProperty(obj, 'sex', {
    get: function () {
        console.log('get sex!');
    },
    set: function (value) {
        console.log('new sex:' + value)
    }
})
const sex = obj.sex
obj.sex = '女'

运行以上代码,先后会打印出“get sex!”,“new sex:女”。由此可见,当我们访问对象下的属性时就会调用get函数;当我们修改对象下的属性时,就会调用set函数。

到此,你明白数据挟持的含义了吗?

2、发布/订阅模式

大家的微信应该关注了各种各样自己喜欢的公众号,当公众号推出新的文章时,关注的小伙伴都可以收到消息推送,这其实就是一个发布/订阅模式,微信公众号就是一个发布者,我们都是订阅者。

我们常见的发布/订阅模式有我们经常使用的用于父子组件通信的$bus

// eventBus.js
// 事件中心
let eventHub = new Vue()

// ComponentA.vue
// 发布者
addTodo:function(){
    // 发布消息
    eventHub.$emit('add-todo',{text:'新增代办事件'})
}

// ComponentB.vue
// 订阅者
created(){
    // 订阅消息
    eventHub.$on('add-todo',this.addTodo)
}

由此可见,如果我们要实现一个发布/订阅模式,我们需要定义两个类,定义一个订阅者类,要接收消息更新。还需要定义一个发布者类,可以添加订阅者,记录下所有的订阅者,然后有新消息时发布通知到各个订阅者。根据此思路,我们来手动实现一个简单的发布/订阅模式吧~

// 发布者
class Dep {
    constructor(){
        // 记录所有的订阅者
        this.subs = []
    }
    // 添加订阅者
    addSub(sub){
        if(sub && sub.update()){
            this.subs.push()
        }
    }
    // 发布通知
    notify(){
        this.subs.forEach(sub => {
            sub.update()
        })
    }
}
// 订阅者
class Watcher {
    update(){
        console.log('update')
    }
}

// test
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()

从上述的介绍中我们已经了解了数据劫持和发布/订阅模式的含义,要想实现响应式,首先我们需要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发生变化了,就需要通知我们的订阅者Watcher。因为订阅者是有很多个,所以我们需要有一个发布者Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理。接着,我们还需要一个指令解析器Compiler,对每个结点元素进行扫描和解析,将相关指令对应初始化成一个个Watcher,并替换模板数据或者绑定相应的函数。此时,当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。

根据以上思路,我们自己动手来实现一个简单的vue响应式原理吧!

创建一个my-vue.js

class MyVue {
    constructor(options) {
        this.$options = options
        this.$data = options.data

        // 对数据进行响应化处理
        observe(this.$data)

        //代理,方便数据访问, 代理前 this.$data.counter 代理后 this.counter
        proxy(this,'$data')

        // 创建编译器
        new Compiler(options.el, this)
    }
}

将传进来的初始数据都保存在$options下,以便后续访问,然后将我们的数据保存在$data,接着对$data中的数据进行劫持。由于Object.defineProperty()是对象才有的方法,所以仅能劫持对象。

function observe(obj) {
    if (typeof obj !== 'object' || obj === null) {
        return
    }
    // 创建Observer实例
    new Observer(obj)
}

接下来我们来创建Observer实例,对数据进行劫持

class Observer {
    constructor(value) {
        this.value = value
        // 对数据进行劫持
        this.walk(value)
    }

    walk(obj) {
        // 遍历数据进行劫持
        Object.keys(obj).forEach(key => {
            defineReactive(obj, key, obj[key])
        })
    }
}

我们对数据进行劫持,是需要做什么操作呢?

根据之前的思路可知,我们需要给每个数据定义一个发布者,当数据被访问时,则需要将该订阅者添加到发布者中,当数据被修改时,需要通知发布者中的每个订阅者进行更新操作。所以我们首先来实现一个发布者,里面需要有添加订阅者以及通知订阅者的方法。

class Dep {
    constructor(){
        this.deps = []
    }

    // 新增订阅者
    addDep(dep){
        this.deps.push(dep)
    }

    //通知订阅者进行更新
    notify(){
        this.deps.forEach(dep => dep.update())
    }
}

发布者实现好了后,我们来实现下响应化的函数

function defineReactive(obj, key, val) {
    // 递归响应化处理
    observe(val)

    // 创建一个Dep和当前key一一对应
    const dep = new Dep()

    // 数据劫持
    Object.defineProperty(obj,key,{
        get() {
            // 增加订阅者
            Dep.target && dep.addDep(Dep.target)
            return val
        },
        set(newVal){
            if(newVal !== val){
                // 如果传入的newVal依然是对象,则需要做响应化处理
                observe(newVal)
                val = newVal

                // 通知更新
                dep.notify()
            }
        }
    })
}

由于Object.defineProperty()只能对对象的属性进行劫持处理,如果对象的属性仍是一个对象,则需要递归处理进行劫持。

实现了发布者,我们接着来实现下订阅者。

// 订阅者,保存更新函数,值发生改变时调用更新函数
class Watcher {
    constructor(vm,key,updateFn){
        this.vm = vm
        this.key = key
        this.updateFn = updateFn

        // Dep.target静态属性上设置为当前的watcher实例
        Dep.target = this
        // 读取数据会触发getter,则该数据的发布者中会增加当前订阅者
        this.vm[this.key]
        // 增加完成之后就置空,为下一个订阅者做准备
        Dep.target = null
    }

    // 更新函数
    update(){
        this.updateFn.call(this.vm,this.vm[this.key])
    }
}

最后,我们来实现下代理函数,方便用户直接访问$data中的数据

function proxy(vm,sourceKey) {
    Object.keys(vm[sourceKey]).forEach(key => {
        Object.defineProperty(vm,key,{
            get(){
                return vm[sourceKey][key]
            },
            set(newVal){
                vm[sourceKey][key] = newVal
            }
        })
    })
}

接下来,我们需要对模板进行解析,建立Watcher。新建一个compile.js。

首先我们需要先获取所有的节点

class Compiler{
    constructor(el,vm){
        // 获取当前实例
        this.$vm = vm
        // 获取当前根节点
        this.$el =  document.querySelector(el)
        // 根节点存在则进行编译处理
        if(this.$el){
            this.compile(this.$el)
        }
    }
}

获取到所有的节点后,我们就需要对节点进行解析处理,判断是否是插值表达式,还是自定义的指令,对此进行不同的处理

compile(el){
    // 或者根节点下的所有子节点
    const childNodes = el.childNodes
    Array.from(childNodes).forEach(node => {
        // 判断该节点是否是一个元素节点
        if(this.isElement(node)){
            this.compileElement(node)
        }else if(this.isInterpolation(node)){
            // 判断该节点是否是插值表达式
            this.compileText(node)
        }
        // 如果该节点还有子节点,则进行递归处理
        if(node.childNodes && node.childNodes.length > 0){
            this.compile(node)
        }
    })
}
// 判断节点是否是元素节点
isElement(node){
    return node.nodeType === 1
}

// 判断该节点是否是插值表达式(节点为文本节点,并且采用双大括号包裹)
isInterpolation(node){
    return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}

如果是元素节点的话,我们需要获取该元素上的所有属性,对自定义指令进行解析,在此,我们约定自定义指定以“m-”开头。

// 解析元素节点上的属性
compileElement(node){
    // 获取节点上的所有属性
    const nodeAttrs = node.attributes
    Array.from(nodeAttrs).forEach(attr => {
        const attrName = attr.name
        const exp = attr.value
        // 判断是否是自定义指令
        if(this.isDirective(attrName)){
            const directive = attrName.substring(2)
            // 执行指令对应的函数
            this[directive] && this[directive](node,exp)
        }
    })
}

// 是否是自定义指令
isDirective(attr){
    return attr.indexOf('m-') === 0
}

如果是插值表达式,则需要更新该插值表达式

// 解析插值表达式
compileText(node){
    // RegExp这个对象会在我们调用了正则表达式的方法后, 自动将最近一次的结果保存在里面
    this.update(node,RegExp.$1,'text')
}

我们先猜想下update这个函数里我们需要做什么?首先,我们将该变量的值渲染到页面上,另外,如果后续该变量发生变化的时候,页面需要自动更新,所以我们需要在这里实例化一个订阅者。

// 更新函数
update(node,exp,dir){
    const fn = this[dir + 'Updater']
    fn && fn(node,this.$vm[exp])

    // 实例化一个Watcher
    new Watcher(this.$vm,exp,function(val) {
        fn && fn(node,val)
    })
}

插值表达式的更新函数如下

//更新插值表达式的文本  
textUpdater(node,value){
    node.textContent = value
}

到此,我们已经实现了一个简单的响应式,我们来使用看看吧!

新建一个vue.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>
    <div id="app">
        <p>{{counter}}</p>
        <p m-html="richText"></p>
    </div>

    <script src="compile.js"></script>
    <script src="my-vue.js"></script>
    <script>
        const app = new MyVue({
            el:"#app",
            data:{
                counter:1,
                richText:'<span>啦啦啦</span>',
            }
        })
        const timer = setInterval(() => {
            if(app.counter < 10){
                app.counter++
            }else{
                clearInterval(timer)
            }
        }, 1000);
    </script>
</body>
</html>

运行结果如下

show.gif

一个简单的响应式就实现了~当然啦,vue中的响应式原理远比这复杂的多,这仅仅是个大概思路。

留给大家一个小作业~实现代码中的m-html

3、参考文章

1、Object.defineProperty()