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

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

1、准备

但愿准备阅读本文的你最好具有如下技能:html

  • 熟悉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框架的基本功能吧!vue

MVVM 实现思路

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

  • backbone.js

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

  • 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。

上述流程以下图所示:设计模式

2、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]
                },
            })
        })
    }
}
复制代码

3、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)
        }
    }
}
复制代码

4、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()
            }
        })
    }
} 
复制代码

5、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框架的基本功能,因此在细节上的处理和代码质量上确定会牺牲不少,还请你们见谅。

文中不免会有一些不严谨的地方,欢迎你们指正,有兴趣的话你们能够一块儿交流下

相关文章
相关标签/搜索