【Vue】响应式原理,definProperty和Proxy

2,108 阅读9分钟

写在前面

MVVM框架将web开发引入了新的境界。昔日的业务代码数据操作与DOM操作混在一起,既需要考虑数据操作是否正确,还需要考虑dom操作是否高效。当业务逻辑复杂起来,其复杂程度无需多言。

而有了这样的一个框架,我们基本不用再考虑过多的DOM细节。专注于维护业务逻辑,数据结构,可以说为开发人员减少了很大的压力。

下面进入正题,我们一起来实现一个简易的响应式框架,以点带面剖析原理。

框架实现

vue.js 是采用数据劫持结合发布/订阅模式,通过Object.defineProperty()劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听函数

框架实现主要包括以下三方面内容:

  1. 数据劫持(代理)
  2. 发布/订阅
  3. 模板编译

前两点实现响应式,第三点解析自定义dom结构,解析自定义指令,添加订阅实现响应等

数据代理/劫持

主要使用两种方法:

  1. Vue2使用Object.defineProperty()
  2. Vue3使用Proxy代理

Observer类

首先我们创建一个Observer类用来为数据定义访问器属性。主要功能是为传入对象的每个属性定义访问器属性,这是实现响应式的第一步。

class Observer {
    constructor (data) {
        this.observe(data)
    }
    // 观测对象
    observe (data) {
        // 注重原理,这里目的是判定数据是否为对象,更精确的还有Object.prototype.toString.call()
        if(typeof data === 'object' && typeof data !== null) {
            // 遍历 data 对象属性,调用 defineReactive 方法
            for(let key in data) {
                this.defineReactive(data, key, data[key])
            }
        }
    }
    // defineReactive方法仅仅是将data的属性转换为访问器属性
    defineReactive (data, key, val) {
        // 递归观测子属性
        this.observe(val)
        // 定义一个依赖收集器
        const dep = new Dep() 
        // 定义访问器属性,响应式基础
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get () {
                // 在这里收集依赖
                Dep.target && dep.add(Dep.target)
                return val  // 这里用到了闭包
            },
            set: newVal => {
                // 判断数据是否更新
                if(val !== newVal){
                    // 对新值进行观测,同样是为了实现响应式
                    this.observe(newVal)
                    // 更新闭包中的值
                    val = newVal
                    // 当数据改变后通知当前dep收集到的所有Watcher
                    dep.notify() // 通知订阅者更新
                }
            }
        })
    }
}

由于语言层面的限制,我们只能监听到data对象的常规属性。而对象不同于常规类型,属于引用类型的数据,保存在堆内存中,它的比较更为复杂,无法直接利用等号完成,这也就是为什么Vue中如果修改对象某一个属性时可能会出现没有响应到的情况,这时就需要使用Vue.set()函数强制更新,或者使用整体替换的方式,新建一个对象覆盖原对象。

事件的发布/订阅

到这里,我们实现了Observer类,访问器属性准备就绪.但是光有基础还不够,我们还差一些调度——Dep类和Watcher类。

Dep是Dependency的缩写,意为依赖,是用来收集依赖并将访问器属性与Watcher连接起来的桥梁。每当setter发现数据更新,就通知相应属性的dep实例通知名下所有watcher更新数据,这就是响应,也就是之前的dep.notify()

Dep类

/**
 * 依赖收集,用来关联watcher和observer
 */
class Dep {
    constructor () {
        // 储存订阅者——Watcher实例
        this.watchers = []
    }
    add (sub) {
        this.watchers.push(sub)
    }
    notify () {
        // 通知收集到的所有观察者,数据更新啦!执行操作
        this.watchers.forEach(watcher => watcher.update())
    }
}

Dep类很简单,存储Watcher,然后在适当的时机调用update()

Watcher类

/**
 * watcher
 * @param {*} vm  当前实例
 * @param {*} expr 表达式,就是数据获取途径,例如:data.style.color,data.name
 * @param {*} fn  回调函数
 */
class Watcher {
    constructor (vm, expr, fn) {
        this.callback = fn
        this.vm = vm
        this.expr = exp // 类似于data.name的字符串
        // 添加到订阅中 ————********下面三行划重点*********————
        Dep.target = this
        this.fire()
        Dep.target = null // 防止watcher重复添加
    }
    // 这里是为了触发Observer中定义的getter属性,执行dep.add(Dep.target)
    // 从而将watcher与Observer联系起来,函数叫什么本身并不重要
    fire () {
        // 利用reduce,将属性路径(如data.address.city)一层层剥开
        return this.expr.split('.').reduce((result, key) =>{
            return result[key]
        }, this.vm.$data)
    }
    update () {
        // 获取最新值,传入回调函数
        const val = this.fire()
        this.callback(val)
    }
}

接下来让我们把目光放到这三行上:

Dep.target = this
this.fire()
Dep.target = null

首先我们思考,我们给data的属性都设置了访问器,数据变化时可以执行相应的逻辑,为什么还需要再使用发布/订阅模式来包一层呢?

由于模块化的限制,我们无法将对应的回调函数传入data属性的getter和setter中,因为我们不知道在网页中究竟哪些地方会使用到data里面的内容,不可能提前写好,只能动态添加

所以这里需要dep实例来收集依赖,并适当的时机(setter中)执行dep.notify()

如何收集呢依赖呢?

Dep.target && dep.add(Dep.target)

结合上面的三行代码可以知道:

Dep.target = this   // 将当前实例添加到Dep.target,暴露到外部
this.fire()         // 触发getter,将Dep.target添加到dep实例中,收集到了依赖
Dep.target = null   // 防止可能出现的意外,例如不小心添加了相同的Watcher

响应式的原理大致就是这样了,下面是框架的实现

模板编译

Vue模板编译的过程就是将template中的内容编译成render函数的形式。再由render函数来生成dom结构。而传入render函数的参数实际上就是虚拟dom。大家不要把虚拟dom想的那么高深,实际上就是json格式的对象而已。每个对象主要有三个属性:

  1. tag:标签名,如div
  2. properties:如style,class,id等dom属性
  3. children:数组,存放子元素,子元素结构与父元素相同。

这样形成的结构实际上是一棵树,所以也称为dom树。经过这样一番处理,template中的内容就变成了虚拟dom的格式,并且传给render函数render(tag, properties, children)

但是这里没有这么复杂,只是单纯的遍历dom结构,演示Watcher类和Dep类的使用

Vue 类

class Vue {
    // options就是data, methods等常见配置
    constructor(options) {
        // 绑定根元素
        this.$el =  document.querySelector(options.el)
        this.$data = options.data
        // 用到了Observer为data设置访问器属性
        new Observer(this.$data)
        // 将方法挂载到this,接可以通过this直接调用
        Object.assign(this, options.methods)
        // 用来匹配{{}}双括号语法的正则表达式,多处用到,干脆绑定到this
        this.reg = new RegExp(/\{\{(.+?)\}\}/, 'g')
        // 缓存dom节点,为什么使用文档碎片,后面会提到
        let fragment = this.createFragment(this.$el)
        // 开始编译
        this.compile(fragment)
        // 将编译完的文本节点替换回去
        this.$el.appendChild(fragment)
        // 将data代理到this上,我们使用的时候大都是通过this直接调用
        for (let key in this.$data) {
            Object.defineProperty(this, key, {
                enumerable: true,
                get () {
                    return this.$data[key]
                },
                set (newVal) {
                    this.$data[key] = newVal
                }
            })
        }
    }
    compile (fragment) {
        // 将类数组结构展开遍历
        [...fragment.childNodes].forEach(node => {
            // 如果是元素节点则递归遍历
            if (node.nodeType === 1) {
                this.compileElement(node)
                this.compile(node)
            } else {
                // 除去元素节点,就是文本节点了
                this.compileText(node)
            }
        })
    }
    compileElement (node) {
        // 元素节点的编译主要是转换其属性,所以这里遍历属性
        [...node.attributes].forEach(attr => {
            // expr就是expression的简写,是表达式的意思
            // 这里name是属性名,value是属性值,就是我们需要编译替换的部分
            // 通过解构赋值给value重新命名为expr
            let {name, value: expr} = attr
            // 判断是不是指令:v-bind,v-model,v-on
            if (name.startsWith('@') || name.startsWith(':') || name.startsWith('v-')) {
                // 如果属性包含这些字符说明是自定义属性,需要编译
                // 否则是原生属性,跳过
                this.compileInstruction(node, name, expr)
                // 自定义节点编译完就删除
                node.removeAttribute(name)
            }
        })
    }
    /**
     * 编译自定义属性,指令等
     * @param node  dom节点
     * @param name  属性名
     * @param expr  表达式(属性值)
     */
    compileInstruction (node, name, expr) {
        // 用来匹配v-on:中的on(举例)
        let reg = new RegExp(/v-(.+?)\:/)
        if (reg.test(name)) {
            // 举例 v-on:click / v-bind:class
            // type就是on或bind
            let [, type] = name.match(reg) // 获取匹配内容
            // 获取事件名或属性名,例如click或class
            let prop = name.substr(name.indexOf(':') + 1) 
            // 调用策略模式封装的算法,相关代码在后面
            Instructions[type](this, node, prop, expr)
        } else if (name.startsWith('v-')) {
            // type为指令名
            let [, type] = name.split('-')
            Instructions[type](this, node, expr)
        } else {
            // prop是对应的事件类型或者属性
            let [type, ...prop] = name
            if (type === '@') {
                Instructions['on'](this, node, prop.join(''), expr)
            } else if (type === ':') {
                Instructions['bind'](this, node, prop.join(''), expr)
            }
        }
    }
    // 编译文本节点
    compileText (node) {
        // 获取文本节点值
        const nodeValue = node.nodeValue
        // 匹配 {{}}
        if (this.reg.test(nodeValue)) {
            // 匹配到双括号说明需要替换
            this.updateText(node, nodeValue)
        }
    }
    updateText (node, originText) {
        node.nodeValue = originText.replace(this.reg, (match, content) => {
            // 这里的content就是我们的表达式
            // 例如:我叫{{name}},匹配的content就是'name'
            // 这个表达式用于getData函数获取数据,根据属性获取数据
            
            // 这里需要为匹配到双括号表达式的文本节点添加订阅
            // 利用箭头函数没有this的特性
            // Watcher被触发时依然可以访问到这里的上下文
            new Watcher(this, content.trim(), () => {
                // *******这里注意*******
                // this.getContentValue返回的就是最新值
                // 不直接调用this.updateText是为了防止重复添加订阅
                // 所以将一部分逻辑分离出来单独作为一个函数
                // 实际上getContentValue在编译的时候是不会调用的
                // 只有数据更新才会调用到
                node.nodeValue = this.getContentValue(originText)
            })
            // 根据表达式content获取到相应的值并替换
            return this.getData(content.trim())
        })
    }
    // 这里为什么多引入一个函数?可以看到这里的逻辑基本是一样的
    // 因为如果递归调用updateText的话,不仅会重复添加订阅
    // 还会导致originText的值改变,不再为原始双括号表达式 => 我叫{{name}}
    // 这样后面就无法匹配并更新这个表达式的值了
    // 只有拿到这个文本才能每次替换新的值进去
    // 如果递归调用,这个值就会变成例如:我叫小明
    // 再往后数据再改变时,正则表达式就匹配不到双括号无法替换了
    // 幸运的是,有了闭包,可能你都没意识到你使用了闭包,就这么解决了
    getContentValue(originText) {
        return originText.replace(this.reg, (match, content) => {
            return this.getData(content.trim())
        })
    }
    // 根据表达式拿到对应的数据
    getData (expr) {
        // 一层层的解析,获取到最终数据
        return expr.split('.').reduce((data, key) => {
            return data[key]
        }, this.$data)
    }
    // 根据表达式设置data中对应的值
    setData (expr, value) {
        expr.split('.').reduce((data, current, index, arr) => {
            if (index === arr.length - 1) {
                return data[current] = value
            }
            return data[current]
        }, this.$data)
    }
    // 创建文档碎片
    createFragment (node) {
        let fragment = document.createDocumentFragment()
        do {
            // 插入碎片相当于从页面中remove
            fragment.appendChild(node.firstChild)
        } while (node.firstChild)
        return fragment
    }
}

Instructions对象

封装了常见的指令:

// 指令处理对象
const Instructions = {
    // v-model 实现数据双向绑定
    model (vm, node, expr) {
        // input => vm.$data
        node.addEventListener('input', event => {
            vm.setData(expr, event.target.value)
        })
        // vm.$data => input
        node.value = vm.getData(expr)
    },
    // v-on / '@' 绑定事件
    on (vm, node, eventType, handler) {
        // handler绑定this,否则this指向event.target
        node.addEventListener(eventType, vm[handler].bind(vm))
    },
    // v-bind / ':' 绑定属性
    bind (vm, node, prop, expr) {
        switch (prop) {
            // v-bind:style
            case 'style':
                new Watcher(vm, expr, value => {
                    // 驼峰命名的CSS属性
                    Object.assign(node.style, value)
                })
                Object.assign(node.style, vm.getData(expr))
                break
            // v-bind:class
            case 'class':
                // 由于这里绑定的数组,能够编译出来,但响应方面可能没有做到
                // 这里就不做了,感兴趣的话可以自己研究下如何响应数组
                // 当然,直接用新的覆盖还是可以的
                new Watcher(vm, expr, list => {
                    node.classList = list.join(' ')
                })
                node.classList = vm.getData(expr).join(' ')
                break
            default:
                throw new Error('can\'t resolve the value ' + expr)
        }
    }
}

示例

先是DOM结构:

<style>
    .red {
        color: red
    }
</style>
<div id="app">
    <div class="header"
         :style="style"
         v-on:click="sayHello"
    >
        姓名:{{name}}
    </div>
    <div>
        <div>省份:{{address.province}}, 市区:{{address.city}}</div>
        <div :class="myClass">市区:{{address.city}}</div>
    </div>
    <div :class="myClass">我叫:{{name}}</div>
    <input type="text" id="input" v-model="name">
    <button @click="onClick">改变颜色</button>
</div>

DOM
然后是Vue实例:

new Vue({
    el: '#app',
    data: {
        style: {
            color: 'green',
            fontSize: '10px'
        },
        myClass: ['red'],
        name: '小明',
        address: {
            province: '陕西省',
            city: '汉中市'
        }
    },
    methods: {
        onClick (e) {
            this.style = {
                color: 'red',
                fontSize: '30px'
            }
        },
        sayHello (e) {
            console.log('你好,我叫', this.name)
        }
    }
})

动态示例

一些问题

1. Object.defineProperty的缺点

在Vue2使用Object.defineProperty实现响应式,但这个方法有三个缺点:

  1. 对于庞大的对象需要一次性递归到底,效率很低
  2. 无法监听新增或删除属性,因为属性劫持只会在初始化时执行一次,这也是为什么,需要用到的属性即使是空值也要提前写在data中才行
  3. 无法原生监听数组,需要对数组进行包装

2. 如何使用Object.defineProperty监听数组?

监听数组需要做一些额外的处理,替换原型

// 创建一个原型指向Array.prototype的对象
const arrProto = Object.create(Array.prototype) 
const methods = ['push', 'pop', 'splice', 'shift', 'unshift']
// 重新定义这些数组方法
methods.forEach(method => {
    arrProto[method] = function (...args) {
        console.log('更新视图!') // 在这里更新视图
        Array.prototype[method].apply(this, args)
    }
})

// 修改defineReactive函数
defineReactive (data, key, val) {
    // 递归观测子属性
    this.observe(val)
    
    // 监听数组,更换新原型
    if (Array.isArray(value)) {
        value.__proto__ = arrProto
    }
    
    ···
    ···
}

3. Vue3使用Proxy实现响应式

/**
 * Vue3使用Proxy实现响应式
 */
function proxyObserve (target = {}) {
    // 不是对象或数组则直接返回
    if (
        Object.prototype.toString.call(target) !== '[object Object]' &&
        !Array.isArray(target)
    ) {
        return target
    }

    return new Proxy(target, {
        get (target, key, receiver) {
            const result = Reflect.get(target, key, receiver)
            if (Reflect.ownKeys(target).includes(key)) {
                console.log('get', key) // 只监听对象,不监听原型
            }
        
            return proxyObserve(result) // 递归监听(惰性的,不会一次性递归完全)
        },
        set (target, key, value, receiver) {
            if (target[key] === value) {
                return true // 值没有变化则直接返回
            }
            console.log('set', key, value)
            return Reflect.set(target, key, value, receiver)
        },
        deleteProperty (target, key) {
            console.log('delete', key)
            return Reflect.deleteProperty(target, key)
        }
    })
}

4. Proxy的优点和缺点

优点和Object.defineProperty相对应:

  1. Proxy原生支持监听数组,非常方便
  2. Proxy可以监听到新增、删除属性
  3. 在面对庞大对象时,不会一次性递归到底,默认只会代理第一层。当访问到对象属性或数组属性时才会进一步监听,效率更高

缺点主要是:Proxy的兼容性稍差一点,并且无法使用polyfill补救。

5. 关于Reflect对象

这里简要提一下Reflect对象。JavaScript中有很多的工具方法是定义在Object对象中的,但是实际上,这些方法与Object这个数据类型并没有什么关系。例如,Object.definProperty, Object.hanOwnProperty等等。

Object承担了很多本不应该承担的责任,Reflect的出现就是为了将这些方法从Object中抽离出来,使Object单纯作为一种数据类型使用。并且为了和Proxy一一对应。总结来说,有以下几点:

  1. 将Object上一些明显属于语言内部的方法放到Reflect对象上(现阶段同时存在,以后会慢慢移除)。
  2. 修改某些Object方法的返回结果,使其变得更合理。例如Object.defineProperty在无法定义属性是抛错,而Refelct.defineProperty则会返回false。
  3. 让Object操作都变成函数行为(函数式编程)。某些操作是命令式,例如:name in obj 和 delete obj[name]。而Reflect对象让其vi按成了函数操作,如:Reflect.has(obj, name) 和 Reflect.deleteProperty(obj, name)
  4. Reflect的方法和Proxy一一对应,只要是Proxy对象的方法,就能在Reflect上找到。这使得Proxy可以使用Reflect的方法轻松完成对象的默认行为

6. 实现一个MVVM里面需要那些核心模块?各个核心模块之间的关系是怎样的?

  1. 模型:就是数据
  2. 视图:用户在屏幕上看到的结构、布局和外观(UI)。
  3. 视图模型:连接视图和数据,将视图转化为数据或将数据转换为视图。如何转换?视图到数据需要通过dom事件监听,数据到视图需要观察者。视图模型层就负责调度这些流程。
    流程
    关系如下:
    关系
    对比Vue官方的图:
    是不是一模一样。重点在于,如何使用Dep类和Watcher类来添加订阅。不明白的话,可以参考上面的defineReactive函数

7. 为什么操作DOM要利用文档碎片(Fragment)?

大家都知道DOM操作很昂贵,非常消耗性能,但却不知道为什么。其实主要是因为会触发浏览器的重绘(repaint)和重排(reflow)

重绘是部分元素样式改变,需要重新绘制;重排是元素位置、大小等发生改变,浏览器要重新计算渲染树。导致渲染树的一部分或全部发生变化。渲染树重新建立后,浏览器会重新绘制页面上受影响的元素。

这些操作操作需要大量计算才能完成,所以吃性能。但是如果用文档碎片的话,将DOM元素放进内存操作,这时候就只是操作对象了,和浏览器就没有关系了。如果说对性能的影响的话,就只能是最后插入文档中的时候了。同理,也可以将display设置为none,只要浏览器显示出来的界面没有变化就ok,但一般不会这样做。。。

参考

  1. 阮一峰 《ES6标准入门》 ——Reflect
  2. 【掘金】手写一套完整的基于Vue的MVVM原理
  3. 【其他】Vue2.1.7源码学习
  4. 【掘金】50行代码的MVVM,感受闭包的艺术