vue 响应式学习笔记

一、响应式原理初体验

vue 响应式的核心是数据驱动视图,那怎么做到数据发生变更,从而使视图发生变化那?
如果有能够监听数据变化的 API 即可实现,ES5 提供了 Object.defineProperty 与 ES6 提供了 Proxy 可以实现对数据的监听。

接下来模拟下 vue2.x 与 vue3.x 响应式的实现

1. vue2.x

响应式原理 = Object.defineProperty + 观察者模式 (后续有介绍)
在这里插入图片描述

<body>
	<h1 id="elm">姓名为:{{name}}</h1>
	<button id="btn">点击进行初始化渲染</button>
</body>
复制代码
// vue 2.x 将 data 数据转换为响应式
    const elm = document.querySelector('#elm')
    // 1. 响应式初始化
    const data = {
      name: 'reborn',
      age: 18,
      hobbies: {
        a: 'play game',
        b: 'play ball'
      }
    }

    // 假设为 vm 为 vue 实例
    const vm = {}

    // 将 data 中属性注入到 vue 实例上。(这也是为什么我们可以通过 this  访问 data 中数据的原因)
    Reflect.ownKeys(data).forEach(key => {
      Object.defineProperty(vm, key, {
        enumerable: true,
        configurable: true,
        // 获取操作的劫持
        get() {
          return data[key]
        },
        // 设置新值操作的劫持
        set(newVal) {
          data[key] = newVal
        }
      })
    })

    // 将 data 数据也转换为响应式, 简单 demo  暂不考虑 对象嵌套对象
    Reflect.ownKeys(data).forEach(key => {
      let value = data[key]
      Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,

        // 这里之所以返回value的原因如下: 1. 如果继续返回 data[key] 会一直触发 get 劫持,从而导致 调用栈溢出。解决办法利用闭包缓存 value 变量,get set 都变更 vlaue。
        get() {
          return value
        },

        // 暂不考虑变更数据的是新对象。
        set(newVal) {
          if (newVal === value) return
          value = newVal
          // 3. 模拟当值发生改变之后更新视图
          elm.textContent = value
        }
      })
    })

    // 2. 页面初始化,不考虑插值表达式解析
    document.querySelector('#btn').addEventListener('click', () => {
      elm.textContent = vm.name
    })

复制代码

2. vue3.x

所得到的效果与使用

<body>
	<h1 id="elm">姓名为:{{name}}</h1>
	<button id="btn">点击进行初始化渲染</button>
</body>
复制代码
   // vue 3.x 将 data 数据转换为响应式
    const elm = document.querySelector('#elm')
    // 1. 响应式初始化
    const data = {
      name: 'reborn',
      age: 18,
      hobbies: {
        a: 'play game',
        b: 'play ball'
      }
    }

    // 假设为 vm 为 vue 实例
    const vm = {}

    const proxyData = new Proxy(data, {
      get(target, key) {
        return target[key]
      },

      set(target, key, val) {
        if (val === target[key]) return
        Reflect.set(...arguments)
        // 模拟当值发生改变之后更新视图
        elm.textContent = val
        return true
      }
    })

    Reflect.ownKeys(proxyData).forEach(key => {
      Object.defineProperty(vm, key, {
        enumerable: true,
        configurable: true,
        // 获取操作的劫持
        get() {
          return proxyData[key]
        },
        // 设置新值操作的劫持
        set(newVal) {
          proxyData[key] = newVal
        }
      })
    })

    // 页面初始化,不考虑插值表达式解析
    document.querySelector('#btn').addEventListener('click', () => {
      elm.textContent = vm.name
    })

复制代码

细心的你经过测试一定能够发现,上面的 demo 会出现我们无论更改 data 中哪一个属性的值都会导致 elm dom 元素的文本内容变成新改变的值。虽然说我们现在已经能够劫持每一个属性的变化,那该如何做到当 data 中的某个属性发生变化之后,页面上所有依赖该属性的 dom 元素的值都能够发生变化?
稍后会揭晓,请您继续看下去~

3. Object.defineProperty 与 Proxy 之间的区别

defineProperty Proxy
监听对象 仅能够监听对象 {} 可以是任何类型的对象,包括原生数组,函数,甚至另一个代理
监听个体 监听对象的某个属性 监听整个对象

4. vue 3.x 从 Object.defineProperty 过渡到 Proxy 带来那些变化

挖个坑。。。

二、响应式系统设计模式

发布订阅 && 观察者是软件的一种设计的模式

1. 观察者模式

引入维基百科的定义:
一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。

举一个观察者模式应用与 vue 响应式系统的例子:

<!-- 页面布局 -->
 <div id="app">
    <!-- 头部 -->
    <div class="header">{{subject}}</div>
    
    <!-- 主体 -->
    <div class="main">{{subject}}</div>

    <!-- 底部 -->
    <div class="bottom">{{subject}}</div>
  </div>
复制代码
const vm = new Vue({
    el: '#app',
    data: {
      subject: "someContent",
      other: "otherContent"
    }
  })
复制代码

假设有一个 html 页面,其布局分别由 头部 ,主体,底部构成并依赖与 data 对象中 subject 属性所对应的值进行展示。了解响应式的朋友们都知道,响应式系统是数据驱动视图,当 data 中 subject 属性的值发生改变之后,页面中所依赖与该属性值的头部,主体,底部的内容也会发生变化。可以思考下,目标对象,观察者对象,状态改变分别对应观察者模式那些主体?

在这一段 demo 中,目标对象就是 data 对象的 subject 属性,而页面中所有依赖于该 subject 属性的主体为观察者对象,状态改变则对应数据发生变化,如: data.subject = “reborn”。参考下图:

在这里插入图片描述
在这里插入图片描述
用代码实现观察者模式

模拟场景:
国庆假期,你与你的4个好朋友约定好了一起去球场打篮球,但是那由于你的粗心大意忘记提前预约了。
当你们到球场的时候已经没场了,此时球场老板跟你说,不好意思啊帅哥,假期球场都已经约满了,要是有人取消预约的时候会通知您。

// 观察者模式

// 目标对象
class Subject {
  constructor() {
    this.observes = []
  }

  // 添加观察者
  addObserve(observe) {
    if (!observe.update) return
    this.observes.push(observe)
  }

  // 事件触发通知函数
  notify(val) {
    if (!this.observes.length) return
    this.observes.forEach(observes => {
      observes.update?.(val)
    })
  }

  // 移除单个观察者
  removeObserve(observe) {
    const targetIdx = this.observes.findIndex(obsv => obsv === observe)
    this.observes.splice(targetIdx, 1)
  }

  // 移除所有观察者
  removeAllObserves() {
    this.observes = []
  }
}


// 观察者对象
class Observe {
  constructor(cb) {
    this.cb = cb
  }

  update(val) {
    this.cb(val)
  }
}

// 分析:
// 观察者 => 帅哥
// 目标对象 => 球场
// 状态变更(事件) => 球场为空

// 创建目标对象
const ballPark = new Subject()

// 创建观察者
const handsomeMan = new Observe((date) => {
  console.log('XDM 有场了,'+ date + '跟我一起杀过去')
})

// 订阅球场
ballPark.addObserve(handsomeMan)

// 有人取消预约,老板通知你有场拉
ballPark.notify('本周三')
复制代码

到现在你应该已经能够手写一个观察者模式了,接下来我们将结合响应式初体验中 demo ( vue2.x ) 来实现 vue 响应式系统。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <h1 id="elm">姓名为:{{name}}</h1>
  <!-- 1. 将刚刚写的观察者模式的代码引进来 -->
  <script src="./observe.js"></script>
  <script>
    // 2. 数据劫持相关操作
    const data = {
      name: 'reborn',
      age: 18,
      hobbies: {
        a: 'play game',
        b: 'play ball'
      }
    }

    // 假设为 vue 实例
    const vm = {}

    Reflect.ownKeys(data).forEach(key => {
      Object.defineProperty(vm, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key]
        },
        set(newVal) {
          data[key] = newVal
        }
      })
    })

    Reflect.ownKeys(data).forEach(key => {
      let value = data[key]
      // 创建目标对象
      const dep = new Subject()
      Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,

        get() {
          // 当访问该数据的时候,添加 观察者
          Subject.isAddObsv && dep.addObserve(Subject.isAddObsv)
          return value
        },

        set(newVal) {
          if (newVal === value) return
          value = newVal
          // 当数据发生变化之后通知所有的观察者
          dep.notify(value)
        }
      })
    })


    // 3. 页面首次渲染
    function initRender() {
      const elm = document.querySelector("#elm")
      // 创建观察者对象
      Subject.isAddObsv = new Observe((val) => {
        // 当劫持数据发生变化之后,在回调中更新数据。
        elm.textContent = val
      })
      elm.textContent = vm.name
      Subject.isAddObsv = null
    } 

    // 触发首屏渲染
    initRender()

  </script>

</body>

</html>
复制代码

在这里插入图片描述

2. 发布订阅模式

发布订阅模式与观察者模式很相似,但又有所不同,好像 西红柿与圣女果的关系一般。

维基百科定义
发布-订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

根据上述定义我们可能会有些疑问,既然发布者与订阅者都无需关心对方的存在,那订阅者和发布者如如何实现关联的?是需要存在一个第三者消息中心来作为中间人来协调订阅者与发布者,他的职责就是来维护发布与订阅的消息,将发布订阅消息进行筛选匹配,从而间接实现发布者订阅者对应关系。(个人理解)

从定义就可以得出发布订阅模式与观察者的差异:

  • 发布订阅模式松耦合,发布者订阅者无需知道对方的存在,仅关注消息的本身。
  • 观察者模式为强耦合,观察者需要依赖与目标对象。

代码实现发布订阅模式

// 发布订阅模式

// 事件调度中心(消息中心)
class EventEmitter {
  constructor() {
    this.subs = {}
  }

  // 订阅消息
  $on(type, handler) {
    this.subs[type] = this.subs[type] || []
    this.subs[type].push(handler)
  }


  // 发布消息
  $emit(type, ...args) {
    if (!this.subs[type]) return
    this.subs[type].forEach(handler => {
      handler(...args)
    })
  }


  // 移除单个消息
  removeMsg(type, cbFn) {
    if (!type || !cbFn) return 

    const fnInSubsIdx = this.subs[type].findIndex(cb => cbFn === cb)

    if (fnInSubsIdx === -1) return

    this.subs[type].splice(fnInSubsIdx, 1 )
  }


  // 移除所有消息
  removeAllMsg() {
    this.subs = {}
  }
}

const e = new EventEmitter()

e.$on('playBall', (val) => {
  // finished homework
  console.log('come on , go to play ball!', {val})
})


e.$on('playBall', (val) => {
  // no finished homework
  console.log('nonono ', {val})
})


e.$emit('playBall' , 'hhhh')
复制代码

目前 vue 内部自定义事件就是用到了发布订阅模式,webpack 依赖的核心库 tapable 也同样是发布订阅模式。这种设计模式我们有必要了解~

三、实现一个简版的 Vue

到现在,我们已经知道了如何对 data 中的数据进行劫持来监听到数据发生改变,也知道了响应式系统的设计模式了。现在根据已经实现的 demo 现在我们来思考下要实现一个 vue 响应式系统需要做那些事。

实现思路

  • 对数据进行劫持,监听数据的变化。
  • 响应式系统设计模式——观察者模式,订阅数据变化,更新视图。
  • 页面初始化渲染,解析插值表达式,指令,创建 观察者 订阅 目标数据的变化。

图片[3]-vue 响应式学习笔记-一一网
接下来就让我们将思路转换为代码吧~

代码实现
先了解下目录结构
在这里插入图片描述

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>

  <div id="app">
    <h1>插值表达式:name {{name}}</h1>
    <h1>插值表达式:age {{age}}</h1>
    <h1 v-text="hobby">v-text</h1>
    <div v-html="htmlContent">v-html</div>
    <input type="text" v-model="tall" placeholder="请输入身高">
    <button v-on:click.native="handleClick">
      点击更改name
    </button>
  </div>
  
  <script src="https://juejin.cn/post/js/Watcher.js"></script>
  <script src="https://juejin.cn/post/js/Compiler.js"></script>
  <script src="https://juejin.cn/post/js/Dep.js"></script>
  <script src="https://juejin.cn/post/js/Observe.js"></script>
  <script src="https://juejin.cn/post/js/Vue.js"></script>

  <script>
    const vm = new Vue({
      el: '#app',
      data: {
        name: 'reborn',
        age: 18,
        tall: '',
        obj: {
          a : 1,
          b: 2
        },
        hobby: 'game',
        htmlContent: '<strong>hello Reborn~~</strong>'
      },
      methods: {
        handleClick(e) {
          console.log('事件触发了', {e})
        }
      }
    })
  </script>

</body>
</html>
复制代码
// vue.js
// 职责
// create vue
class Vue {
  constructor(options) {
    // 解析参数
    this.$options = options
    this.$data = options.data
    this.$el =
      typeof options.el === 'string' ? document.querySelector('#app') : options.el

    // 将 data 数据添加到 this 实例上
    this._proxyData(this.$data)

    // 将 data 中的所有数据转为响应式
    new Observe(this.$data)

    // 解析vue语法规则
    new Compiler(this.$el, this)

  }

  _proxyData(data) {
    Object.keys(data).forEach((key) => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,

        get() {
          return data[key]
        },

        set(newVal) {
          if (newVal === data[key]) return
          data[key] = newVal
        }
      })
    })
  }
}
复制代码
// Observe.js
// 职责:
// 将 data 中属性转换为响应式数据
// 在 getter 劫持的时候,添加 观察者 watcher
// 在 setter 劫持 通知所有观察者更新视图
class Observe {
  constructor(data) {
    this.walk(data)
  }
  walk (data) {

    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key])
    })
  }

  // 给属性添加数据劫持
  defineReactive(data, key, value) {
    const dep = new Dep()
    let that = this
    // 递归将 树形结构的所有数据转为响应式
    if (typeof value === 'object') this.walk(value)
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get() {
        Dep.haveWatcher && dep.addSubs(Dep.haveWatcher)
        return value
      },
      set(newVal) {
        if (value === newVal) return
        value = newVal
        // 改变数据的时候如果是 {} ,则将其内部的属性也同样转换为响应式
        if (typeof value === 'object') that.walk(value)
        dep.notify(newVal)
      }
    })
  }
  
}
复制代码
// Dep.js 
// 职责:
// create Subject
class Dep {
  constructor() {
    this.subs = []
  }

  addSubs(watcher) {
    if (!watcher.update) return
    this.subs.push(watcher)
  }

  notify(newVal) {
    this.subs.forEach(item => {
      item.update(newVal)
    })
  }
}
复制代码
// wather.js
// 职责:
// create 观察者
class Watcher {
  constructor(vm, prop, cb) {
    this.cb = cb
    Dep.haveWatcher = this
    this.oldValue = vm[prop]
    Dep.haveWatcher = null
  }

  update(newVal) {
    if (newVal === this.oldValue) return
    this.cb && this.cb(newVal)
  }
}
复制代码
// compile.js
// Compiler 职责:
// 1. 解析 vue 语法
// 2. 初次渲染时 create watcher
class Compiler {
  constructor(el, vm) {
    this.vm = vm
    this.compile(el)
  }

  // 编辑
  compile(el) {
    const childrenNode = el.childNodes || []
    childrenNode.forEach((item) => {
      this.handleNode(item)
      if (childrenNode.length) this.compile(item)
    })
  }

  handleNode(node) {
    if (this.isTextNode(node) && this.isHaveHuaKuohao(node)) {
      this.handleTextNode(node)
    } else if (this.isElmNode(node)) {
      this.handleElmNode(node)
    }
  }

  // 是否文本节点
  isTextNode(node) {
    return node.nodeType === 3
  }

  // 是否元素节点
  isElmNode(node) {
    return node.nodeType === 1
  }

  // 处理文件节点,解析插值表达式 (不考虑插值表达式中有表达式的情况)
  handleTextNode(node) { 
    let {textContent} = node
    const getVariableRegexp = /\{{2}(.+)\}{2}/
    const variable = textContent.match(getVariableRegexp)?.[1]?.trim()
    const watcher = new Watcher(this.vm, variable, (newValue) => {
      node.textContent = newValue
    })
    const value = watcher.oldValue
    node.textContent = value
    
    // 在页面使用的地方进行进行依赖收集
    
    
  }
  // 有花括号的为需要进行解析操作
  isHaveHuaKuohao(node) {
    const isHaveHuaKuohaoRegexp = /\{{2}(.+)\}{2}/
    return isHaveHuaKuohaoRegexp.test(node.textContent)
  }

  // 处理元素节点,解析指令
  handleElmNode(node) {
    const attrubuts = Array.from(node.attributes) || []
    attrubuts.forEach((attr) => {
      if (this.isDirectives(attr)) {
        // 当前元素节点有指令
        const name = attr.name
        const value = attr.value
        // 不同指令交给不同的函数来处理
        const drtFn = this.createFnName(name)
        this[drtFn]?.(node, value, name)
      }
    })
  }

  // 判断元素节点上是否有指令
  isDirectives(attr) {
    const attrText = attr?.nodeName
    return attrText.startsWith('v-') || attrText.startsWith('@')
  }

  // 一下师处理不同类型的指令
  createFnName(name) {
    // 这里需要考虑注册事件 @click v-on:click 的情况
    if (name.includes('@') || name.includes(':')) {
      name = name.slice(2, 4)
    } else {
      name = name.slice(2)
    }
    return name + 'DrtFn'
  }

  // v-text
  textDrtFn(node, prop) {
    prop = prop.trim()
    const watcher = new Watcher(this.vm, prop, (newVal) => {
      node.textContent = newVal
    })
    node.textContent = watcher.oldValue
    // 将页面上依赖数据的,将其添加到依赖上
  }

  // v-model
  modelDrtFn(node, prop) {
    prop = prop.trim()
    const watcher = new Watcher(this.vm, prop, (newVal) => {
      node.value = newVal
    })

    node.value = watcher.oldValue

    // v-model 实现双向数据绑定,监听input 时间

    node.addEventListener('input', (e) => {
      this.vm[prop] = e.target.value
    })
  }

  // v-html
  htmlDrtFn(node, prop) {
    prop = prop.trim()
    const watcher = new Watcher(this.vm, prop, (newVal) => {
      node.innerHTML = newVal
    })
    node.innerHTML = watcher.oldValue
  }

  // v-on
  onDrtFn(node, prop, attrName) {
    let eventFn, eventName, options

    const { eName, sign } = this.handleDirectName(attrName)
    eventFn = this.handleEventFn(prop.trim())
    // 正常事件绑定
    // 内联处理器中的方法, 可以往事件中传值
    // 处理事件修饰符
    // 按键修饰符 (太复杂了,要重新更改代码,不处理了)
    options = {}

    node.addEventListener(eName, eventFn, options)
  }

  handleDirectName(name) {
    let eName, sign
    const startIdx = name.indexOf(':') > 0 ? name.indexOf(':') : name.indexOf('@')
    const eventNameEndIdx = name.indexOf(".") > 0 ? name.indexOf(".") : name.length
    eName = name.slice(startIdx + 1, eventNameEndIdx)
    const signStartIdx = eventNameEndIdx > 0 && eventNameEndIdx < name.length ? eventNameEndIdx : null

    if (signStartIdx) sign = name.slice(signStartIdx + 1, name.length)
    // 处理带修饰符情况

    return { eName, sign }
  }

  // 处理事件修饰符
  // handleEvent

  // 内联处理器中的方法, 可以往事件中传值
  handleEventFn(fnName) {
    let fn
    const { methods } = this.vm.$options
    const callFnRegxp = /(\w+)(\(.+\))/
    const isCallFn = callFnRegxp.test(fnName)
    if (isCallFn) {
      let paramsArr = []
      // 函数调用的情况
      const res = fnName.match(callFnRegxp)
      const eventName = res?.[1]
      const params = res?.[2]

      if (!eventName) return new Function()

      // 去掉小括号
      const str = params.replace(/\(?\)?/g, '').trim()


      // 处理参数,将变量转换位真实的值
      str.split(',')?.forEach(item => {
        const isVariable = this.isVariable(item)
        if (isVariable) {
          // $event 需要传入对象
          if (item !== '$event') {
            item = this.vm[item]
          }
          paramsArr.push(item)
        } else {
          paramsArr.push(item)
        }
      })

      // 生成函数
      fn = (e) => {
        // 将 '$event' 替换位 事件对象 e
        const arr = paramsArr.map( item  =>  {
          if (item === '$event') {
            return e
          }

          return item
        })
        return methods[eventName](...arr)
      }

    } else {
      // 无函数调用, 
      fn = methods[fnName]
    }
    return fn

  }

  // 判断是否全实
  isVariable(str) {
    // 不是 23423 , 没有 ' ' 字符串
    str = str.trim()
    const isNumRegexp = /^\d+$/
    const isStrRegexp = /^'{1}.?'{1}$/
    const isStrOrNum = isNumRegexp.test(str) || isStrRegexp.test(str)

    return !isStrOrNum
  }
}

复制代码

四、谈谈你对 vue 响应式系统的理解

看完上面文章,你收获了多少?
假如面试官问你:谈谈你对 vue 响应式的理解?你会怎么答复?

从 设计思想 和 具体实现方式这两个点来回答这个问题
设计思想:

首先响应式系统的核心是数据驱动视图,当数据发生变化的时候视图也会跟着变化,所以需要有能够监听数据变化的 API, Object.definedProperty Or Proxy 来实现对数据的监听。最后利用观察者模式以一种”优雅”的设计方式在数据发生变化之后,更新页面上所有依赖该数据的视图的内容。

具体实现方式:

  1. Vue 会将 data 中的所有属性通过 Object.defineProperty 将其进行数据劫持,并给每一个属性创建一个 目标对象(发布者)。
  2. 在 getter 中其主要的职责有两件事,收集依赖(添加观察者),返回其访问的目标值。
  3. 在 setter 中主要的职责有两件事, 变更目标值,通知所有的观察者更新视图。
  4. 在页面初次渲染的时候, 为视图所依赖的劫持数据创建 观察者,并将 观察者添加到 发布者,此时创建回调函数,在回调函数中修改 dom,等待数据更新之后发布者调用 所有watcher 的 回调来更新视图。
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享