Vue2源码学习 | 手写简易Vue响应式原理

82 阅读7分钟

前言

我们在学习vue的时候是否有过这样的疑问,为什么当响应式数据发生变化时视图会发生变化,又如何将一个普通的数据变成一个响应式数据,本篇文章是我最近对vue源码学习的总结,我将通过手写vue响应式原理,带你实现一个基础版的vue。

插值语法

我们先来看一段代码:

<div id="app">
    {{ name }}
</div>
const vm = new Vue({
    el: '#app',
    data: {
        name: 'andy'
        }
})

效果:

image.png 这段代码会将页面中使用插值语法{{ name }}的地方,替换成data中对应的属性值。我们现在知道它做了什么,接下来就要想它要怎么做了,实现思路也很简单,那就是遍历页面中的DOM节点,找到使用了插值语法的节点,再将对应的属性值替换掉插值语法就可以了。

遍历之前需要先把要遍历的区域交给Vue进行管理,我们创建一个Vue实例,并将el和data选项传进去,el对应的节点就是Vue实例的管理区域。

class Vue {
    constructor(options) {
        if(this.isElement(options.el)) {
            // 这种情况是el本身就是一个选择器,如el: document.querySelector('#app')
            this.$el = options.el
        }else {
            this.$el = document.querySelector(options.el)
        }
        
        this.$data = options.data
    }
    
    // 判断el是否为一个元素节点
    isElement(node) {
        return node.nodeType === 1
    }
}

创建一个Compiler类,根据$el$data对Vue管理的区域进行编译,除了插值语法,还需要对指令(v-xxx)进行编译,因此在查找的时候需要对节点进行判断。

class Vue{
    constructor(options) {
        ...
        new Compiler(this)
    }
}

class Compiler {
    constructor(vm) {
        this.vm = vm
        let fragment = this.nodeToFragment(this.vm.$el)
        // 将数据和视图进行编译
        this.buildTemplate(fragment)
        this.vm.$el.appendChild(fragment)
    }
    
    nodeToFragment(app) {
        let fragment = document.createDocumentFragment()
        let node = app.firstChild
        while(node) {
            fragment.appendChild(node)
            node = app.firstChild
        }
        return fragment
    }
    
    buildTemplate(fragment) {
        let nodeList = [...fragment.childNodes]
        nodeList.forEach(node => {
            if(this.vm.isElement(node)) {
                // node是一个元素节点,对元素进行编译,处理指令
                this.buildElement(node)
                // 并且递归调用这个函数,处理元素节点的嵌套内容
                this.buildTemplate(node)
            }else {
                // node是一个文本节点,对文本进行编译,处理插值语法
                this.buildText(node)
            }
        })
    }
    
    buildText(node) {
        let content = node.textContent
        // 利用正则表达式判断文本是不是插值语法
        let reg = /\{\{.+?\}\}/ig
        if(reg.test(content)) {
            DiretiveUtils['content'](node, content, this.vm)
        }
    }
}

buildElement(node) {
    let attrs = [...node.attributes]
    attrs.forEach(attr => {
        let { name, value } = attr
        // 判断元素节点的属性是否以v-开头
        if(name.startsWith('v-')) {
            let [_, directive] = name.split('-')
            // 后续将在工具对象中封装响应的命令函数
            DiretiveUtils[directive]()
        }
    })
}

// 该对象主要用于封装处理Vue指令和插值语法的工具函数
const DiretiveUtils = {
    getValue(vm, value) {
        return value.split('.').reduce((data, key) => {
            return data[key.trim()]
        }, vm.$data)
    },
    getContent(vm, content) {
        let reg = /\{\{(.+?)\}\}/ig
        return content.replace(reg, (...args) => {
            return this.getValue(vm, args[1])
        })
    },
    content(node, content, vm) {
        let reg = /\{\{(.+?)\}\}/ig
        // 将插值语法替换成对应的属性值
        let val = content.replace(reg, (...args) => {
            return this.getContent(vm, content)
        })
        node.textContent = val
    }
}

到这里就简单实现了插值语法,将app内的子节点移到fragment代码片段中,然后对这些子节点进行遍历,如果子节点是一个文本节点,则进一步判断该文本是不是插值语法,是的话就调用插值语法的工具函数,将插值语法替换成$data中对应的属性值;如果子节点是一个元素节点,就获取元素节点的属性,判断属性中是否有指令,有的话则调用对应的指令工具函数,并递归调用buildTemplate,对该元素节点的嵌套节点做处理。fragment中的子节点都处理完毕之后,就将代码片段中的节点添加回app中。

这一步只是实现了简单的插值语法,但是数据并不是响应式的,我们可以看下图,数据改变之后,视图还是没发生变化,要实现响应式我们必须要监测到数据的变化。

image.png

image.png

Object.defineProperty

Vue2的响应式中使用Object.defineProperty对Vue实例中的数据进行劫持,我们定义一个Observe的类,负责对data中的属性设置getter和setter。数据的劫持应该在编译之前实现,因此要在编译之前就创建Observe类的实例。

class Vue {
    constructor(options) {
        ...
        new Observe(this.$data)
        
        new Compiler(this)
    }
}

class Observe {
    constructor(obj) {
        this.observe(obj)
    }
    
    observe(obj) {
        if(obj && typeof obj === 'object') {
            for(let attr in obj) {
                this.defineReactive(obj, attr, obj[attr])
            }
        }
    }
    
    defineReactive(obj, attr, value) {
        // 判断某个属性值是否为一个对象,如果是则需要对这个对象里面的属性进行劫持
        this.observe(value)
        Object.defineProperty(obj, attr, {
            get: () => {
                return value
            },
            set: (newValue) => {
                console.log('触发setter')
                if(newValue !== value) {
                    // 判断赋予的新值是否为一个对象,如果是对象也需要被劫持
                    this.observe(newValue)
                    value = newValue
                }
            }
        })
    }
}

这段代码完成了对data数据的劫持,在数据劫持的时候我们要考虑两个问题:

一个是如果某个属性的属性值是一个对象的话,我们要让这个嵌套对象内部的属性也具有getter和setter,因此每遍历一个属性,要先递归调用observe方法,判断该属性的属性值是否为一个对象,如果是的话也需要对该对象的属性进行劫持。

还有一个需要考虑的问题是,后续对某个属性进行赋值时,如果赋予的值是一个对象,那么我们也要让这个新赋予的对象内部的属性都具有getter和setter,因此在进行赋值之前也要调用一次observe方法,判断新的属性值是否为对象,是的话也要对这个对象内部的属性进行劫持。

如何实现数据响应式

到这里已经完成了对数据的监测,可以开始实现数据的响应式了。

实现思路就是,我们可以在对属性进行劫持的时候,给每个属性创建一个订阅者Dep。在初次编译的时候,在模板中使用了响应式数据的地方创建一个观察者Watcher,并将观察者推送到订阅者的subs中。当某个属性发生变化的时候,就通知依赖该属性的观察者修改节点的内容。

Watch类的实现(观察者)

class Watcher {
    // vm: Vue实例,attr: 属性,cb: 属性变化后的回调
    constructor(vm, attr, cb) {
        this.vm = vm
        this.attr = attr
        this.cb = cb   
        this.oldValue = this.getOldValue()
    }
    
    getOldValue() {
        Dep.target = this
        let oldValue = DiretiveUtils.getValue(this.vm, this.attr)
        Dep.target = null
        return oldValue
    }
    
    update() {
        let newValue = DiretiveUtils.getValue(this.vm, this.attr)
        if(this.oldValue !== this.newValue) {
            this.cb(newValue, this.oldValue)
        }
    }
}

Dep类的实现(订阅者)

class Dep {
    constructor() {
        // 订阅列表
        this.subs = []
    }
    
    // 将观察者添加到订阅列表
    addSub(watcher) {
        this.subs.push(watcher)
    }
    
    // 通知观察者执行更新方法
    notify() {
        this.subs.forEach(watcher => watcher.update())
    }
}

模拟响应式原理

实现步骤:

  • 在数据劫持的时候给每个属性创建一个Dep实例,每个属性都对应着不同的Dep,分别存储在不同的闭包中。
  • 编译阶段在使用了响应式属性的地方会创建一个Watcher实例,在创建的时候会触发属性的getter,利用getter将这个Watcher存放到属性对应的Dep的订阅列表中。
  • 当属性被修改的时候,会触发属性的setter,setter会调用订阅列表中观察者的update方法,依赖于该属性的观察者会依次修改DOM的内容。

代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        {{ name }}
        <p>{{ name }}</p>
        <p>{{ time.h }}:  {{ time.m }}: {{ time.s }}</p>
    </div>
</body>
<script src="./myVue.js"></script>
<script type="text/javascript">
    const vm = new Vue({
        el: '#app',
        data: {
            name: 'andy',
            time: {
                h: 12,
                m: 23,
                s: 34
            },
        }
    })
</script>
</html>
class Vue {
    constructor(options) {
        ...
        new Observe(this.$data)
        new Compiler(this)
    }
}
class Observe {
    constructor(obj) {
        this.observe(obj)
    }

    observe(obj) {
        if(obj && typeof obj === 'object') {
            for(let attr in obj) {
                this.defineReactive(obj, attr, obj[attr])
            }
        }
    }

    defineReactive(obj, attr, value) {
         // 判断某个属性值是否为一个对象,如果是则需要对这个对象里面的属性进行劫持
        this.observe(value)
        // 创建一个订阅者,该订阅者将存储在闭包里面
        let dep = new Dep()

        Object.defineProperty(obj, attr, {
            get: () => {
                // 将依赖于该属性的观察者 添加到 该属性的订阅列表
                Dep.target && dep.addSub(Dep.target)
                return value
            },
            set: (newValue) => {
                console.log('触发setter')
                if(value !== newValue) {
                    this.observe(newValue)
                    value = newValue
                    // 属性被修改时,通知订阅列表中的观察者修改DOM
                    dep.notify()
                }
            }
        })
    }
}
class Compiler {
    constructor(vm) {
        this.vm = vm
        let fragment = this.nodeToFragment(this.vm.$el)
        this.buildTemplate(fragment) // 将数据和视图进行编译
        this.vm.$el.appendChild(fragment)
    }
    
    buildTemplate(fragment) {
        let nodeList = [...fragment.childNodes]
        nodeList.forEach(node => {
            if(this.vm.isElement(node)) {
                this.buildElement(node)
                this.buildTemplate(node)
            }else {
                this.buildText(node)
            }
        })
    }
    
    buildText(node) {
        let content = node.textContent
        let reg = /\{\{.+?\}\}/ig
        if(reg.test(content)) {
            DiretiveUtils['content'](node, content, this.vm)
        }
    }
    
    ...
}

const DiretiveUtils = {
    // 获取$data中,key对应的value
    getValue(vm, value) { // value: "name", ['name'].reduce()
        return value.split('.').reduce(( data, key ) => {
            return data[key.trim()]
        }, vm.$data)
    },
    // 将插值语法替换成$data中的值
    getContent(vm, content) {
        let reg = /\{\{(.+?)\}\}/ig
        return content.replace(reg, (...args) => {
            return this.getValue(vm, args[1])
        })
    },
    content(node, content, vm) {
    // content: {{ xxx }}
     let reg = /\{\{(.+?)\}\}/ig
     let val = content.replace(reg, (...args) => {
         // 触发getter,将该Watcher添加到该属性的订阅列表
         new Watcher(vm, args[1], (newValue, oldValue) => {
            node.textContent = this.getContent(vm, content)
         })
        return this.getValue(vm, args[1])
     })

        node.textContent = val
    },
}

到这里Vue的响应式已经实现了,我们到页面上看下效果,以下是最开始的页面:

image.png 当我们修改属性值,页面也会发生变化:

image.png

image.png 观察者和订阅者的关系如下:

image.png

双向数据绑定(v-model)

双向数据绑定就是v-model指令,指的是视图和数据要同步更新。在这里我们继续完善Compiler内部的buildElement方法,若指令为v-model,则调用DiretiveUtils对象中的model方法。

    <div id="app">
        {{ name }}
        <input type="text" v-model="name">
        <input type="text" v-model="time.h">
    </div>
class Compiler {
    constructor(vm) {
        ...
    }
    
    buildElement(node) {
        let attrs = [...node.attributes]
        attrs.forEach(attr => {
            let { name, value } = attr
            if(name.startsWith('v-')) {
                let [ _, directive ] = direactiveName.split('-')
                DiretiveUtils[directive](node, value, this.vm)
            }
        })
    }
}
const DiretiveUtils = {
        ...,
        setVal(vm, value, newValue) {
            value.split('.').reduce((data, key, index, arr) => {
                if(index === arr.length - 1) {
                    data[key] = newValue
                }
                return data[key]
            }, vm.$data)
        },
        model(node, value, vm) {
            // value: name time.h
            // 数据 => 视图:数据改变时,视图也发生变化
            new Watcher(vm, value, (newValue, oldValue) => {
                node.value = newValue
            })
            // 初始化
            node.value = this.getValue(vm, value)

            // 视图 => 数据:视图发生变化时,数据也要变化
            node.addEventListener('input', (e) => {
                let newValue = e.target.value

                // 赋值的时候需要给value的最后一个属性进行赋值
                this.setVal(vm, value, newValue)
        })
    },
}

其实双向数据绑定无非就是在做两件事,一件事是当数据发生改变时,我们要去更新视图;另外一件事是当视图发生改变时,我们要去更新数据。

当数据发生变化时,我们要做的事和之前是一样的,编译到元素节点存在v-model指令时,我们会为这个节点创建一个Watcher实例,接着利用getter将该Watcher添加到对应属性的订阅列表中,当数据发生变化时,setter会依次调用依赖于该属性的Watcher改变视图。

我们为输入框绑定了一个input事件,当改变输入框中的内容时,就将最新的值赋给v-model绑定的属性,从而实现视图改变时,数据也同步更新。

我们在页面中看下效果,以下是初始的页面状态:

image.png 数据到视图的变化:

image.png

image.png 我们刷新一下页面,测试一下视图到数据的变化:

image.png

image.png

事件绑定(v-on)

如果要添加事件,会在data中传入methods的配置,我们需要修改一下buildElement函数,再在DiretiveUtils对象中添加相应的方法,用来调用methods中传入的回调。

<body>
    <div id="app">
        {{ name }}
        <input type="text" v-model="name">
        <input type="text" v-model="time.h">
        <button v-on:click="clickHandler">点击</button>
    </div>
</body>
<script type="text/javascript">
    const vm = new Vue({
        el: '#app',
        data: {
            name: 'andy',
            time: {
                h: 12,
                m: 23,
                s: 34
            },
        },
        methods: {
            clickHandler() {
                console.log('123')
            }
        },
    })
</script>
class Compiler {
    constructor(vm) {
        ...
    }
    
    ...
    
    buildElement(node) {
        let attrs = [...node.attributes]
        attrs.forEach(attr => {
            let { name, value } = attr
            if(name.startsWith('v-')) {
                let [direactiveName, direactiveType] = name.split(':')
                let [ _, directive ] = direactiveName.split('-')
                DiretiveUtils[directive](node, value, this.vm, direactiveType)
            }
        })
    }
}

const DiretiveUtils = {
    on(node, value, vm, type) {
        node.addEventListener(type, (e) => {
            vm.$methods[value].call(vm, e)
        })
    }
}

我们在页面上看下效果:

image.png

image.png 当我们按下点击按钮,clickHandler被触发,在控制台打印出'123'。

计算属性

我们在使用计算属性的时候,会在创建Vue实例时传入computed的配置选项,我们需要将computed内的属性映射到$data中,并对该属性进行劫持。

我们看下面代码中计算属性返回的this.n,其实现在是访问不到的,因为data中的属性都在data内,因此我们还需要将data内,因此我们还需要将data中的属性映射到Vue实例中。

<body>
    <div id="app">
        {{ name }}
        <p>{{ n }}---{{ dbN }}</p>
    </div>
</body>
<script type="text/javascript">
    const vm = new Vue({
        el: '#app',
        data: {
            name: 'andy',
            n: 18
        },
        computed: {
            dbN() {
                return this.n*2
            }
        }
    })
</script>
class Vue {
    constructor(options) {
        ...
        this.$data = options.data
         
        // 计算属性
        this.$computed = options.computed
        this.computedToData()
        
        // 需要把$data映射到this上
        this.proxyDataVm()
    }
    
    computedToData() {
        for(let attr in this.$computed) {
            Object.defineProperty(this.$data, attr, {
                get: () => {
                    return this.$computed[attr].call(this)
                }
            })
        }
    }
    
    proxyDataVm() {
        for(let attr in this.$data) {
            Object.defineProperty(this, attr, {
                get: () => {
                    return this.$data[attr]
                }
            })
        }
    }
}

我们在页面中看下效果:

image.png 当我们修改n的值,dbN也会发生变化:

image.png

image.png

完整代码

class Vue {
    constructor(options) {
        // 将options.el绑定到Vue实例的$el上
        if(this.isElement(options.el)) {
            this.$el = options.el
        }else {
            this.$el = document.querySelector(options.el)
        }

        // 把options.data绑定到Vue实例的 $data
        this.$data = options.data

        // 计算属性
        this.$computed = options.computed
        this.computedToData()

        // 需要把data映射到this上
        this.proxyDataVm()

        this.$methods = options.methods

        // 先完成数据的劫持
        new Observe(this.$data)

        // 然后在需要根据$el和$data对Vue管理的区域进行编译操作
        new Compiler(this)
    }

    computedToData() {
        for(let attr in this.$computed) {
            Object.defineProperty(this.$data, attr, {
                get: () => {
                    return this.$computed[attr].call(this)
                }
            })
        }
    }

    proxyDataVm() {
        for(let attr in this.$data) {
            Object.defineProperty(this, attr, {
                get: () => {
                    return this.$data[attr]
                }
            })
        }
    }

    isElement(node) {
        // 判断el是否为一个DOM对象
        return node.nodeType === 1
    }
}
// 编译指令和插值语法
class Compiler {
    constructor(vm) {
        this.vm = vm
        let fragment = this.nodeToFragment(this.vm.$el)
        this.buildTemplate(fragment) // 将数据和视图进行编译
        this.vm.$el.appendChild(fragment)
    }

    // 模板的指令和插值表达式的查找
    buildTemplate(fragment) {
        let nodeList = [...fragment.childNodes]
        nodeList.forEach(node => {
            if(this.vm.isElement(node)) {
                this.buildElement(node)
                // 如果node是一个元素,则继续向下编译
                this.buildTemplate(node)
            }else {
                this.buildText(node)
            }
        })
    }

    // 对元素进行编译,处理指令
    buildElement(node) {
        let attrs = [...node.attributes]
        attrs.forEach(attr => {
            let { name, value } = attr
            if(name.startsWith('v-')) {
                let [direactiveName, direactiveType] = name.split(':')               
                let [ _, directive ] = direactiveName.split('-')
                DiretiveUtils[directive](node, value, this.vm, direactiveType)
            }
        })
    }

    // 对文本进行编译,处理插值表达式
    buildText(node) {
        let content = node.textContent
        let reg = /\{\{.+?\}\}/ig
        if(reg.test(content)) {
            DiretiveUtils['content'](node, content, this.vm)
        }
    }

    nodeToFragment(app) {
        let fragment = document.createDocumentFragment()
        let node = app.firstChild
        while(node) {
            fragment.appendChild(node)
            node = app.firstChild
        }
        return fragment
    }
}
class Observe {
    constructor(obj) {
        this.observe(obj)
    }

    observe(obj) {
        if(obj && typeof obj === 'object') {
            for(let attr in obj) {
                this.defineReactive(obj, attr, obj[attr])
            }
        }
    }

    defineReactive(obj, attr, value) {
        // 判断某个属性值是否为一个对象,如果是则需要对这个对象里面的属性进行劫持
        this.observe(value) 
        // 创建一个订阅者,该订阅者将存储在闭包里面
        let dep = new Dep()

        Object.defineProperty(obj, attr, {
            get: () => {
                // 将依赖于该属性的观察者 添加到 该属性的订阅列表
                Dep.target && dep.addSub(Dep.target)
                return value
            },
            set: (newValue) => {
                console.log('触发setter')
                if(value !== newValue) {
                    // 判断新赋予的值是否是对象,如果是对象,也需要被劫持
                    this.observe(newValue) 
                    value = newValue
                    // 属性被修改时,通知订阅列表中的观察者修改DOM
                    dep.notify()
                }
            }
        })
    }
}
 class Watcher {
        // vm: Vue实例
        // attr:属性
        // cb:属性变化后的回调
    constructor(vm, attr, cb) {
        this.vm = vm
        this.attr = attr
        this.cb = cb
        this.oldValue = this.getOldValue()
    }

    getOldValue() {
        Dep.target = this
        let oldValue = DiretiveUtils.getValue(this.vm, this.attr)
        Dep.target = null
        return oldValue
    }

    update() {
        let newValue = DiretiveUtils.getValue(this.vm, this.attr)
        if(this.oldValue !== this.newValue) {
            this.cb(newValue, this.oldValue)
        }
    }
 }
class Dep {
    constructor() {
        this.subs = []
    }

    // 将观察者添加到订阅列表
    addSub(watcher) {
        this.subs.push(watcher)
    }

    // 发布通知,执行观察者的更新方法
    notify() {
        this.subs.forEach(watcher => watcher.update())
    }
}
const DiretiveUtils = {
    // 获取$data中,key对应的value
    getValue(vm, value) { // value: "name", ['name'].reduce()
        return value.split('.').reduce(( data, key ) => {
            return data[key.trim()]
        }, vm.$data)
    },
    // 将插值语法替换成$data中的值
    getContent(vm, content) {
        let reg = /\{\{(.+?)\}\}/ig
        return content.replace(reg, (...args) => {
            return this.getValue(vm, args[1])
        })
    },
    setVal(vm, value, newValue) {
        value.split('.').reduce((data, key, index, arr) => {
            if(index === arr.length - 1) {
                data[key] = newValue
            }
            return data[key]
        }, vm.$data)
    },
    model(node, value, vm) {
        // value name time.h
        // 数据 => 视图:数据改变时,视图也发生变化
        new Watcher(vm, value, (newValue, oldValue) => {
            node.value = newValue
        })
        // 初始化
        node.value = this.getValue(vm, value)

        // 视图 => 数据:视图发生变化时,数据也要变化
        node.addEventListener('input', (e) => {
            let newValue = e.target.value

            // 赋值的时候需要给value的最后一个属性进行赋值
            this.setVal(vm, value, newValue)
        })
    },
    content(node, content, vm) {
        // content: {{ xxx }}
         let reg = /\{\{(.+?)\}\}/ig
         let val = content.replace(reg, (...args) => {
             new Watcher(vm, args[1], (newValue, oldValue) => {
                //  node.textContent = newValue
                node.textContent = this.getContent(vm, content)
                // node.textContent = this.getValue(vm, args[1])
             })
            return this.getValue(vm, args[1])
         })

        // let val = this.getContent(vm, content)
        node.textContent = val
    },
    on(node, value, vm, type) {
        node.addEventListener(type, (e) => {
            vm.$methods[value].call(vm, e)
        })
    }
}