# [每周分享] 手写实现Vue响应式原理

1,112 阅读5分钟

大家好,我是王大傻。最近在公司做分享时候,发现了大家对Vue的响应式原理理解并不透彻,索性就给公司同事讲了响应式原理,并略有心得,所以这就来分享一波(第一次写文章,有什么不到位的地方可以留言告诉我) 79388139a7df9cff39b9026c839c390.jpg

分析

首先呢,我们可以看下响应式原理这个图。当我们创建Vue实例时候,我们做了两件事

  1. 数据劫持
  2. 解析指令 在数据劫持这条流程中,当我们数据劫持后,我们会将数据发生的变化及时反馈给我们的Dep实例,此时Dep(目标发布者)会给我们的Watcher(观察者)发生通知,而我们的Watcher在接到通知后调用相关的函数去更新视图。到此,数据劫持算是也进行完毕。等等,我们还有另外一条线路。 在外面解析这条线路中,我们首先将指令发送给我们的Compiler进行解析,完毕后我们直接渲染到视图层去替代我们之前的插值表达式,去订阅数据的具体变化,并将绑定我们的更新函数,此时Watcher将会去我们的Dep上添加订阅者。

观察者模式

在讲原理前,首先我们需要先了解一下观察者模式是什么。

image.png 如图所示,被观察者也就是我们的发布者可以同时支持多个观察者,而被观察者主要是用来注册我们的观察实例,一有结果就马上通知我们的观察者。

// # 发布者
class Dep {
    constructor() {
        this.subs = []// 存储我们的观察者
    }

    addSub(sub) {
        if (sub && sub.update) {// 判断是否有观察者函数 update为观察者中的函数
            this.subs.push(sub)
        }
    }

    notify() {
        this.subs.forEach(sub => {
            sub.update()// 执行我们观察者函数
        })
    }
}

// # 观察者
class Watcher {
    constructor() {
        update()
        {
            console.log('update')
        }
    }
}

let dep = new Dep()
let watch = new Watcher()
dep.addSub(watch)// 注册我们观察实例
dep.notify()  // 通知到我们的观察者

image.png 清晰了我们观察者模式后,我们再来思考下在Vue中我们具体的使用吧

第一步 创建Vue类

首先最最最重要的肯定是我们需要初始化一个Vue类

let vm=new Vue({
    el:'#app',
    data:{
        msg:'hello',
        title:'你好'
    }
})
console.log(vm)

这里的Vue是我们自己创建的实例,首先分析

  1. Vue实例里面接受了一个对象
  2. 对象里面包含el和data两个参数
  3. 那么我们Vue具有什么功能呢
    1. 负责接收初始化参数
    2. 负责把data中的属性注入到Vue实例 转换成getter/setter
    3. 负责调用observer监听data中所有属性的变化
    4. 负责调用compiler解析指令/表达式 那么我们依据我们所了解的功能先去初始化实例
class Vue {
    constructor(options) {
        //1. 通过属性保存选项的数据
        this.$options = options
        this.$data = options.data || {}
        this.$el = typeof options.el === "string" 
                        ? document.querySelector(options.el) 
                        : options.el
        //2. 把data中的成员转换为getter setter 注入到vue实例中
        this._proxyData(this.$data)
        //3. 调用observer对象 监听数据的变化
        new Observer(this.$data)
        //4. 调用compiler对象 解析指令和插值表达式
        new Compiler(this)
    }

    _proxyData(data) {
        // 遍历data中所有的属性
        Object.keys(data).forEach(key => {
            // 把data的属性注入到vue实例中
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return data[key]
                },
                set(newValue) {
                    if (newValue === data[key]) {// 判断导入值是否和当前值相等 相等了我们就不做替换
                        return
                    }
                    data[key] = newValue
                }
            })
        })
    }
}

第二步 创建Observer

接下来,我们需要创建一个Observer类,用来为我们Vue中的data属性设置setter、getter 那么我们分析一下它的功能

  1. 负责把data选项中的属性转换为响应式数据
  2. data中的某个属性也是对象 把该属性转换为响应式数据
  3. 数据变化发送通知 我们依据它的功能来实现一下
class Observer {
    constructor(data) {// 接收我们在Vue实例中传过来的data数据
        this.walk(data)
    }

    walk(data) {
        // 1. 判断data是否为对象
        if (!data || typeof data !== 'object') return
        // 2. 遍历data中的所有属性
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key])
        })
    }

    defineReactive(data, key, value) {
        const self = this// 保存我们的指针 使当前this指向我们的实例
        let dep = new Dep()// 在创建时候先去生成一个发布者实例
        this.walk(value)// 如果是属性的话 不会处理 如果是对象的话 会对对象也做监听
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
            // 判断我们的Dep实例中是否有target属性 并将target加入到我们的发布者存储中
             Dep.target && dep.addSub(Dep.target)
            // 当前我们预留了这段代码 后续在Watcher中为Dep添加target属性
                return value
            },
            set(newVal) {
                if (newVal === value) {
                    return
                }
                value = newVal
                self.walk(newVal)
                dep.notify()// 当我们去设置新的值的时候 发送通知
            }
        })
    }
}

至此我们可以在控制台上打印下我们的vm实例

  1. 需要注释当前未声明的类及对类的相关使用,如Vue中的Compiler Observer中的Dep

image.png

第三步 创建我们的Compiler类

到此为止呢,我们已经初始化了Vue实例并通过Observer去给我们实例上的数据挂载到我们Vue上并添加了getter、setter方法,那么接下来到了我们的重点,Compiler类,那么它又有什么功能呢。

  1. 负责编译模板,解析指令和插值表达式
  2. 负责页面的首次渲染
  3. 当数据变化后重新渲染视图 说到编译模板,那我们就需要对标签进行一些操作了,在此我们实现了插值表达式以及v-text v-model的填充运算

image.png 如图所示 我们需要做的内容就是

  1. 把插值表达式部分替换成我们data中的具体数据
  2. v-text绑定的变量替换为我们data中的具体数据
  3. v-model绑定的表单数据我们也要替换为具体数据,并且在更改数据同时也要及时响应到页面中。

那么根据上述我们创建Compiler类

  class Compiler {
    constructor(vm) {
        this.el = vm.$el
        this.vm = vm
    }

    // 编译模板 处理文本和元素节点
    compile(el) {
        let childNodes = el.childNodes
        Array.from(childNodes).forEach(node => {
            if (this.isTextNode(node)) {
                // 判断文本节点
                this.compileText(node)
            } else if (this.isElementNode(node)) {
                // 判断元素节点
                this.compileELement(node)
            }
            if (node.childNodes && node.childNodes.length) {
                this.compile(node)
            }
        })
    }

    // 编译元素节点 处理指令
    compileELement(node) {
        Array.from(node.attributes).forEach(attr => {
            let attrName = attr.name
            if (this.isDerictive(attrName)) {
                attrName = attrName.substr(2)
                let key = attr.value
                this.update(node, key, attrName)
            }
        })
        // 遍历所有的节点
        // 判断是否是指令
    }

    // 判断需要执行的方法
    update(node, key, attrName) {
        let fn = this[attrName + 'Update']
        fn && fn.call(this, node, this.vm[key], key)
        // 此处需要用call来改变我们的this指向 因为我们将函数存储为变量时 this指针此时指向了window对象
    }

    // 处理v-text指令
    textUpdate(node, val, key) {
        node.textContent = val
        new Watcher(this.vm, key, (newVal) => {
            node.textContent = newVal
        })
    }

    // 处理v-model指令
    modelUpdate(node, val, key) {
        node.value = val
        new Watcher(this.vm, key, (newVal) => {
            node.value = newVal
        })
        // 双向绑定
        node.addEventListener('input', () => {
            this.vm[key] = node.value
        })
    }

    // 编译文本节点 处理插值表达式
    compileText(node) {
        let reg = /\{\{(.+?)\}\}/
        let val = node.textContent
        if (reg.test(val)) {
            let key = RegExp.$1.trim()
            node.textContent = val.replace(reg, this.vm[key])
            //    创建watcher对象 属性改变时候更新视图
            new Watcher(this.vm, key, (newVal) => {
            // 写入我们的watcher 方法 在处理节点时添加上我们的观察者
                node.textContent = newVal
            })
        }
    }

    // 判断元素是否是指令
    isDerictive(attrName) {
        return attrName.startsWith('v-')
    }

    // 判断节点是否是文本节点
    isTextNode(node) {
        return node.nodeType === 3
    }

    // 判断是否为元素节点
    isElementNode(node) {
        return node.nodeType === 1
    }
}

对于代码中的某些判定,我们将单独解释下

  1. 我们需要判断两种节点(文本节点和元素节点)
  2. 在文本节点中 通过判断nodetype===3 并可以通过textContent进行赋值
  3. 在元素节点中 通过判断nodetype===1 如果是表单元素那么可以通过value进行赋值 如果不是 我们可以取得attributes 并通过textContent赋值 文本节点

image.png 元素节点

image.png image.png 至此我们页面上的插值表达式以及 v-text v-model已经转换为我们对应的数据了

第四步 创建我们的观察者模式完成最后的操作

在之前我们对观察者模式已经做了相应的介绍,所以话不多说,我们直接走起 首先是我们的发布者

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

    addSub(sub) {
        if (sub && sub.update) {
            this.subs.push(sub)
        }
    }

    notify() {
        this.subs.forEach(sub => {
            sub.update()
        })
    }
}

其次是我们的观察者 那么在我们观察者中根据此时的处理 我们需要的功能如下

  1. 当数据变化时候触发依赖 dep通知Watcher实例更新视图
  2. 自身变化时候在dep中添加自己
 class Watcher {
    constructor(vm, key, cb) {
        this.vm = vm
        this.key = key
        this.cb = cb
        // 当前的watcher记录到dep的target中
        // 触发get方法 在get中addSub
        Dep.target = this
        this.oldValue = vm[key]
        Dep.target = null// 在最后对target进行回收操作 避免多次添加
    }

    update() {
        let newValue = this.vm[this.key]
        if (this.oldValue === newValue) {
            return
        }
        this.cb(newValue)
    }

}

至此,我们的Vue响应式原理就大功告成了,让我们在页面中跑一下看看吧

首先我们按照类之间互相依赖的顺序将文件引入到html页面中 image.png 我们运行到页面中的结果 image.png 当我们在输入框中输入数据时候触发了双向绑定 image.png over,至此我们明白了响应式原理的简单版运作方法。如果大家有什么疑问,可以在评论区留言。我会一一解答,如果有哪块做的不好的,欢迎大家指正

image.png