【Vue原理剖析】Object的变化侦测

9,728

前言: 三月四月是招聘旺季,相信不少面试前端岗的同学都有被问到vue的原理是什么吧?本文就以最简单的方式教你如何实现vue框架的基本功能。为了减少大家的学习成本,我就以最简单的方式教大家撸一个vue框架。

一、准备

希望准备阅读本文的你最好具备以下技能:

  • 熟悉ES6语法
  • 了解HTML DOM 节点类型
  • 熟悉Object.defineProperty()方法的使用
  • 正则表达式的基本使用。(例如分组)

首先,我们按照以下代码创建一个HTML文件,本文主要就是教大家如何实现以下功能。

   <script src="../src/vue.js"></script>
</head>
<body>
    <div id="app">
        <!--  解析插值表达式 -->
        <h2>title 是 {{title}}</h2>
        <!-- 解析常见指令 -->
        <p v-html='msg1' title='混淆属性1'>混淆文本1</p>
        <p v-text='msg2' title='混淆属性2'>混淆文本2</p>
        <input type="text" v-model="something">
        <!-- 双向数据绑定 -->
        <p>{{something}}</p>
        <!-- 复杂数据类型 -->
        <p>{{dad.son.name}}</p>
        <p v-html='dad.son.name'></p>
        <input type="text" v-model="dad.son.name"> 
        
        <button v-on:click='sayHi'>sayHi</button>
        <button @click='printThis'>printThis</button>
    </div>
</body>
 let vm = new Vue({
        el: '#app',
        data: {
            title: '手把手教你撸一个vue框架',
            msg1: '<a href="#">应该被解析成a标签</a>',
            msg2: '<a href="#">不应该被解析成a标签</a>',
            something: 'placeholder',
            dad: {
                name: 'foo',
                son: {
                    name: 'bar',
                    son: {}
                }
            }
        },
        methods: {
            sayHi() {
                console.log('hello world')
            },
            printThis() {
                console.log(this)
            }
        },
    })

准备工作做好了,那我们就一起来实现vue框架的基本功能吧!

MVVM 实现思路

我们都知道,vue是基于MVVM设计模式的渐进式框架。那么在JavaScript中,我们该如何实现一个MVVM框架呢? 主流的实现MVVM框架的思路有三种:

  • backbone.js

发布者-订阅者模式,一般通过pub和sub的方式实现数据和视图的绑定。

  • Angular.js

Angular.js是通过脏值监测的方式对比数据是否有变更,来决定是否更新视图。类似于通过定时器轮寻监测数据是否发生了额改变。

  • Vue.js

Vue.js是采用数据劫持结合发布者-订阅者模式的方式。在vue2.6之前,是通过Object.defineProperty() 来劫持各个属性的setter和getter方法,在数据变动时发布消息给订阅者,触发相应的回调。这也是IE8以下的浏览器不支持vue的根本原因。

Vue实现思路

  • 实现一个Compile模板解析器,能够对模板中的指令和插值表达式进行解析,并赋予对应的操作
  • 实现一个Observer数据监听器,能够对数据对象(data)的所有属性进行监听
  • 实现一个Watcher 侦听器。讲Compile的解析结果,与Observer所观察的对象连接起来,建立关系,在Observer观察到数据对象变化时,接收通知,并更新DOM
  • 创建一个公共的入口对象(Vue),接收初始化配置,并协调Compile、Observer、Watcher模块,也就是Vue。

上述流程如下图所示:

二、Vue入口文件

把逻辑捋顺清楚后,我们会发现,其实我们要在这个入口文件做的事情很简单:

  • 把data和methods挂载到根实例中;
  • 用Observer模块监听data所有属性的变化
  • 如果存在挂载点,则用Compile模块编译该挂载点下的所有指令和插值表达式
/**
 * vue.js (入口文件)
 * 1. 将data,methods里面的属性挂载根实例中
 * 2. 监听 data 属性的变化
 * 3. 编译挂载点内的所有指令和插值表达式
 */
class Vue {
    constructor(options={}){
        this.$el = options.el;
        this.$data = options.data;
        this.$methods = options.methods;
        debugger
        // 将data,methods里面的属性挂载根实例中
        this.proxy(this.$data);
        this.proxy(this.$methods);
        // 监听数据
        // new Observer(this.$data)
        if(this.$el) {
        //    new Compile(this.$el,this);
        }
    }
    proxy(data={}){
        Object.keys(data).forEach(key=>{
            // 这里的this 指向vue实例
            Object.defineProperty(this,key,{
                enumerable: true,
                configurable: true,
                set(value){
                    if(data[key] === value) return
                    return value
                },
                get(){
                    return data[key]
                },
            })
        })
    }
}

三、Compile模块

compile主要做的事情是解析指令(属性节点)与插值表达式(文本节点),将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。

因为遍历解析的过程有多次操作dom节点,这会引发页面的回流与重绘的问题,为了提高性能和效率,我们最好是在内存中解析指令和插值表达式,因此我们需要遍历挂载点下的所有内容,把它存储到DocumentFragments中。

DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。

所以我们需要一个node2fragment()方法来处理上述逻辑。

实现node2fragment,将挂载点内的所有节点存储到DocumentFragment中

node2fragment(node) {
    let fragment = document.createDocumentFragment()
    // 把el中所有的子节点挨个添加到文档片段中
    let childNodes = node.childNodes
    // 由于childNodes是一个类数组,所以我们要把它转化成为一个数组,以使用forEach方法
    this.toArray(childNodes).forEach(node => {
        // 把所有的字节点添加到fragment中
        fragment.appendChild(node)
    })
    return fragment
}

this.toArray()是我封装的一个类方法,用于将类数组转化为数组。实现方法也很简单,我使用了开发中最常用的技巧:

toArray(classArray) {
    return [].slice.call(classArray)
}

解析fragment里面的节点

接下来我们要做的事情就是解析fragment里面的节点:compile(fragment)

这个方法的逻辑也很简单,我们要递归遍历fragment里面的所有子节点,根据节点类型进行判断,如果是文本节点则按插值表达式进行解析,如果是属性节点则按指令进行解析。在解析属性节点的时候,我们还要进一步判断:是不是由v-开头的指令,或者是特殊字符,如@:开头的指令。

// Compile.js
class Compile {
    constructor(el, vm) {
        this.el = typeof el === "string" ? document.querySelector(el) : el
        this.vm = vm
        // 解析模板内容
        if (this.el) {
        // 为了避免直接在DOM中解析指令和差值表达式所引起的回流与重绘,我们开辟一个Fragment在内存中进行解析
        const fragment = this.node2fragment(this.el)
        this.compile(fragment)
        this.el.appendChild(fragment)
        }
    }
    // 解析fragment里面的节点
    compile(fragment) {
        let childNodes = fragment.childNodes
        this.toArray(childNodes).forEach(node => {
            // 如果是元素节点,则解析指令
            if (this.isElementNode(node)) {
                this.compileElementNode(node)
            }

            // 如果是文本节点,则解析差值表达式
            if (this.isTextNode(node)) {
                this.compileTextNode(node)
            }

            // 递归解析
            if (node.childNodes && node.childNodes.length > 0) {
                this.compile(node)
            }
        })
    }
}

处理解析指令的逻辑:CompileUtils

接下来我们要做的就只剩下解析指令,并把解析后的结果通知给视图了。

当数据发生改变时,通过Watcher对象监听expr数据的变化,一旦数据发生变化,则执行回调函数。

new Watcher(vm,expr,callback) // 利用Watcher将解析后的结果返回给视图.

我们可以把所有处理编译指令和插值表达式的逻辑封装到compileUtil对象中进行管理。

这里有两个坑点大家需要注意一下:

  1. 如果是复杂数据的情形,例如插值表达式:{{dad.son.name}}或者<p v-text='dad.son.name'></p>,我们拿到v-text的属性值是字符串dad.son.name,我们是无法通过vm.$data['dad.son.name']拿到数据的,而是要通过vm.$data['dad']['son']['name']的形式来获取数据。因此,如果数据是复杂数据的情形,我们需要实现getVMData()setVMData()方法进行数据的获取与修改。
  2. 在vue中,methods里面的方法里面的this是指向vue实例,因此,在我们通过v-on指令给节点绑定方法的时候,我们需要把该方法的this指向绑定为vue实例。
// Compile.js
let CompileUtils = {
    getVMData(vm, expr) {
        let data = vm.$data
        expr.split('.').forEach(key => {
            data = data[key]
        })
        return data
    },
    setVMData(vm, expr,value) {
        let data = vm.$data
        let arr = expr.split('.')
        arr.forEach((key,index) => {
            if(index < arr.length -1) {
                data = data[key]
            } else {
                data[key] = value
            }
        })
    },
    // 解析插值表达式
    mustache(node, vm) {
        let txt = node.textContent
        let reg = /\{\{(.+)\}\}/
        if (reg.test(txt)) {
            let expr = RegExp.$1
            node.textContent = txt.replace(reg, this.getVMData(vm, expr))
            new Watcher(vm, expr, newValue => {
                node.textContent = txt.replace(reg, newValue)
            })
        }
    },
    // 解析v-text
    text(node, vm, expr) {
        node.textContent = this.getVMData(vm, expr)
        new Watcher(vm, expr, newValue => {
            node.textContent = newValue
        })
    },
    // 解析v-html
    html(node, vm, expr) {
        node.innerHTML = this.getVMData(vm, expr)
        new Watcher(vm, expr, newValue => {
            node.innerHTML = newValue
        })
    },
    // 解析v-model
    model(node, vm, expr) {
        let that = this
        node.value = this.getVMData(vm, expr)
        node.addEventListener('input', function () {
            // 下面这个写法不能深度改变数据
            // vm.$data[expr] = this.value
            that.setVMData(vm,expr,this.value)
        })
        new Watcher(vm, expr, newValue => {
            node.value = newValue
        })
    },
    // 解析v-on
    eventHandler(node, vm, eventType, expr) {
        // 处理methods里面的函数fn不存在的逻辑
        // 即使没有写fn,也不会影响项目继续运行
        let fn = vm.$methods && vm.$methods[expr]
        
        try {
            node.addEventListener(eventType, fn.bind(vm))
        } catch (error) {
            console.error('抛出这个异常表示你methods里面没有写方法\n', error)
        }
    }
}

四、Observer模块

其实在Observer模块中,我们要做的事情也不多,就是提供一个walk()方法,递归劫持vm.$data中的所有数据,拦截setter和getter。如果数据变更,则发布通知,让所有订阅者更新内容,改变视图。

需要注意的是,如果设置的值是一个对象,则我们需要保证这个对象也要是响应式的。 用代码来描述即:walk(aObjectValue)。关于如何实现响应式对象,我们采用的方法是Object.defineProperty()

完整代码如下:

// Observer.js
class Observer { 
    constructor(data){
        this.data = data
        this.walk(data)
    }
    
    // 遍历walk中所有的数据,劫持 set 和 get方法
    walk(data) {
        // 判断data 不存在或者不是对象的情况
        if(!data || typeof data !=='object') return

        // 拿到data中所有的属性
        Object.keys(data).forEach(key => {
            // console.log(key)
            // 给data中的属性添加 getter和 setter方法
            this.defineReactive(data,key,data[key])

            // 如果data[key]是对象,深度劫持
            this.walk(data[key])
        })
    }

    // 定义响应式数据
    defineReactive(obj,key,value) {
        let that = this
        // Dep消息容器在Watcher.js文件中声明,将Observer.js与Dep容器有关的代码注释掉并不影响相关逻辑。
        let dep = new Dep()
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable: true,
            get(){
                // 如果Dep.target 中有watcher 对象,则存储到订阅者数组中
                Dep.target && dep.addSub(Dep.target)
                return value
            },
            set(aValue){
                if(value === aValue) return
                value = aValue
                // 如果设置的值是一个对象,那么这个对象也应该是响应式的
                that.walk(aValue)

                // watcher.update
                // 发布通知,让所有订阅者更新内容
                dep.notify()
            }
        })
    }
} 

五、Watcher模块

Watcher的作用就是将Compile解析的结果和Observer观察的对象关联起来,建立关系,当Observer观察的数据发生变化是,接收通知(dep.notify)告诉Watcher,Watcher在通过Compile更新DOM。这里面涉及一个发布者-订阅者模式的思想。

Watcher是连接Compile和Observer的桥梁。

我们在Watcher的构造函数中,需要传递三个参数:

  • vm :vue实例
  • expr:vm.$data中数据的名字(key)
  • callback:当数据发生改变时,所执行的回调函数

注意,为了获取深层数据对象,这里我们需要引用之前声明的getVMData()方法。

定义Watcher

constructor(vm,expr,callback){
    this.vm = vm
    this.expr = expr 
    this.callback = callback
    
    //
    this.oldValue = this.getVMData(vm,expr)
    //
}

暴露update()方法,用于在数据更新时更新页面

我们应该在什么情况更新页面呢?

我们应该在Watcher中实现一个update方法,对新值和旧值进行比较。当数据发生改变时,执行回调函数。

update() {
    // 对比expr是否发生改变,如果改变则调用callback
    let oldValue = this.oldValue
    let newValue = this.getVMData(this.vm,this.expr)

    // 变化的时候调用callback
    if(oldValue !== newValue) {
        this.callback(newValue,oldValue)
    }
}

关联Watcher与Compile

以插值表达式为例:(下文也会以这个例子进行说明) 当我们在控制台修改vm.msg的值的时候,需要重新渲染DOM,所以我们还需要通过Watcher侦听expr值的变化。

// compile.js
mustache(node, vm) {
    let txt = node.textContent
    let reg = /\{\{(.+)\}\}/
    if (reg.test(txt)) {
        let expr = RegExp.$1
         node.textContent = txt.replace(reg, this.getVMData(vm, expr))
         
         // 侦听expr值的变化。当expr的值发生改变时,执行回调函数
        new Watcher(vm, expr, newValue => {
            node.textContent = txt.replace(reg, newValue)
        })
    }
},

那么我们应该在什么时候调用update方法,触发回调函数呢?

由于我们在上文中已经在Observer实现了响应式数据,所以在数据发生改变时,必然会触发set方法。所以我们在触发set方法的同时,还需要调用watcher.update方法,触发回调函数,修改页面。

// observer.js
defineReactive(obj,key,value) {
    ...
    set(aValue){
        if(value === aValue) return
        value = aValue
        // 如果设置的值是一个对象,那么这个对象也应该是响应式的
        that.walk(aValue)

        watcher.update
    }
}

那么问题来了,我们在解析不同的指令时,new 了很多个Watcher,那么这里要调用哪个Watcher的update方法呢?如何通知所有的Watcher,告诉他数据发生了改变了呢?

所以这里又引出了一个新的概念:发布者-订阅者模式。

什么是发布者-订阅者模式?

发布者-订阅者模式也叫观察者模式。 他定义了一种一对多的依赖关系,即当一个对象的状态发生改变时,所有依赖于他的对象都会得到通知并自动更新,解决了主体对象与观察者之间功能的耦合。

这里我们用微信公众号为例来说明这种情况。

譬如我们一个班级都订阅了公众号,那么这个班级的每个人都是订阅者(subscriber),公众号则是发布者(publisher)。如果某一天公众号发现文章内容出错了,需要修改一个错别字(修改vm.$data中的数据),是不是要通知每一个订阅者?总不能学委那里的文章发生了改变,而班长的文章没有发生改变吧。在这个过程中,发布者不用关心谁订阅了它,只需要给所有订阅者推送这条更新的消息即可(notify)。

所以这里涉及两个过程:

  • 添加订阅者:addSub(watcher)
  • 推送通知:notify(){ sub.update() }

在这个过程中,充当发布者角色的是每一个订阅者所共同依赖的对象。

我们在Watcher中定义一个类:Dep(依赖容器)。在我们每次new一个Watcher的时候,都往Dep里面添加订阅者。一旦Observer的数据发生改变了,则通知Dep发起通知(notify),执行update函数更改DOM即可。

// watcher.js
// 订阅者容器,依赖收集
class Dep {
    constructor(){
        // 初始化一个空数组,用来存储订阅者
        this.subs = []
    }

    // 添加订阅者
    addSub(watcher){
        this.subs.push(watcher)
    }
 
    // 通知
    notify() {
        // 通知所有的订阅者更改页面
        this.subs.forEach(sub => {
            sub.update()
        })
    }
    
}

接下来我们的思路就很明确了,就是在每次new一个Watcher的时候,将它存储到Dep容器中。即将Dep与Watcher关联到一起。我们可以为Dep添加一个类属性target来存储Watcher对象,即我们需要在Watcher的构造函数中,将this赋给Dep.target。

还是以上面这个图为例,我们分析下解析插值表达式的流程:

  1. 首先我们会进入Observer劫持data中的数据msg,这里我们会进入Observer中的get方法;
  2. 劫持后我们会判断el是否存在,存在的话则编译插值表达式进入Compile;
  3. 如果此时劫持的数据msg发生改变,则会通过mustache中的Watcher来侦听数据的改变;
  4. 在Watcher的构造函数中,通过this.oldValue = this.getVMData(vm, expr)方法会在一次进入Observer中的get方法,然后程序执行完毕。

所以我们也就不难发现添加订阅者的时机,代码如下:

  • 将Watcher添加到订阅者数组中,如果数据发生改变,则为所有订阅者发起通知
// Observer.js
// 定义响应式数据
defineReactive(obj,key,value) {
    // defineProperty 会改变this指向
    let that = this
    let dep = new Dep()
    Object.defineProperty(obj,key,{
        enumerable:true,
        configurable: true,
        get(){
            // 如果Dep.target存在,即存在watcher 对象,则存储到订阅者数组中
            // debugger
            Dep.target && dep.addSub(Dep.target)
            return value
        },
        set(aValue){
            if(value === aValue) return
            value = aValue
            // 如果设置的值是一个对象,那么这个对象也应该是响应式的
            that.walk(aValue)

            // watcher.update
            // 发布通知,让所有订阅者更新内容
            dep.notify()
        }
    })
}
  • 将Watcher存储到Dep容器中后,将Dep.target置为空,以便下一次存储Watcher
// Watcher.js
constructor(vm,expr,callback){
    this.vm = vm
    this.expr = expr 
    this.callback = callback

    Dep.target = this
    // debugger
    this.oldValue = this.getVMData(vm,expr)

    Dep.target = null
}

Watcher.js完整代码如下:

// Watcher.js

class Watcher {
    /**
     * 
     * @param {*} vm 当前的vue实例 
     * @param {*} expr data中数据的名字
     * @param {*} callback  一旦数据改变,则需要调用callback
     */
    constructor(vm,expr,callback){
        this.vm = vm
        this.expr = expr 
        this.callback = callback

        Dep.target = this

        this.oldValue = this.getVMData(vm,expr)

        Dep.target = null
    }

    // 对外暴露的方法,用于更新页面
    update() {
        // 对比expr是否发生改变,如果改变则调用callback
        let oldValue = this.oldValue
        let newValue = this.getVMData(this.vm,this.expr)

        // 变化的时候调用callback
        if(oldValue !== newValue) {
            this.callback(newValue,oldValue)
        }
    }

    // 只是为了说明原理,这里偷个懒,就不抽离出公共js文件了
    getVMData(vm,expr) {
        let data = vm.$data
        expr.split('.').forEach(key => {
            data = data[key]
        })
        return data
    }
}

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

    // 添加订阅者
    addSub(watcher){
        this.subs.push(watcher)
    }
 
    // 通知
    notify() {
        this.subs.forEach(sub => {
            sub.update()
        })
    }
    
}

至此,我们就已经实现了Vue框架的基本功能了。

本文只是通过用最简单的方式来模拟vue框架的基本功能,所以在细节上的处理和代码质量上肯定会牺牲很多,还请大家见谅。

文中难免会有一些不严谨的地方,欢迎大家指正,有兴趣的话大家可以一起交流下