Vue 响应式——模拟封装Vue(一)

前言

首先,一个问题。

为啥要去了解Vue的响应式数据

直白的说 为了面试的时候应付面试官的相关问题,在现在Vue广泛使用的情况下,基本上数据响应式 是一个必考点

委婉的讲 了解它的原理,可以学习别人优秀经验,转为自己的经验,还可以通过原理层面去解决一些开发中的问题,比如:

  • 给 Vue 实例新增一个成员是否是响应式的?
  • 给属性重新赋值称为对象,是否是响应式的?

真正的原因 为了能够跟同事们,尤其是漂亮的前端小妹妹装逼

三个概念

在开始模拟实现Vue响应式原理之前,需要先了解三个概念。分别是:

  1. 数据驱动
  2. 响应式核心原理
  3. 发布/订阅模式 和 观察者模式

数据驱动

数据响应式、双向绑定、数据驱动

  • 数据响应式
    数据模型仅仅是普通的 JavaScript 对象,而当我们修改数据时,视图会进行更新,避免了繁琐的 DOM 操作,提高开发效率
  • 双向绑定
    数据改变,视图改变;视图改变,数据也随之改变 我们可以使用 v-model 在表单元素上创建双向数据绑定
  • 数据驱动是 Vue 最独特的特性之一
    开发过程中仅需要关注数据本身,不需要关心数据是如何渲染到视图

响应式核心原理

Vue2 和 Vue3 响应式的实现方式发生了变化,我们分别来列出。

Vue2.x

当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用
Object.defineProperty 把这些 property 全部转为
getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持
IE8 以及更低版本浏览器的原因

无法 shim 的特性 意思就是不能被降级,文章中说的意思就是,Object.defineProperty这个特性是无法使用低级浏览器中的方法来实现的,所以Vue不支持IE8以及更低版本的浏览器

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>defineProperty 多个成员</title>
</head>
<body>
  <div id="app">
    hello
  </div>
  <script>
    // 模拟 Vue 中的 data 选项
    let data = {
      msg: 'hello',
      count: 10
    }

    // 模拟 Vue 的实例
    let vm = {}

    proxyData(data)

    function proxyData(data) {
      // 遍历 data 对象的所有属性
      Object.keys(data).forEach(key => {
        // 把 data 中的属性,转换成 vm 的 setter/setter
        Object.defineProperty(vm, key, {
          enumerable: true,
          configurable: true,
          get () {
            console.log('get: ', key, data[key])
            return data[key]
          },
          set (newValue) {
            console.log('set: ', key, newValue)
            if (newValue === data[key]) {
              return
            }
            data[key] = newValue
            // 数据更改,更新 DOM 的值
            document.querySelector('#app').textContent = data[key]
          }
        })
      })
    }

    // 测试
    vm.msg = 'Hello World'
    console.log(vm.msg)
  </script>
</body>
</html>

以上代码 就是 vm 对象通过defineProperty 劫持了 data 对象中的所有的属性,所以需要利用循环去添加所有的data中的属性

Vue3.x
  • MDN - Proxy
  • 直接监听对象,而非属性, 因此不需要向defineProperty那样去循环
  • ES 6中新增,IE 不支持,性能由浏览器优化
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Proxy</title>
</head>
<body>
  <div id="app">
    hello
  </div>
  <script>
    // 模拟 Vue 中的 data 选项
    let data = {
      msg: 'hello',
      count: 0
    }

    // 模拟 Vue 实例
    let vm = new Proxy(data, {
      // 执行代理行为的函数
      // 当访问 vm 的成员会执行
      get (target, key) {
        console.log('get, key: ', key, target[key])
        return target[key]
      },
      // 当设置 vm 的成员会执行
      set (target, key, newValue) {
        console.log('set, key: ', key, newValue)
        if (target[key] === newValue) {
          return
        }
        target[key] = newValue
        document.querySelector('#app').textContent = target[key]
      }
    })

    // 测试
    vm.msg = 'Hello World'
    console.log(vm.msg)
  </script>
</body>
</html>

以上代码 是 vm 代理 data这个对象,data作为代理目标 当访问vm.key的时候 就会代理访问data.key
所以不需要循环。

发布/订阅模式

发布/订阅模式

  • 订阅者
  • 发布者
  • 信号中心
    我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信
    号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执
    行。这就叫做"发布/订阅模式"(publish-subscribe pattern)

代码流程:

  1. 创建EventEmitter 事件触发器类
  2. 其中添加 $on 注册事件方法 和 $emit 触发事件方法
  3. 添加 subs 属性,以键值对的形式记录所有的事件和事件对应的执行函数,subs是一个对象,我们可以用 Object.create(null) 的方式初始化它的值,这样的好处是创建出来的对象原型为null,性能会好一些,因为我们只是需要subs来记录一些数据
<!DOCTYPE html>
<html lang="cn">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>发布订阅模式</title>
</head>
<body>
  <script>
    // 事件触发器
    class EventEmitter {
      constructor () {
        // { 'click': [fn1, fn2], 'change': [fn] }
        this.subs = Object.create(null)
      }

      // 注册事件
      $on (eventType, handler) {
        this.subs[eventType] = this.subs[eventType] || []
        this.subs[eventType].push(handler)
      }

      // 触发事件
      $emit (eventType) {
        if (this.subs[eventType]) {
          this.subs[eventType].forEach(handler => {
            handler()
          })
        }
      }
    }

    // 测试
    let em = new EventEmitter()
    em.$on('click', () => {
      console.log('click1')
    })
    em.$on('click', () => {
      console.log('click2')
    })

    em.$emit('click')
  </script>
</body>
</html>

观察者模式

  • 观察者(订阅者) – Watcher
    update():当事件发生时,具体要做的事情
  • 目标(发布者) – Dep
    subs 数组:存储所有的观察者
    addSub():添加观察者
    notify():当事件发生,调用所有观察者的 update() 方法
    没有事件中心
		class Dep {
            constructor() {
                this.subs = []
            }

            // 添加观察者
            addSub(sub) {
                if (sub && sub.update) {
                    this.subs.push(sub)
                }
            }

            // 发布
            notify() {
                this.subs.forEach(item => item.update())
            }
        }

        class Watcher {
            constructor(name) {
                this.name = name
            }
            update() {
                console.log(this.name + ' update!');
            }
        }

        var sub = new Watcher('dog')
        var dep = new Dep()

        dep.addSub(sub)
        dep.notify()
两种模式的区别

在这里插入图片描述

  • 观察者模式 是由具体目标调度,比如当事件触发,Dep就会去调用观察者的方法,所以观察者模式的订阅和发布之间是存在依赖的
  • 发布/订阅模式 由统一调度中心调用,因为发布者和订阅者 不需要知道对方的存在。

实现最简易的Vue

要实现最简易的Vue,我们需要实现以下几种功能:

  • Vue
    把 data 中的成员注入到 Vue 实例,并且把 data 中的成员转成 getter/setter
  • Observer
    能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知 Dep
  • Compiler
    解析每个元素中的指令/插值表达式,并替换成相应的数据
  • Dep
    添加观察者(watcher),当数据变化通知所有观察者
  • Watcher
    数据变化更新视图, 其中要有一个update方法
创建class Vue

先确定其基本结构
在这里插入图片描述
options 、el 、data 都是用于接收传入的参数
proxyData() 方法的作用是把data中属性 转为getter setter 注入到vue实例中
下划线 _ 开头的都是私 有成员

class Vue {
    constructor(options) {
        // 1 通过属性保存选择的数据
        this.$options = options || {}
        this.$data = options.data || {}
        this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
        // 2 把data中的成员转换成getter和setter,注入到vue实例中
        this._proxyData(this.$data)
        // 3 调用observer对象, 监听数据变化
        new Observer(this.$data)
        // 4 调用compiler对象, 解析指令和插值表达式
        new Compile(this.$el,this)
    }
    _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
                }
            })
        })
    }
}
Observer
  • 功能
  1. 负责把 data 选项中的属性转换成响应式数据
  2. data 中的某个属性也是对象,把该属性转换成响应式数据
  3. 数据变化发送通知
  • 结构
    在这里插入图片描述
  1. walk(data), walk 方法 用来遍历 data 中的所有属性
  2. defineReactive 方法,意思是定义响应式数据,也就是通过调用Object.defineProperty 把属性转化成getter 和 setter
  3. walk 方法在循环中 会调用defineReactive
  4. defineReactive 中为data中的属性添加getter setter
class Observer {
    constructor(data) {
        this.walk(data)
    }
    walk(data) {
        // 1. 判断data 是否是一个对象
        if (!data || typeof data !== 'object') {
            return
        }
        // 2. 遍历data 中的属性添加getter setter
        Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))

    }
    defineReactive(obj, key, value) {
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                return value
            },
            set(newValue) {
                if (newValue == value) return
                value = newValue
                // 发出通知
            }
        })
    }
}
defineReactive(obj, key, value)

为什么 defineReactive中要接收第三个形参value,而不是直接在get中return data[key]?

如果我们访问vm上的数据
比如:

console.log(vm.msg)

这样其实就是在触发 vm 上msg的getter
而vm上msg的getter 中 return的就是data[key]
这样就再次触发了 data中msg的getter ,如果data中msg属性的get再返回data[key]就会陷入死循环
而如果我们使用value传递,value是在Object.keys[data].forEach 循环的时候产生的 此时还没有添加getter
就可以得到value 而因为defineReactive函数访问了walk函数中的 value 就形成了闭包,
value不会被释放掉,我们就可以得到其值

优化Observer

此时,我们还存在两个问题。

  1. 如果data对象中的属性也是一个对象,那么这个对象中的属性并没有被添加getter 和setter
  2. 如果我们对data中的某个值是基本数据类型的属性,进行重新赋值,赋值成一个对象,那么赋值后的这个对象中的属性 也没有getter和setter

解决办法

第一个问题,在defineReactive中 Object.defineProperty中立刻先调用 walk方法,这样就会判断是否是一个object 如果是 则深入进行遍历添加getter和setter 如果不是则直接return

第二个问题,我们需要在 defineReactive 方法中 Object.defineProperty的set中 在赋值之后 再次调用walk

这两次调用walk我们又面临一个新的问题 ,就是this的指向问题,这个好解决,我们可以用一个that变量,提前保存this

最后 优化后的代码

class Observer {
    constructor(data) {
        this.walk(data)
    }
    walk(data) {
        // 1. 判断data 是否是一个对象
        if (!data || typeof data !== 'object') {
            return
        }
        // 2. 遍历data 中的属性添加getter setter
        Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))

    }
    defineReactive(obj, key, value) {
        let that = this
        // 递归解决属性依然是对象的问题
        that.walk(value)
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                return value
            },
            set(newValue) {
                if (newValue == value) return
                value = newValue
                // 发出通知
                // 为新赋值的对象属性 添加响应式的getter和setter
                that.walk(newValue)
            }
        })
    }
}

Compiler

功能

  • 负责编译模板,解析指令/插值表达式
  • 负责页面的首次渲染
  • 当数据变化后重新渲染视图
    在这里插入图片描述
    compile 方法用于编译模板
    compileElement 方法用于编译元素节点
    compileText 方法用于编译文本节点
    isDirective 方法用于判断是否是指令
    isTextNode 方法用于判断是否是文本节点
    isElementNode 方法用于判断是否是元素节点
class Compile {
    constructor(el,vm){
        this.el = el
        this.vm = vm
        // 调用 compile
        this.compile(this.el)
    }
    // 用于编译模板
    compile(el){

    }

    // 用于编译元素节点属性
    compileElement(node){

    }

    // 用于编译文本节点
    compileText(node){

    }

    // 判断是否是指令
    isDirective(attrName){
        return attrName.startsWith('v-')
    }
    // 判断是否是文本节点
    isTextNode(node){
        return node.nodeType === 3
    }
    // 判断是否是元素节点
    isElementNode(node){
        return node.nodeType === 1
    }
}
compile方法
    // 用于编译模板
    compile(el){
        let childNodes = el.childNodes
        // Array.from 将伪数组转化为数组
        Array.from(childNodes).forEach(node=>{
            if(this.isTextNode(node)){
                this.compileText(node)
            }else if(this.isElementNode(node)){
                this.compileElement(node)
            }
        })
        // 判断节点是否还有子节点,进行递归调用
        if(node.childNodes && node.childNodes.length){
            this.compile(node)
        }
    }
compileText 方法

处理文本节点

    // 用于编译文本节点
    compileText(node) {
        // 判断文件节点中的内容是不是插值表达式
        let reg = /\{\{(.+?)\}\}/
        let value = node.textContent
        if (reg.test(value)) {
            // 获取括号分组内的字符
            let key = RegExp.$1.trim()
            // 替换掉插值表达式的内容
            node.textContent = value.replace(reg, this.vm[key])
        }
    }
compileElement

编译元素节点 处理指令

    // 用于编译元素节点属性
    compileElement(node) {
        // 遍历所有的属性节点
        Array.from(node.attributes).forEach(attr => {
            // 判断是否是指令 v-xxx
            let attrName = attr.name
            if (this.isDirective(attrName)) {
                // v-model --> model
                attrName = attrName.substr(2)
                let key = attr.value
                // 因为指令有很多 不可能通过if来进行判断
                // 可以采用函数名拼接的方式来扩展执行
                this.update(node,key,attrName)
            }
        })
    }
    // 定义update函数来执行不同指令的更新操作
    update(node, key, attrName) {
        let updateFn = this[attrName + 'Updater']
        updateFn && updateFn(node, this.vm[key])
    }
    // 处理 v-text 指令
    textUpdater(node,value){
        node.textContent = value
    }
    // v-model
    modelUpdater(node,value){
        node.value = value
    }
Compile结束
class Compile {
    constructor(el, vm) {
        this.el = el
        this.vm = vm
        this.compile(this.el)
    }
    // 用于编译模板
    compile(el) {
        let childNodes = el.childNodes
        // Array.from 将伪数组转化为数组
        Array.from(childNodes).forEach(node => {
            if (this.isTextNode(node)) {
                this.compileText(node)
            } else if (this.isElementNode(node)) {
                this.compileElement(node)
            }
            // 判断节点是否还有子节点,进行递归调用
            if (node.childNodes && node.childNodes.length) {
                this.compile(node)
            }
        })

    }

    // 用于编译元素节点属性
    compileElement(node) {
        // 遍历所有的属性节点
        Array.from(node.attributes).forEach(attr => {
            // 判断是否是指令 v-xxx
            let attrName = attr.name
            if (this.isDirective(attrName)) {
                // v-model --> model
                attrName = attrName.substr(2)
                let key = attr.value
                // 因为指令有很多 不可能通过if来进行判断
                // 可以采用函数名拼接的方式来扩展执行
                this.update(node,key,attrName)
            }
        })
    }
    // 定义update函数来执行不同指令的更新操作
    update(node, key, attrName) {
        let updateFn = this[attrName + 'Updater']
        updateFn && updateFn(node, this.vm[key])
    }
    // 处理 v-text 指令
    textUpdater(node,value){
        node.textContent = value
    }
    // v-model
    modelUpdater(node,value){
        node.value = value
    }

    // 用于编译文本节点
    compileText(node) {
        // 判断文件节点中的内容是不是插值表达式
        let reg = /\{\{(.+?)\}\}/
        let value = node.textContent
        if (reg.test(value)) {
            // 获取括号分组内的字符
            let key = RegExp.$1.trim()
            // 替换掉插值表达式的内容
            node.textContent = value.replace(reg, this.vm[key])
        }
    }

    // 判断是否是指令
    isDirective(attrName) {
        return attrName.startsWith('v-')
    }
    // 判断是否是文本节点
    isTextNode(node) {
        return node.nodeType === 3
    }
    // 判断是否是元素节点
    isElementNode(node) {
        return node.nodeType === 1
    }
}

到此为止,我们初步渲染就处理完成了,但是核心功能还没有实现,数据更新,视图也更新的功能 我们下篇再继续…

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值