大前端进阶篇-Vuejs响应式原理剖析

  像React,Vue这类的框架,响应式是其最核心的特性之一。通过响应式可以实现当改变数据的时候,视图会自动变化,反之,视图变化,数据也随之更新。避免了繁琐的dom操作,让开发者在开发的时候只需要关注数据本身,而不需要关注数据如何渲染到视图。

实现原理

2.x

  在vue2.0中通过Object.defineProperty方法实现数据拦截,也就是为每个属性添加get和set方法,当获取属性值和修改属性值的时候会触发get和set方法。

let vue = {}

let data = {

    msg: 'foo'

}

Object.defineProperty(vue, 'msg', {

    enumerable: true,

    configurable: true,

    get() {

        console.log('正在获取msg属性对应的值')

        return data.msg

    },

    set(newValue) {

        if(newValue === data.msg) {

            return

        }

        console.log('正在为msg属性赋值')

        data.msg = newValue

    }

})

console.log(vue.msg)

vue.msg = 'bar'

  Object.defineProperty添加的数据拦截在针对数组的时候会出现问题,也就是当属性值为一个数组的时候,如果进行push,shift等操作的时候,虽然修改了数组,但不会触发set拦截。

  为了解决这个问题,vue在内部重写了原生的数组操作方法,以支持响应式。

3.x

在vue3.0版本中使用ES6新增的Proxy对象替换了Object.defineProperty,不仅简化了添加拦截的语法,同时也可以支持数组。

let data = {

    msg: 'foo'

}

let vue = new Proxy(data, {

    get(target, key) {

        console.log('正在获取msg属性对应的值')

        return target[key]

    },

    set(target, key, newValue) {

        if(newValue === target[key]) {

            return

        }

        console.log('正在为msg属性赋值')

        target[key] = newValue

    }

})

console.log(vue.msg)

vue.msg = 'bar'

依赖的开发模式

在vue实现响应式的代码中,使用了观察者模式。

观察者模式

观察者模式中,包含两个部分:

观察者watcher

观察者包含一个update方法,此方法表示当事件发生变化的时候需要做的事情

class Watcher {

    update() {

        console.log('执行操作')

    }

}

目标dep

目标包含一个属性和两个方法:

subs属性:用于存储所有注册的观察者。

addSub方法: 用于添加观察者。

notify方法: 当事件变化的时候,用于轮询subs中所有的观察者,并执行其update方法。

class Dep {

    constructor() {

        this.subs = []

    }

    addSub(watcher) {

        if (watcher.update) {

            this.subs.push(watcher)

        }

    }

    notify() {

        this.subs.forEach(watcher => {

            watcher.update()

        })

    }

}

使用方式

// 创建观察者和目标对象

const w = new Watcher()

const d = new Dep()

// 添加观察者

d.addSub(w)

// 触发变化

d.notify()

发布订阅模式

与观察者模式很相似的是发布订阅模式,该模式包含三个方面:

订阅者

订阅者类似观察者模式中的观察者,当事件发生变化的时候,订阅者会执行相应的操作。

发布者

发布者类似观察者模式中的目标,其用于发布变化。

事件中心

在事件中心中存储着事件对应的所有订阅者,当发布者发布事件变化后,事件中心会通知所有的订阅者执行相应操作。

与观察者模式相比,发布订阅模式多了一个事件中心,其作用是隔离订阅者和发布者之间的依赖。

vue中的on和emit就是实现的发布订阅模式,因为其和响应式原理关系不大,所以此处不再详细说明。

自实现简版vue

简化版的vue核心包含5大类,如下图:

通过实现这5大类,就可以一窥Vue内部如何实现响应式。

vue

vue是框架的入口,负责存储用户变量、添加数据拦截,启动模版编译。

Vue类:

属性

$options存储初始化Vue实例时传递的参数

$data存储响应式数据

$methods存储传入的所有函数

$el编译的模版节点

方法

_proxyData私有方法,负责将data中所有属性添加到Vue实例上。

_proxyMethods私有方法,遍历传入的函数,将非声明周期函数添加到Vue实例上。

directive静态方法,用于向Vue注入指令。

实现

// 所有声明周期方法名称

const hooks = ['beforeCreate', 'created', 'beforeMount', 'mounted',

    'beforeUpdate', 'updated', 'activated', 'deactivated', 'beforeDestroy', 'destroyed']

class Vue {

    constructor(options) {

        this.$options = Object.assign(Vue.options || {}, options || {})

        this.$data = options.data || {}

        this.$methods = options.methods || {}

        if (options && options.el) {

            this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

        }

        this._proxyData(this.$data)

        this._proxyMethods(this.$methods)

        // 实现数据拦截

        // 启动模版编译

    }

    _proxyMethods(methods) {

        let obj = {}

        Object.keys(methods).forEach(key => {

            if (hooks.indexOf(key) === -1 && typeof methods[key] === 'function') {

                obj[key] = methods[key].bind(this)

            }

        })

        this._proxyData(obj)

    }

    _proxyData(data) {

        Object.keys(data).forEach(key => {

            Object.defineProperty(this, key, {

                enumerable: true,

                configurable: true,

                get() {

                    return data[key]

                },

                set(newValue) {

                    // 数据未发生任何变化,不需要处理

                    if (newValue === data[key]) {

                        return

                    }

                    data[key] = newValue

                }

            })

        })

    }

    // 用于注册指令的方法

    static directive(name, handle) {

        if (!Vue.options) {

            Vue.options = {

                directives: {}

            }

        }

        Vue.options.directives[name] = {

            bind: handle,

            update: handle

        }

    }

}

observer

observer类负责为data对象添加数据拦截。

方法

walk轮询对象属性,调用defineReactive方法为每个属性添加setter和getter。

defineReactive添加setter和getter。

实现

class Observer {

    constructor(data) {

        this.walk(data)

    }

    // 轮询对象

    walk(data) {

        // 只有data为object对象时,才轮询其属性

        if (data && typeof data === 'object') {

            Object.keys(data).forEach(key => {

                this.defineReactive(data, key, data[key])

            })

        }

    }

    // 添加拦截

    defineReactive(data, key, val) {

        const that = this

        // 如果val是一个对象,为对象的每一个属性添加拦截

        this.walk(val)

        Object.defineProperty(data, key, {

            enumerable: true,

            configurable: true,

            get() {

                return val

            },

            set(newValue) {

                if (val === newValue) {

                    return

                }

                // 如果赋值为一个对象,为对象的每一个属性添加拦截

                that.walk(newValue)

                val = newValue

            }

        })

    }

}

在Vue的constructor构造函数中添加Observer:

constructor(options) {

        this.$options = Object.assign(Vue.options || {}, options || {})

        this.$data = options.data || {}

        this.$methods = options.methods || {}

        if (options && options.el) {

            this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

        }

        this._proxyData(this.$data)

        this._proxyMethods(this.$methods)

        // 实现数据拦截

        new Observer(this.$data)

        // 启动模版编译

        new Compiler(this)

}

directive

由于在compiler编译模版的时候,需要用到指令解析,所以此处模拟一个指令初始化方法,用于向vue实例添加内置指令。

在此处模拟实现了四个指令:

// v-text

Vue.directive('text', function (el, binding) {

    const { value } = binding

    el.textContent = value

})

// v-model

Vue.directive('model', function (el, binding) {

    const { value, expression } = binding

    el.value = value

    // 实现双向绑定

    el.addEventListener('input', () => {

        el.vm[expression] = el.value

    })

})

// v-html

Vue.directive('html', function (el, binding) {

    const { value } = binding

    el.innerHTML = value

})

// v-on

Vue.directive('on', function (el, binding) {

    const { value, argument } = binding

    el.addEventListener(argument, value)

})

compiler

compiler负责html模版编译,解析模版中的插值表达式和指令等。

属性

el保存编译的目标元素

vm保存编译时用到的vue上下文信息。

方法

compile负责具体的html编译。

实现

class Compiler {

    constructor(vm) {

        this.vm = vm

        this.el = vm.$el

        // 构造函数中执行编译

        this.compile(this.el)

    }

    compile(el) {

        if (!el) {

            return

        }

        const children = el.childNodes

        Array.from(children).forEach(node => {

            if (this.isElementNode(node)) {

                this.compileElement(node)

            } else if (this.isTextNode(node)) {

                this.compileText(node)

            }

            // 递归处理node下面的子节点

            if (node.childNodes && node.childNodes.length) {

                this.compile(node)

            }

        })

    }

    compileElement(node) {

        const directives = this.vm.$options.directives

        Array.from(node.attributes).forEach(attr => {

            // 判断是否是指令

            let attrName = attr.name

            if (this.isDirective(attrName)) {

                // v-text --> text

                // 获取指令的相关数据

                let attrNames = attrName.substr(2).split(':')

                let name = attrNames[0]

                let arg = attrNames[1]

                let key = attr.value

                // 获取注册的指令并执行

                if (directives[name]) {

                    node.vm = this.vm

                    // 执行指令绑定

                    directives[name].bind(node, {

                        name: name,

                        value: this.vm[key],

                        argument: arg,

                        expression: key

                    })

                }

            }

        })

    }

    compileText(node) {

        // 利用正则表达式匹配插值表达式

        let reg = /\{\{(.+?)\}\}/

        const value = node.textContent

        if (reg.test(value)) {

            let key = RegExp.$1.trim()

            node.textContent = value.replace(reg, this.vm[key])

        }

    }

    // 判断元素属性是否是指令,简化vue原来逻辑,现在默认只有v-开头的属性是指令

    isDirective(attrName) {

        return attrName.startsWith('v-')

    }

    // 判断节点是否是文本节点

    isTextNode(node) {

        return node.nodeType === 3

    }

    // 判断节点是否是元素节点

    isElementNode(node) {

        return node.nodeType === 1

    }

}

修改vue的构造函数,启动模版编译。

constructor(options) {

        this.$options = Object.assign(Vue.options || {}, options || {})

        this.$data = options.data || {}

        this.$methods = options.methods || {}

        if (options && options.el) {

            this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

        }

        this._proxyData(this.$data)

        this._proxyMethods(this.$methods)

        // 实现数据拦截

        new Observer(this.$data)

        // 启动模版编译

        new Compiler(this)

}

dep

dep负责收集某个属性的所有观察者,当属性值发生变化的时候,会依次执行观察者的update方法。

属性

subs记录所有的观察者

方法

addSub添加观察者

notify触发执行所有观察者的update方法

实现

class Dep {

    constructor() {

        // 存储所有的观察者

        this.subs = []

    }

    // 添加观察者

    addSub(sub) {

        if (sub && sub.update) {

            this.subs.push(sub)

        }

    }

    // 发送通知

    notify() {

        this.subs.forEach(sub => {

            sub.update()

        })

    }

}

现在的问题是何时添加观察者,何时触发更新?

从上图可以看出,应该在Observer中触发拦截的时候对Dep进行操作,也就是get的时候添加观察者,set时触发更新。

修改observer的defineReactive方法:

defineReactive(data, key, val) {

        const that = this

        // 创建dep对象

        const dep = new Dep()

        // 如果val是一个对象,为对象的每一个属性添加拦截

        this.walk(val)

        Object.defineProperty(data, key, {

            enumerable: true,

            configurable: true,

            get() {

                // 添加依赖

                // 在watcher中,获取属性值的时候,会把相应的观察者添加到Dep.target属性上

                Dep.target && dep.addSub(Dep.target)

                return val

            },

            set(newValue) {

                if (val === newValue) {

                    return

                }

                // 如果赋值为一个对象,为对象的每一个属性添加拦截

                that.walk(newValue)

                val = newValue

                // 触发更新

                dep.notify()

            }

        })

}

watcher

watcher是观察者对象,在vue对象的属性发生变化的时候执行相应的更新操作。

方法

update执行具体的更新操作

实现

class Watcher {

    // vm: vue实例

    // key: 监控的属性键值

    // cb: 回调函数,执行具体更新

    constructor(vm, key, cb) {

        this.vm = vm

        this.key = key

        this.cb = cb

        // 指定在这个执行环境下的watcher实例

        Dep.target = this

        // 获取旧的数据,触发get方法中Dep.addSub

        this.oldValue = vm[key]

        // 删除target,等待下一次赋值

        Dep.target = null

    }

    update() {

        let newValue = this.vm[this.key]

        if (this.oldValue === newValue) {

            return

        }

        this.cb(newValue)

        this.oldValue = newValue

    }

}

由于需要数据双向绑定,在compiler编译模版的时候,创建Watcher实例,并指定具体如何更新页面。

compileElement(node) {

        const directives = this.vm.$options.directives

        Array.from(node.attributes).forEach(attr => {

            // 判断是否是指令

            let attrName = attr.name

            if (this.isDirective(attrName)) {

                // v-text --> text

                // 获取指令的相关数据

                let attrNames = attrName.substr(2).split(':')

                let name = attrNames[0]

                let arg = attrNames[1]

                let key = attr.value

                // 获取注册的指令并执行

                if (directives[name]) {

                    node.vm = this.vm

                    // 执行指令绑定

                    directives[name].bind(node, {

                        name: name,

                        value: this.vm[key],

                        argument: arg,

                        expression: key

                    })

                    new Watcher(this.vm, key, () => {

                        directives[name].update(node, {

                            name: name,

                            value: this.vm[key],

                            argument: arg,

                            expression: key

                        })

                    })

                }

            }

        })

    }

想持续了解更多,不妨点赞和关注呗。

Web前端技术交流q群:1137068794,

群里可以一起学习编程,进群能领到学习资料以及源代码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,923评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,154评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,775评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,960评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,976评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,972评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,893评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,709评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,159评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,400评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,552评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,265评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,876评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,528评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,701评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,552评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,451评论 2 352