Vue.js 响应式原理

🌙
手机阅读
本文目录结构

问题

Vue.js 响应式原理

答案

Vue 内部使⽤了 Object.defineProperty() 来实现数据响应式,通过这个函数可以监听到 set 和 get 的事件

var data = { name: 'poetries' }
observe(data)
let name = data.name // -> get value
data.name = 'yyy' // -> change value
function observe(obj) {
    // 判断类型
    if (!obj || typeof obj !== 'object') {
        return
    }
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key])
    })
}
function defineReactive(obj, key, val) {
    // 递归⼦属性
    observe(val)
    Object.defineProperty(obj, key, {
        // 可枚举
        enumerable: true,
        // 可配置
        configurable: true,
        // ⾃定义函数
        get: function reactiveGetter() {
            console.log('get value')
            return val
        },
        set: function reactiveSetter(newVal) {
            console.log('change value')
            val = newVal
        }
    })
}

以上代码简单的实现了如何监听数据的 set 和 get 的事件,但是仅仅如此 是不够的,因为⾃定义的函数⼀开始是不会执⾏的。只有先执⾏了依赖收集, 从能在属性更新的时候派发更新,所以接下来我们需要先触发依赖收集

<div>
    {{name}}
</div>
  • 在解析如上模板代码时,遇到 就会进⾏依赖收集。
  • 接下来我们先来实现⼀个 Dep 类,⽤于解耦属性的依赖收集和派发更新操作
// 通过 Dep 解耦属性的依赖和更新操作
class Dep {
    constructor() {
        this.subs = []
    }
    // 添加依赖
    addSub(sub) {
        this.subs.push(sub)
    }
    // 更新
    notify() {
        this.subs.forEach(sub => {
            sub.update()
        })
    }
}
// 全局属性,通过该属性

以上的代码实现很简单,当需要依赖收集的时候调⽤ addSub ,当需要派发 更新的时候调⽤ notify 。

接下来我们先来简单的了解下 Vue 组件挂载时添加响应式的过程。在组件挂 载时,会先对所有需要的属性调⽤ Object.defineProperty() ,然后实例 化 Watcher ,传⼊组件更新的回调。在实例化过程中,会对模板中的属性进 ⾏求值,触发依赖收集。

因为这⼀⼩节主要⽬的是学习响应式原理的细节,所以接下来的代码会简略的表达触发依赖收 集时的操作。

class Watcher {
    constructor(obj, key, cb) {
        // 将 Dep.target 指向⾃⼰
        // 然后触发属性的 getter 添加监听
        // 最后将 Dep.target 置空
        Dep.target = this
        this.cb = cb
        this.obj = obj
        this.key = key
        this.value = obj[key]
        Dep.target = null
    }
    update() {
        // 获得新值
        this.value = this.obj[this.key]
        // 调⽤ update ⽅法更新 Dom
        this.cb(this.value)
    }
}

以上就是 Watcher 的简单实现,在执⾏构造函数的时候将 Dep.target 指 向⾃身,从⽽使得收集到了对应的 Watcher ,在派发更新的时候取出对应的 Watcher 然后执⾏ update 函数。

接下来,需要对 defineReactive 函数进⾏改造,在⾃定义函数中添加依赖收集和派发更新 相关的代码

function defineReactive(obj, key, val) {
    // 递归⼦属性
    observe(val)
    let dp = new Dep()
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            console.log('get value')
            // 将 Watcher 添加到订阅
            if (Dep.target) {
                dp.addSub(Dep.target)
            }
            return val
        },
        set: function reactiveSetter(newVal) {
            console.log('change value')
            val = newVal
            // 执⾏ watcher 的 update ⽅法
            dp.notify()
        }
    })
}

以上所有代码实现了⼀个简易的数据响应式,核⼼思路就是⼿动触发⼀次属性 的 getter 来实现依赖收集。

现在我们就来测试下代码的效果,只需要把所有的代码复制到浏览器中执⾏,就会发现⻚⾯的 内容全部被替换了

var data = { name: 'poetries' }
observe(data)
function update(value) {
    document.querySelector('div').innerText = value
}
// 模拟解析到 `{{name}}` 触发的操作
new Watcher(data, 'name', update)
// update Dom innerText
data.name = 'yyy'

Object.defineProperty 的缺陷

以上已经分析完了 Vue 的响应式原理,接下来说⼀点 Object.defineProperty 中的缺 陷。

如果通过下标⽅式修改数组数据或者给对象新增属性并不会触发组件的重新渲染,因为 Object.defineProperty 不能拦截到这些操作,更精确的来说,对于数组⽽⾔,⼤部分 操作都是拦截不到的,只是 Vue 内部通过重写函数的⽅式解决了这个问题。 对于第⼀个问题, Vue 提供了⼀个 API 解决

export function set (target: Array<any> | Object, key: any, val: any): any
    // 判断是否为数组且下标是否有效
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        // 调⽤ splice 函数触发派发更新
        // 该函数已被重写
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
    }
    // 判断 key 是否已经存在
    if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
    }
    const ob = (target: any).__ob__
    // 如果对象不是响应式对象,就赋值返回
    if (!ob) {
        target[key] = val
        return val
    }
    // 进⾏双向绑定
    defineReactive(ob.value, key, val)
    // ⼿动派发更新
    ob.dep.notify()
    return val
}

对于数组⽽⾔, Vue 内部重写了以下函数实现派发更新

// 获得数组原型
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// 重写以下函数
const methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
]
methodsToPatch.forEach(function (method) {
    // 缓存原⽣函数
    const original = arrayProto[method]
    // 重写函数
    def(arrayMethods, method, function mutator (...args) {
        // 先调⽤原⽣函数获得结果
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        // 调⽤以下⼏个函数时,监听新数据
        switch (method) {
            case 'push':
            case 'unshift':
            inserted = args
            break
            case 'splice':
            inserted = args.slice(2)
            break
        }
        if (inserted) ob.observeArray(inserted)
        // ⼿动派发更新
        ob.dep.notify()
        return result
    })
})

编译过程

想必⼤家在使⽤ Vue 开发的过程中,基本都是使⽤模板的⽅式。那么你有过 「模板是怎么在浏览器中运⾏的」这种疑虑嘛?

⾸先直接把模板丢到浏览器中肯定是不能运⾏的,模板只是为了⽅便开发者进⾏开发。 Vue 会通过编译器将模板通过⼏个阶段最终编译为 render 函数,然后通过执⾏ render 函数⽣成 Virtual DOM 最终映射为真实 DOM 。

接下来我们就来学习这个编译的过程,了解这个过程中⼤概发⽣了什么事情。这个过程其 中⼜分为三个阶段,分别为:

  • 将模板解析为 AST
  • 优化 AST
  • 将 AST 转换为 render 函数

在第⼀个阶段中,最主要的事情还是通过各种各样的正则表达式去匹配模板中的内容,然后将 内容提取出来做各种逻辑操作,接下来会⽣成⼀个最基本的 AST 对象

{
    // 类型
    type: 1,
    // 标签
    tag,
    // 属性列表
    attrsList: attrs,
    // 属性映射
    attrsMap: makeAttrsMap(attrs),
    // ⽗节点
    parent,
    // ⼦节点
    children: []
}

然后会根据这个最基本的 AST 对象中的属性,进⼀步扩展 AST 。

当然在这⼀阶段中,还会进⾏其他的⼀些判断逻辑。⽐如说对⽐前后开闭标签是否⼀致, 判断根组件是否只存在⼀个,判断是否符合 HTML5 Content Model 规范等等问题。

接下来就是优化 AST 的阶段。在当前版本下, Vue 进⾏的优化内容其实还是不多的。只 是对节点进⾏了静态内容提取,也就是将永远不会变动的节点提取了出来,实现复⽤

Virtual DOM ,跳过对⽐算法的功能。在下⼀个⼤版本中, Vue 会在优化 AST 的阶段 继续发⼒,实现更多的优化功能,尽可能的在编译阶段压榨更多的性能,⽐如说提取静态 的属性等等优化⾏为。

最后⼀个阶段就是通过 AST ⽣成 render 函数了。其实这⼀阶段虽然分⽀有很多,但是 最主要的⽬的就是遍历整个 AST ,根据不同的条件⽣成不同的代码罢了。

NextTick 原理分析

nextTick 可以让我们在下次 DOM 更新循环结束之后执⾏延迟回调,⽤于 获得更新后的 DOM 。

在 Vue 2.4 之前都是使⽤的 microtasks ,但是 microtasks 的优先级过⾼,在某些 情况下可能会出现⽐事件冒泡更快的情况,但如果都使⽤ macrotasks ⼜可能会出现渲染 的性能问题。所以在新版本中,会默认使⽤ microtasks ,但在特殊情况下会使⽤ macrotasks ,⽐如 v-on 。

对于实现 macrotasks ,会先判断是否能使⽤ setImmediate ,不能的话降级为 MessageChannel ,以上都不⾏的话就使⽤ setTimeout

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    macroTimerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else if (
    typeof MessageChannel !== 'undefined' &&
    (isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]')
) {
    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = flushCallbacks
    macroTimerFunc = () => {
        port.postMessage(1)
    }
} else {
    macroTimerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

以上代码很简单,就是判断能不能使⽤相应的 API

更多面试题

如果你想了解更多的前端面试题,可以查看本站的WEB前端面试题 ,这里基本包涵了市场上的所有前端方面的面试题,也有一些大公司的面试图,可以让你面试更加顺利。

面试题
HTML CSS JavaScript
jQuery Vue.js React
算法 HTTP Babel
BootStrap Electron Gulp
Node.js 前端经验相关 前端综合
Webpack 微信小程序 -

这些题库还在更新中,如果你有不错的面试题库欢迎分享给我,我整理后放上来;人人为我,我为人人,互帮互助,共同提高,祝大家都拿到心仪的Offer!

AXIHE / 精选资源

浏览全部教程

面试题

学习网站

前端培训
自己甄别

前端书籍

关于朱安邦

我叫 朱安邦,阿西河的站长,在杭州。

以前是一名平面设计师,后来开始接接触前端开发,主要研究前端技术中的JS方向。

业余时间我喜欢分享和交流自己的技术,欢迎大家关注我的 Bilibili

关注我: Github / 知乎

于2021年离开前端领域,目前重心放在研究区块链上面了

我叫朱安邦,阿西河的站长

目前在杭州从事区块链周边的开发工作,机械专业,以前从事平面设计工作。

2014年底脱产在老家自学6个月的前端技术,自学期间几乎从未出过家门,最终找到了满意的前端工作。更多>

于2021年离开前端领域,目前从事区块链方面工作了