响应式系统的关键要素包括什么,怎样实现响应式的
Admin 2022-06-23 群英技术资讯 292 次浏览
按照原生 JS 的逻辑想一想,我们应该做三件事:监听点击事件,在事件处理函数中修改数据,然后手动去修改 DOM 重新渲染,这和我们使用 Vue 的最大区别在于多了一步【手动去修改DOM重新渲染】,这一步看起来简单,但我们得考虑几个问题:
所以要实现一个响应式系统并不简单,来结合 Vue 源码学习一下 Vue 中优秀的思想叭~
显然通过监听所有用户交互事件来获取数据变化是非常繁琐的,且有些数据的变动也不一定是用户触发的,那Vue是怎么监听数据变化的呢?—— Object.defineProperty
Object.defineProperty 方法为什么能监听数据变化?该方法可以直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象,先来看一下它的语法:
Object.defineProperty(obj, prop, descriptor) // obj是传入的对象,prop是要定义或修改的属性,descriptor是属性描述符
这里比较核心的是 descriptor,它有很多可选键值。这里我们最关心的是 get 和 set,其中 get 是一个给属性提供的 getter 方法,当我们访问了该属性的时候会触发 getter 方法;set 是一个给属性提供的 setter 方法,当我们对该属性做修改的时候会触发 setter 方法。
简言之,一旦一个数据对象拥有了 getter 和 setter,我们就可以轻松监听它的变化了,并可将其称之为响应式对象。具体怎么做呢?
function observe(data) { if (isObject(data)) { Object.keys(data).forEach(key => { defineReactive(data, key) }) } } function defineReactive(obj, prop) { let val = obj[prop] let dep = new Dep() // 用来收集依赖 Object.defineProperty(obj, prop, { get() { // 访问对象属性了,说明依赖当前对象属性,把依赖收集起来 dep.depend() return val } set(newVal) { if (newVal === val) return // 数据被修改了,该通知相关人员更新相应的视图了 val = newVal dep.notify() } }) // 深层监听 if (isObject(val)) { observe(val) } return obj }
这里我们需要一个 Dep 类(dependency)来做依赖收集
PS:Object.defineProperty 只能监听已存在的属性,对于新增的属性就无能为力了,同时无法监听数组的变化(Vue2中通过重写数组原型上的方法解决这一问题),所以在 Vue3 中将其换成了功能更强大的Proxy。
基于构造函数实现:
function Dep() { // 用deps数组来存储各项依赖 this.deps = [] } // Dep.target用来记录正在运行的watcher实例,这是一个全局唯一的 Watcher // 这是一个非常巧妙的设计,因为JS是单线程的,在同一时间只能有一个全局的 Watcher 被计算 Dep.target = null // 在原型上定义depend方法,每个实例都能访问 Dep.prototype.depend = function() { if (Dep.target) { this.deps.push(Dep.target) } } // 在原型上定义notify方法,用于通知watcher更新 Dep.prototype.notify = function() { this.deps.forEach(watcher => { watcher.update() }) } // Vue中会有嵌套的逻辑,比如组件嵌套,所以利用栈来记录嵌套的watcher // 栈,先入后出 const targetStack = [] function pushTarget(_target) { if (Dep.target) targetStack.push(Dep.target) Dep.target = _target } function popTarget() { Dep.target = targetStack.pop() }
这里主要理解原型上的两个方法:depend 和 notify,一个用于添加依赖,一个用于通知更新。我们说收集“依赖”,那 this.deps 数组里到底存的是啥东西啊?Vue 设置了 Watcher 的概念用作依赖表示,即 this.deps 里收集的是一个个 Watcher。
Watcher,在Vue中有三种类型,分别用于页面渲染以及computed和watch这两个API,为了区分,将不同用处的 Watcher 分别称为 renderWatcher、computedWatcher 和 watchWatcher。
用 class 实现一下:
class Watcher { constructor(expOrFn) { // 这里传入参数不是函数时需要解析,parsePath略 this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn) this.get() } // class中定义函数不需要写function get() { // 执行到这时,this是当前的watcher实例,也是Dep.target pushTarget(this) this.value = this.getter() popTarget() } update() { this.get() } }
到这里,一个简单的响应式系统就成形了,总结来说:Object.defineProperty 让我们能够知道谁访问了数据以及什么时候数据发生变化,Dep 可以记录都有哪些 DOM 和某个数据有关,Watcher 可以在数据变化的时候通知 DOM 去更新。
Watcher 和 Dep 是一个非常经典的观察者设计模式的实现。
虚拟 DOM 是用 JS 中的对象来表示真实的DOM,如果有数据变动,先在虚拟 DOM 上改动,最后再去改动真实的DOM,good idea!
关于虚拟 DOM 的优势,还是听尤大的:
在我看来 Virtual DOM 真正的价值从来都不是性能,而是它 1) 为函数式的 UI 编程方式打开了大门;2) 可以渲染到 DOM 以外的 backend。
举个例子:
<template> <div id="app" class="container"> <h1>HELLO WORLD!</h1> </div> </template>
// 对应的vnode { tag: 'div', props: { id: 'app', class: 'container' }, children: { tag: 'h1', children: 'HELLO WORLD!' } }
我们可以这样去定义:
function VNode(tag, data, childern, text, elm) { this.tag = tag this.data = data this.childern = childern this.text = text this.elm = elm // 对真实节点的引用 }
数据变化时,会触发渲染 watcher 的回调,更新视图。Vue 源码中在更新视图时用 patch 方法比较新旧节点的异同。
(1)判断新旧节点是不是相同节点
function sameVNode() function sameVnode(a, b) { return a.key === b.key && ( a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) }
(2)若新旧节点不同
替换旧节点:创建新节点 --> 删除旧节点
(3)若新旧节点相同
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx, idxInOld, vnodeToMove, refElm // 以上是新旧Vnode的首尾指针、新旧Vnode的首尾节点 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 如果不满足这个while条件,表示新旧Vnode至少有一个已经遍历了一遍了,就退出循环 if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较旧的开头和新的开头是否是相同节点 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 比较旧的结尾和新的结尾是否是相同节点 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 比较旧的开头和新的结尾是否是相同节点 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 比较旧的结尾和新的开头是否是相同节点 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 设置key和不设置key的区别: // 不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 抽取出oldVnode序列的带有key的节点放在map中,然后再遍历新的vnode序列 // 判断该vnode的key是否在map中,若在则找到该key对应的oldVnode,如果此oldVnode与遍历到的vnode是sameVnode的话,则复用dom并移动dom节点位置 if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // same key but different element. treat as new element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }
这里主要的逻辑是:新节点的头和尾与旧节点的头和尾分别比较,看是不是相同节点,如果是就直接patchVnode;否则的话,用一个 Map 存储旧节点的 key,然后遍历新节点的 key 看它们是不是在旧节点中存在,相同 key 那就复用;这里时间复杂度是O(n),空间复杂度也是O(n),用空间换时间~
diff 算法主要是为了减少更新量,找到最小差异部分 DOM ,只更新差异部分。
所谓 nextTick,即下一个 tick,那 tick 是什么呢?
我们知道 JS 执行是单线程的,它处理异步逻辑是基于事件循环,主要分为以下几步:
主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,并且每个 macro task 结束后,都要清空所有的 micro task。
for (macroTask of macroTaskQueue) { // 1. Handle current MACRO-TASK handleMacroTask() // 2. Handle all MICRO-TASK for (microTask of microTaskQueue) { handleMicroTask(microTask) } }
在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate、setInterval;常见的 micro task 有 MutationObsever 和 Promise.then。
我们知道数据的变化到 DOM 的重新渲染是一个异步过程,发生在下一个 tick。比如我们平时在开发的过程中,从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick 后执行。比如下面的伪代码:
getData(res).then(() => { this.xxx = res.data this.$nextTick(() => { // 这里我们可以获取变化后的 DOM }) })
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:mmqy2019@163.com进行举报,并提供相关证据,查实之后,将立刻删除涉嫌侵权内容。
猜你喜欢
JavaScript中FontFace对象的使用方式 目录 介绍 兼容性 创建FontFace对象 FontFace对象属性 FontFace对象方法 将创建的FontFace字体添加到页面中 通过字体Promise回调添加 通过Ajax获取字体文件后回调添加 页面中使用我们添加的字体 css事先定义好 通过js更改元素的字体 FontFace字面理解就是字体脸,也就是文字字体样式的意思,它是通过使用javascript来定义字体对象,并且引入客户端没有安装得字体文件,可以是者服务器端,或者是第三方字体库文件. 介绍 基本语法: concat fo
为什么说 Node.js 不是完全的单线程?如何理解?下面本篇文章就来带大家探讨一下,希望对大家有所帮助!
这篇文章给大家分享的是用JS怎样求两个数最大值的方法。下文分享了三种方法,对新手学习JavaScript的使用有一定的帮助,文中的示例代码介绍得很详细,有需要的朋友可以参考,接下来就跟随小编一起了解看看吧。
闭包在JavaScript高级程序设计(第3版)中是这样描述:闭包是指有权访问另一个函数作用域中的变量的函数,下面这篇文章主要给大家介绍了关于javascript解决闭包漏洞的一个问题的相关资料,需要的朋友可以参考下
javascript的new关键字用法是什么?关于new关键字,大家应该都比较熟悉,这篇文章主要给大家分享构造函数、new操作符和如何实现一个new,小编觉得挺实用的,感兴趣的朋友就继续往下看吧。
成为群英会员,开启智能安全云计算之旅
立即注册Copyright © QY Network Company Ltd. All Rights Reserved. 2003-2020 群英 版权所有
增值电信经营许可证 : B1.B2-20140078 粤ICP备09006778号 域名注册商资质 粤 D3.1-20240008