写点什么

实现简易的 Vue 响应式

作者:CRMEB
  • 2022 年 3 月 23 日
  • 本文字数:8680 字

    阅读完需:约 28 分钟

实现简易的 Vue 响应式

我们首先封装一个响应式处理的方法 defineReactive,通过 defineProperty 这个方法重新定义对象属性的 get 和 set 描述符,来实现对数据的劫持,每次 读取数据 的时候都会触发 get ,每次 更新数据 的时候都会触发 set ,所以我们可以在 set 中触发更新视图的方法 update 来实现一个基本的响应式处理。

/** * @param {*} obj  目标对象 * @param {*} key  目标对象的一个属性 * @param {*} val  目标对象的一个属性的初始值 */function defineReactive(obj, key, val) {  // 通过该方法拦截数据  Object.defineProperty(obj, key, {    // 读取数据的时候会走这里    get() {      console.log('🚀🚀~ get:', key);      return val    },    // 更新数据的时候会走这里    set(newVal) {      // 只有当新值和旧值不同的时候 才会触发重新赋值操作      if (newVal !== val) {        console.log('🚀🚀~ set:', key);        val = newVal        // 这里是触发视图更新的地方        update()      }    }  })}复制代码
复制代码

我们写点代码来测试一下,每 1s 修改一次 obj.foo 的值 , 并定义一个 update 方法来修改 app 节点的内容。

// html<div id='app'>123</div>
// js// 劫持 obj.foo 属性const obj = {}defineReactive(obj, 'foo', '')
// 给 obj.foo 一个初始值obj.foo = new Date().toLocaleTimeString()
// 定时器修改 obj.foosetInterval(() => { obj.foo = new Date().toLocaleTimeString()}, 1000)
// 更新视图function update() { app.innerHTML = obj.foo}复制代码
复制代码

可以看到,每次修改 obj.foo 的时候,都会触发我们定义的 get 和 set ,并调用 update 方法更新了视图,到这里,一个最简单的响应式处理就完成了。

处理深层次的嵌套

一个对象通常情况下不止一个属性,所以当我们要给每个属性添加响应式的时候,就需要遍历这个对象的所有属性,给每个 key 调用 defineReactive 进行处理。

/** * @param {*} obj  目标对象 */function observe(obj) {  // 先判断类型, 响应式处理的目标一定要是个对象类型  if (typeof obj !== 'object' || obj === null) {    return  }  // 遍历 obj, 对 obj 的每个属性进行响应式处理  Object.keys(obj).forEach(key => {    defineReactive(obj, key, obj[key])  })}// 定义对象 objconst obj = {  foo: 'foo',  bar: 'bar',  friend: {    name: 'aa'  }}
// 访问 obj 的属性 , foo 和 bar 都被劫持到,就不在浏览器演示了。obj.bar = 'barrrrrrrr' // => 🚀🚀~ set: barobj.foo = 'fooooooooo' // => 🚀🚀~ set: foo
// 访问 obj 的属性 obj.friend.name obj.friend.name = 'bb' // => 🚀🚀~ get: friend复制代码
复制代码

当我们访问 obj.friend.name 的时候,也只是打印出来 get: friend ,而不是 friend.name , 所以我们还要进行个 递归,把 深层次的属性 同样也做响应式处理。

function defineReactive(obj, key, val) {  // 递归  observe(val)    // 继续执行 Object.defineProperty...  Object.defineProperty(obj, key, {    ... ...  })}
// 再次访问 obj.friend.nameobj.friend.name = 'bb' // => 🚀🚀~ set: name复制代码
复制代码

递归的时机在 defineReactive 这个方法中,如果 value 是对象就进行递归,如果不是对象直接返回,继续执行下面的代码,保证 obj 中嵌套的属性都进行响应式的处理,所以当我们再次访问 obj.friend.name 的时候,就打印出了 set: name 

处理直接赋值一个对象

上面已经实现了对深层属性的响应式处理,那么如果我直接给属性赋值一个对象呢?

const obj = {  friend: {    name: 'aa'  }}obj.friend = {           // => 🚀🚀~ set: friend  name: 'bb'}obj.friend.name = 'cc'   // => 🚀🚀~ get: friend复制代码
复制代码

这种赋值方式还是只打印出了 get: friend ,并没有劫持到 obj.friend.name ,那怎么办呢?我们只需要在 触发 set 的时候,判断一下 value 的类型,如果它是个对象类型,我们就对他执行 observe 方法。

function defineReactive(obj, key, val) {  Object.defineProperty(obj, key, {    ... ...    set(newVal) {      // 只有当新值和旧值不同的时候 才会触发重新赋值操作      if (newVal !== val) {        console.log('🚀🚀~ set:', key);        // 如果 newVal 是个对象类型,再次做响应式处理。        if (typeof obj === 'object' && obj !== null) {          observe(newVal)        }        val = newVal      }    }  })}// 再次给 obj.friend 赋值一个对象obj.friend = {  name: 'bb'}// 再次访问 obj.friend.name , 这个时候就成功的劫持到了 name 属性obj.friend.name = 'cc'  //=> 🚀~ set: name复制代码
复制代码

处理新添加一个属性

上面的例子都是操作 已经存在 的属性,那么如果我们 新添加 一个属性呢?

const obj = {}obj.age = 18obj.age = 20复制代码
复制代码

当我们试图修改 obj.age 的时候,什么都没有打印出来,说明并没有对 obj.age 进行响应式处理。这里也非常好理解,因为新增加的属性并没有经过 defineReactive 的处理,所以我们就需要一个方法来手动处理新添加属性这种情况。

/** * @param {*} obj  目标对象 * @param {*} key  目标对象的一个属性 * @param {*} val  目标对象的一个属性的初始值 */function $set(obj, key, val) {  // vue 中在这进行了很多判断,val 是对象还是数组等等,我们就从简了  defineReactive(obj, key, val)}
// 调用 $set 方法给 obj 添加新的属性$set(obj, 'age', 18)
// 再次访问 obj.age obj.age = 20 //=> 🚀🚀~ set: age复制代码
复制代码

新定义的 $set 方法,内部也是把目标属性进行了 defineReactive 处理,这时我们再次更新 obj.age 的时候,就打印出了 set: age , 也就实现了一个响应式的处理。

VUE 中的数据响应式

实现简易的 Vue

这是 Vue 中最基本的使用方式,创建一个 Vue 的实例,然后就可以在模板中使用 data 中定义的响应式数据了,今天我们就来完成一个简易版的 Vue 。

<div id='app'>  <p>{{counter}}</p>  <p>{{counter}}</p>  <p>{{counter}}</p>  <p my-text='counter'></p>  <p my-html='desc'></p>  <button @click='add'>点击增加</button>  <p>{{name}}</p>  <input type="text" my-model='name'></div>
<script> const app = new MyVue({ el: "#app", data: { counter: 1, desc: `<span style='color:red' >一尾流莺</span>` }, methods: { add() { this.counter++ } } })</script>复制代码
复制代码

原理



设计类型介绍

  • MyVue: 框架构造函数

  • Observer:执行数据响应化(区分数据是对象还是数组)

  • Compile:编译模板,初始化视图,收集依赖(更新函数,创建 watcher

  • Watcher:执行更新函数(更新 dom )

  • Dep:管理多个 Watcher 批量更新

流程解析

  • 初始化时通过 Observer 对数据进行响应式处理,在 Observer 的 get 的时候创建一个 Dep 的实例,用来通知更新。

  • 初始化时通过 Compile 进行编译,解析模板语法,找到其中动态绑定的数据,从 data 中获取数据并初始化视图,把模板语法替换成数据。

  • 同时进行一次订阅,创建一个 Watcher ,定义一个更新函数 ,将来数据发生变化时,Watcher 会调用更新函数 把 Watcher 添加到 dep 中 。

  • Watcher 是一对一的负责某个具体的元素,data 中的某个属性在一个视图中可能会出现多次,也就是会创建多个 Watcher,所以一个 Dep 中会管理多个 Watcher

  • 当 Observer 监听到数据发生变化时,Dep 通知所有的 Watcher 进行视图更新。

代码实现 - 第一回合 数据响应式

observe

observe 方法相对于上面,做了一小点的改动,不是直接遍历调用 defineReactive 了,而是创建一个 Observer 类的实例 。

// 遍历obj 对其每个属性进行响应式处理function observe(obj) {  // 先判断类型, 响应式处理的目标一定要是个对象类型  if (typeof obj !== 'object' || obj === null) {    return  }  new Observer(obj)}复制代码
复制代码

Observer 类

Observer 类之前有解释过,它就是用来 做数据响应式 的,在它内部区分了数据是 对象 还是 数组 ,然后执行不同的响应式方案。

// 根据传入value的类型做响应的响应式处理class Observer {  constructor(value) {    this.value = value    if (Array.isArray(value)) {      // todo  这个分支是数组的响应式处理方式 不是本文重点 暂时忽略    } else {      // 这个分支是对象的响应式处理方式      this.walk(value)    }  }
// 对象的响应式处理 跟前面讲到过的一样,再封装一层函数而已 walk(obj) { // 遍历 obj, 对 obj 的每个属性进行响应式处理 Object.keys(obj).forEach(key => { defineReactive(obj, key, obj[key]) }) }}复制代码
复制代码

MVVM 类(MyVue)

这一回合我们就先在实例初始化的时候,对 data 进行响应式处理,为了能用 this.key 的方式访问this.$data.key,我们需要做一层代理。

class MyVue {  constructor(options) {    // 把数据存一下    this.$options = options    this.$data = options.data
// data响应式处理 observe(this.$data)
// 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key proxy(this) }}复制代码
复制代码

proxy 代理也非常容易理解,就是通过 Object.defineProperty 改变一下引用。

/** * 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key * @param {*} vm vue 实例 */function proxy(vm) {  Object.keys(vm.$data).forEach(key => {    // 通过  Object.defineProperty 方法进行代理 这样访问 this.key 等价于访问 this.$data.key    Object.defineProperty(vm, key, {      get() {        return vm.$data[key]      },      set(newValue) {        vm.$data[key] = newValue      }    })  })}复制代码
复制代码

代码实现 - 第二回合 模板编译

这一趴要实现下面这个流程,VNode 不是本文的重点,所以先去掉 Vnode 的环节,内容都在注释里啦~



// 解析模板语法// 1.处理插值表达式{{}}// 2.处理指令和事件// 3.以上两者初始化和更新class Compile {  /**   * @param {*} el 宿主元素   * @param {*} vm vue实例   */  constructor(el, vm) {    this.$vm = vm    this.$el = document.querySelector(el)
// 如果元素存在,执行编译 if (this.$el) { this.compile(this.$el) } }
// 编译 compile(el) { // 获取 el 的子节点,判断它们的类型做相应的处理 const childNodes = el.childNodes childNodes.forEach(node => { // 判断节点的类型 本文以元素和文本为主要内容 不考虑其他类型 if (node.nodeType === 1) { // 这个分支代表节点的类型是元素 // 获取到元素上的属性 const attrs = node.attributes // 把 attrs 转换成真实数组 Array.from(attrs).forEach(attr => { // 指令长 my-xxx = 'abc' 这个样子 // 获取节点属性名 const attrName = attr.name // 获取节点属性值 const exp = attr.value // 判断节点属性是不是一个指令 if (attrName.startsWith('my-')) { // 获取具体的指令类型 也就是 my-xxx 后面的 xxx 部分 const dir = attrName.substring(3) // 如果this[xxx]指令存在 执行这个指令 this[dir] && this[dir](node, exp) } }) } else if (this.isInter(node)) { // 这个分支代表节点的类型是文本 并且是个插值语法{{}} // 文本的初始化 this.compileText(node) } // 递归遍历 dom 树 if (node.childNodes) { this.compile(node) } }) }
// 编译文本 compileText(node) { // 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}} // this.$vm[RegExp.$1] 等价于 this.$vm[key] // 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化 node.textContent = this.$vm[RegExp.$1] }
// my-text 指令对应的方法 text(node, exp) { // 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key' // 把 this.$vm[key] 赋值给文本 即可 node.textContent = this.$vm[exp] }
// my-html 指令对应的方法 html(node, exp) { // 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key' // 把 this.$vm[key] 赋值给innerHTML 即可 node.innerHTML = this.$vm[exp] }
// 是否是插值表达式{{}} isInter(node) { return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent) }}复制代码
复制代码

代码实现 - 第三回合 收集依赖

视图中会用到的 data 中的属性 key 的地方,都可以被称为一个 依赖 ,同一个 key 可能会出现多次,每次出现都会创建一个 Watcher 进行维护,这些 Watcher 需要收集起来统一管理,这个过程叫做 收集依赖

同一个 key 创建的多个 Watcher 需要一个 Dep 来管理,需要更新时由 Dep 统一进行通知。



上面这段代码中,name1 用到了两次, 创建了两个 Watcher ,Dep1 收集了这两个 Watcher ,name2 用到了一次, 创建了一个 Watcher , Dep2 收集了这一个 Watcher



收集依赖的思路

  • defineReactive 时为每一个 key 创建一个 Dep 实例

  • 初始化视图时,读取某个 key,例如 name1,创建一个 Watcher1

  • 由于触发 name1 的 getter 方法,便将 Watcher1 添加到 name1 对应的 Dep 中

  • 当 name1 发生更新时,会触发 setter,便可通过对应的 Dep 通知其管理的所有 Watcher 进行视图的更新

Watcher 类

收集依赖的过程,在 Watcher 实例创建的时候,首先把实例赋值给 Dep.target,手动读一下 data.key 的值 ,触发 defineReactive 中的 get ,把当前的 Watcher 实例添加到 Dep 中进行管理,然后再把Dep.target 赋值为 null

// 监听器:负责依赖的更新class Watcher {  /**   * @param {*} vm vue 实例   * @param {*} key Watcher实例对应的 data.key   * @param {*} cb 更新函数   */  constructor(vm, key, updateFn) {    this.vm = vm    this.key = key    this.updateFn = updateFn
// 触发依赖收集 把当前 Watcher 赋值给 Dep 的静态属性 target Dep.target = this // 故意读一下 data.key 的值 为了触发 defineReactive 中的 get this.vm[this.key] // 收集依赖以后 再置为null Dep.target = null }
// 更新方法 未来被 Dep 调用 update() { // 执行实际的更新操作 this.updateFn.call(this.vm, this.vm[this.key]) }}复制代码
复制代码

Dep 类

addDep 方法把 Watchers 收集起来 放在 deps 中进行管理,notify 方法通知 deps 中的所有 Watchers 进行视图的更新。

class Dep {  constructor() {    this.deps = [] // 存放 Watchers  }  // 收集 Watchers  addDep(dep) {    this.deps.push(dep)  }
// 通知所有的 Watchers 进行更新 这里的 dep 指的就是收集起来的 Watcher notify() { this.deps.forEach(dep => dep.update()) }}复制代码
复制代码

升级 Compile

在第二回合中,我们的 Compile 类只实现了视图的初始化,所以在第三回合中要把它升级一下,支持视图的更新。

Watcher 实例就是在初始化后创建的,用来监听更新。

class Compile {  ... ... // 省略号的地方都没有发生改变    // 下面是发生改变的代码  /**   * 根据指令的类型操作 dom 节点   * @param {*} node dom节点   * @param {*} exp 表达式 this.$vm[key]   * @param {*} dir 指令   */  update(node, exp, dir) {    // 1.初始化 获取到指令对应的实操函数    const fn = this[dir + 'Updater']    //  如果函数存在就执行    fn && fn(node, this.$vm[exp])    // 2.更新 再次调用指令对应的实操函数 值由外面传入    new Watcher(this.$vm, exp, function(val) {      fn && fn(node, val)    })
}
// 编译文本 {{xxx}} compileText(node) { // 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}} // this.$vm[RegExp.$1] 等价于 this.$vm[key] // 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化 this.update(node, RegExp.$1, 'text') }
// my-text 指令 text(node, exp) { this.update(node, exp, 'text') }
// my-text 指令对应的实操 textUpdater(node, value) { // 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key' // 把 this.$vm[key] 赋值给文本 即可 node.textContent = value }
// my-html 指令 html(node, exp) { this.update(node, exp, 'html') }
// my-html 指令对应的实操 htmlUpdater(node, value) { // 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key' // 把 this.$vm[key] 赋值给innerHTML 即可 node.innerHTML = value }
// 是否是插值表达式{{}} isInter(node) { return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent) }
}复制代码
复制代码

Watcher 和 Dep 建立关联

首先在 defineReactive 中创建 Dep 实例,与 data.key 是一一对应的关系,然后再 get 中 调用 dep.addDep 进行依赖的收集,Dep.target 就是一个 Watcher。在 set 中 调用 dep.notify() 通知所有的 Watchers 更新视图。

function defineReactive(obj, key, val) {  ... ...   // 创建 Dep 实例 , 与 key 一一对应  const dep = new Dep()
// 通过该方法拦截数据 Object.defineProperty(obj, key, { // 读取数据的时候会走这里 get() { console.log('🚀🚀~ get:', key); // 依赖收集 Dep.target 就是 一个Watcher Dep.target && dep.addDep(Dep.target)
return val }, // 更新数据的时候会走这里 set(newVal) { // 只有当新值和旧值不同的时候 才会触发重新赋值操作 if (newVal !== val) { console.log('🚀🚀~ set:', key); // 如果 newVal 是个对象类型,再次做响应式处理。 if (typeof obj === 'object' && obj !== null) { observe(newVal) } val = newVal // 通知更新 dep.notify() } } })}复制代码
复制代码

代码实现 - 第四回合 事件和双向绑定

事件绑定

事件绑定也很好理解,首先判断节点的属性是不是以 @ 开头,然后拿到事件的类型,也就是例子中的 click, 再根据函数名找到 methods 中定义的函数体,最后添加事件监听就行了。

class Compile {  ... ... // 省略号的地方都没有发生改变  compile(el) {      // 判断节点属性是不是一个事件      if (this.isEvent(attrName)) {        // @click="onClick"        const dir = attrName.substring(1) // click        // 事件监听        this.eventHandler(node, exp, dir)      }  }  ... ...   // 判断节点是不是一个事件 也就是以@开头  isEvent(dir) {    return dir.indexOf("@") === 0  }  eventHandler(node, exp, dir) {    // 根据函数名字在配置项中获取函数体    const fn = this.$vm.$options.methods && this.$vm.$options.methods[exp]    // 添加事件监听    node.addEventListener(dir, fn.bind(this.$vm))  }  ... ... }复制代码
复制代码

双向绑定

my-model 其实也是一个指令,走的也是指令相关的处理逻辑,所以我们只需要添加一个 model 指令和对应的 modelUpdater 处理函数就行了。

my-model 双向绑定其实就是 事件绑定 和修改 value 的一个语法糖,本文以 input 为例,其它的表单元素绑定的事件会有不同,但是道理是一样的。

class Compile {
// my-model指令 my-model='xxx' model(node, exp) { // update 方法只完成赋值和更新 this.update(node, exp, 'model') // 事件监听 node.addEventListener('input', e => { // 将新的值赋值给 data.key 即可 this.$vm[exp] = e.target.value }) }
modelUpdater(node, value) { // 给表单元素赋值 node.value = value }
}复制代码
复制代码

现在也可以更新一下模板编译的流程图啦~



最后

如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163 相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点 star:http://github.crmeb.net/u/defu不胜感激 !

完整源码下载地址:https://market.cloud.tencent.com/products/33272

PHP 学习手册:https://doc.crmeb.com

技术交流论坛:https://q.crmeb.com

用户头像

CRMEB

关注

还未添加个人签名 2021.11.02 加入

CRMEB就是客户关系管理+营销电商系统实现公众号端、微信小程序端、H5端、APP、PC端用户账号同步,能够快速积累客户、会员数据分析、智能转化客户、有效提高销售、会员维护、网络营销的一款企业应用

评论

发布
暂无评论
实现简易的 Vue 响应式_CRMEB_InfoQ写作平台