学习笔记(十三)模拟 Vue.js 响应式原理

模拟Vue.js响应式原理

数据驱动

  • 数据响应式
    • 数据模型是普通的JavaScript对象,当我们修改数据时,视图会进行相应的更新,避免了繁琐的DOM操作,提高开发效率
  • 双向绑定
    • 数据改变,视图发生相应变化,视图变化,数据发生相应的变化
    • 可以使用v-model指令在表单元素上创建双向数据绑定(自定义组件也可以自己实现v-model)
  • 数据驱动
    • 数据驱动是Vue最独特的特性之一,开发过程只需要关心数据,而不需要关心数据如何被渲染到视图

数据响应式核心原理

Vue2.x

  • 遍历对象中的属性,并通过Object.defineProperty 方法,将对象中的属性,转换成getter/setter方法
  • Object.defineProperty是ES5中新增的,不支持IE8以下浏览器

Vue3.x

  • 基于ES6新增的Proxy来实现
  • Proxy代理的是整个对象,而不是对象的属性,因此不需要对对象的属性进行遍历
  • Proxy的性能由浏览器优化,要优于Object.defineProperty
  • 同样不支持IE8以下浏览器

发布订阅模式和观察者模式

发布订阅模式

发布订阅模式包含发布者、订阅者、消息中心,发布者与订阅者相互之间不知道彼此存在,通过消息中心进行消息转发

  • 发布者

  • 订阅者

  • 消息中心broker

  • 模拟实现代码

    class EventEmitter {
        constructor() {
            this.subs = Object.create(null)
        }
    
        // 订阅 - 注册事件
        $on (topic, handler) {
            this.subs[topic] = this.subs[topic] || []
            this.subs[topic].push(handler)
        }
    
        // 发布 - 触发事件
        $emit (topic, ...args) {
            this.subs[topic]?.forEach(handler => {
                typeof handler === 'function' && handler.apply(null, args)
            });
        }
    }
    

观察者模式

观察者模式包含观察者(订阅者)和目标(发布者),不存在消息中心,被观察的目标需要知道观察者的存在

  • 观察者(订阅者)Watcher

    • update方法:处理函数
  • 目标(发布者)

    • notify方法:通知观察者,调用所有观察者的update方法
    • subs数组:储存所有的观察者
    • addSub:添加观察者
  • 模拟实现代码

    export class Target {
        constructor() {
            this.subs = []
        }
    
        addSub(sub) {
            sub && typeof sub.update === 'function' && this.subs.push(sub)
        }
    
        notify(...args) {
            this.subs.forEach(sub => sub.update.apply(null, args))
        }
    }
    
    export class Watcher {
        update(...args) {
            console.log(args)
        }
    }
    

发布订阅模式 vs 观察者模式

image-20201109182709237

模拟Vue.js响应式原理

Vue类的简单实现

  • 功能

    • 负责接收初始化的参数(选项)
    • 负责把data中的属性转换成getter/setter,并注入到Vue实例中
    • 负责调用observer监听data中所有属性的变化
    • 负责调用compiler解析指令/差值表达式
  • 属性

    • $options
      • 保存传入的选项
    • $data
      • 保存传入的data
    • $el
      • 保存挂载的DOM元素
  • 方法

    • _proxyData
      • 把data中的属性转换成getter/setter,并注入到Vue实例中
  • 实现代码

    export default class Vue {
        // 1. 通过属性保存选项的数据
        constructor(options) {
            this.$options = options || {}
            this.$data = options.data || {}
            this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
            this._proxyData(this.$data)
        }
        // 2. 把data中的属性转换成getter/setter注入到vue实例中
        _proxyData(data) {
            // Vue2.x处理方式
            Object.keys(data).forEach(key => {
                Object.defineProperty(this, key, {
                    enumerable: true,
                    configurable: true,
                    get() {
                        return data[key]
                    },
                    set(newValue) {
                        if (data[key] === newValue) {
                            return
                        }
                        data[key] = newValue
                    },
                })
            })
        }
        // 3. 调用observer对象,监听数据变化
        new Observer(this.$data)
        // 4. 调用compiler对象,解析指令和差值表达式
        new Compiler(this)
    }
    

Observer的简单实现

  • 功能

    • 负责把data中的数据转换成响应式数据
    • data中的某个属性也是对象,把该属性转换成响应式数据
    • 数据变化发送通知
  • 方法

    • walk(data)
      • 遍历data中的所有属性
    • defineReactive(data, key, value)
      • 转换属性为getter/setter
  • 实现代码

    export default class Observer {
        constructor(data) {
            this.walk(data)
        }
        walk(data) {
            // 1. 判断data是否是对象
            // 2. 遍历data的所有属性
            if (!data || typeof data !== 'object') {
                return
            }
            Object.keys(data).forEach(key => {
                this.defineReactive(data, key, data[key])
            })
        }
    
        defineReactive(data, key, value) {
            // 为什么需要第三个参数传递value,而不直接使用data[key]?
            // 在get中使用data[key]会循环触发getter,导致栈溢出
        
            // 为什么value属性在defineProperty执行完成后还可以被访问
            // 外部对内部属性存在引用,形成了闭包
    
            // 如果value是对象,则将对象的属性也转换成响应式数据
            const _this = this
    
        // 创建Dep对象收集依赖,发送通知
            let dep = new Dep()
    
            this.walk(value)
            
            Object.defineProperty(data, key, {
                configurable: true,
                enumerable: true,
                get() {
                    // 收集依赖
                    Dep.target && dep.addSub(Dep.target)
    
                    return value
                },
                set(newValue) {
                    if (value === newValue) {
                        return
                    }
                    value = newValue
                    // 当属性被重新赋值为一个对象,将对象属性也转换为响应式数据
                    _this.walk(newValue)
                    dep.notify()
                }
            })
        }
    }
    

Compiler的简单实现

  • 功能

    • 负责编译模板,解析指令/差值表达式
    • 负责页面的首次渲染
    • 当数据变化后,重新渲染视图
  • 属性

    • el
      • DOM对象
    • vm
      • vue实例
  • 方法

    compiler的方法主要用来进行DOM操作

    • compile(el)
      • 编译解析入口
    • compileElement(node)
      • 解析元素节点的指令
    • compileText(node)
      • 解析文本节点的差值表达式
    • isDirective(attrName)
      • 判断属性是否是指令
    • isElementNode(node)
      • 判断是否是元素节点
    • isTextNode(node)
      • 判断是否文本节点
  • 实现代码

    import Watcher from "./watcher.js"
    
    export default class Compiler {
        constructor(vm) {
            this.el = vm.$el
            this.vm = vm
            this.compile(this.el)
        }
        // 编译模板,处理文本节点和元素节点
        compile (el) {
            [...el.childNodes].forEach(node => {
                if (this.isTextNode(node)) {
                    this.compileText(node)
                }
                if (this.isElementNode(node)) {
                    this.compileElement(node)
                }
                // 判断childNodes并递归调用compile
                if (node.childNodes && node.childNodes.length) {
                    this.compile(node)
                }
            })
        }
    
        // 编译元素节点,处理指令
        compileElement (node) {
            // 获取并遍历元素的属性节点
            [...node.attributes].forEach(attr => {
                const { name, value } = attr
                if (this.isDirective(name)) {
                    this.update(node, name.slice(2), value)
                }
            })
        }
    
        // 处理指令
        update (node, name, value) {
            const fn = this[`${name}Updater`]
            typeof fn === 'function' && fn.call(this, node, value)
        }
    
        // 处理v-text指令
        textUpdater(node, value) {
            node.textContent = this.vm[value]
            // 创建watcher对象,当数据改变时更新视图
            new Watcher(this.vm, value, newValue => {
                node.textContent = newValue
            })
        }
    
        // 处理v-model指令
        modelUpdater(node, value) {
            node.value = this.vm[value]
            // 创建watcher对象,当数据改变时更新视图
            new Watcher(this.vm, value, newValue => {
                node.value = newValue
            })
            // 注册表单input事件实现双向绑定
            node.addEventListener('input', () => {
                this.vm[value] = node.value
            })
        }    
    
        // 处理其他指令
        // ...
    
        // 编译文本节点,处理差值表达式
        compileText (node) {
            // 使用正则表达式匹配差值表达式 {{ xxx }},并提取获取表达式内容
            let reg = /\{\{(.+?)\}\}/
            const text = node.textContent
            node.textContent = text.replace(reg, (word, key) => {
                key = key.trim()
                if (key in this.vm) {
                    // 创建watcher对象,当数据改变时更新视图
                    new Watcher(this.vm, key, newValue => {                    
                        node.textContent = text.replace(reg, (w, k) => this.vm[k.trim()])
                    })
                    return this.vm[key]
                }
                return word            
            })        
        }
    
        // 判断属性是否为指令
        isDirective (attrName) {
            return attrName.startsWith('v-')
        }
    
        // 判断是否为元素节点
        isElementNode (node) {
            return node.nodeType === 1
        }
        // 判断是否为文本节点
        isTextNode (node) {
            return node.nodeType === 3
        }
    }
    

Dep的简单实现

  • 功能

    • 收集依赖,添加观察者(watcher)
    • notify通知所有观察者
  • 属性

    • subs
      • 储存所有的观察者
  • 方法

    • addSubs(sub)
      • 添加观察者
    • notify()
      • 通知观察者,调用所有观察者的update方法
  • 实现代码

    export default class Dep {
        constructor() {
            this.subs = []
        }
     
        addSub(sub) {
            sub && typeof sub.update === 'function' && this.subs.push(sub)
        }
     
        notify(...args) {
            this.subs.forEach(sub => sub.update(...args))
        }
    }
    

Watcher的简单实现

  • 功能

    • 数据变化触发依赖,接收dep通知更新视图
    • 自身实例化的时候向Dep中添加自己
  • 属性

    • vm
      • vue实例
    • key
      • 观察的属性名称
    • cb
      • 更新时的回调处理函数
    • oldValue
      • 观察的属性数据更新之前的值
  • 方法

    • update()
      • 更新处理函数
  • 实现代码

    import Dep from "./dep.js"
    
    export default class Watcher {
        constructor(vm, key, cb) {
            this.vm = vm
            this.key = key
            this.cb = cb
            // 把watcher对象记录到Dep类的静态属性target
            Dep.target = this
            // 触发get方法,在get中调用addSub
            this.oldValue = vm[key]
            // 清空Dep.target,避免重复添加
            Dep.target = null
        }
        // 数据变化时更新视图
        update(...args) {
            const newValue = this.vm[this.key]
            if (newValue !== this.oldValue) {
                this.cb(newValue)
            }
        }
    }
    

总结

问题

  • 将属性重新赋值成对象,是否是响应式的?
    • 是响应式的,重新赋值成对象时会调用属性的set方法,此时会将新赋值的内容转换为响应式数据
  • 为vue实例添加新的属性时,此属性是否是响应式的?
    • 不是响应式的
    • 可以通过Vue.set()或vm.$set()方法设置新的响应式属性

整体流程

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

推荐阅读更多精彩内容