VUE3浅析—VNode虚拟节点&diff算法&计算监听属性
这里写目录标题
- 一、VNode:虚拟节点
- 二、diff:对比算法
- 1、无key的算法源码实现,分三个步骤:
- 2、有key的算法源码实现,分三个步骤:
- 三、nextTick:异步更新DOM
- 1、源码解析:
- 四、监听属性
- 1、computed:监听计算属性
- 1.1、购物车案例:
- 1.2、源码阅读:
- 总结:
- 2、watch & watchEffect:监听属性
- 2.1、购物车案例:
- 2.2、watch源码阅读:
- 总结:
一、VNode:虚拟节点
前端主要是维护或者更新视图,要操作视图就需要操作DOM,如果直接操作DOM,那么就需要做大量的DOM操作,而现实中,我们或者只是改变了某一个DOM的某一部分内容,而视图中其他与该DOM相关的内容并没有改变,那么我们在更新视图的时候,只需要操作该DOM中改变的那部分内容即可,其他的内容不需要更改,这样就会提升性能。此时,VNode
就出现了,同步VNode与原来的Node进行diff(对比)
,找到改变的了内容,进行pathch(补丁)
即可。
VNode实际就是使用JS来描述一个DOM,就是纯的JS对象,它代表一个DOM元素,在运行时,就会根据这些信息创建新的DOM元素。在VUE3中,通过createVNode
将一个组件生成VNode,再通过render
将生成VNode挂载到对应的元素上。
import { createVNode, render } from 'vue'
// 生成VNode对象
const processBar = createVNode(ProcessBarComponent)
// 将VNode挂载到body上
render(processBar, document.body)// 这是通过createVNode转化后的VNode对象:
{anchor: nullappContext: nullchildren: nullcomponent: {uid: 0, vnode: {…}, type: {…}, parent: null, appContext: {…}, …}ctx: nulldirs: nulldynamicChildren: nulldynamicProps: nullel: div.wraps key: nullpatchFlag: 0props: nullref: nullscopeId: nullshapeFlag: 4slotScopeIds: nullssContent: nullssFallback: nullstaticCount: 0suspense: nulltarget: nulltargetAnchor: nulltransition: nulltype: {__name: 'ProcessBarComponent', __hmrId: '43816b8f', __scopeId: 'data-v-43816b8f', setup: ƒ, render: ƒ, …}__v_isVNode: true__v_skip: true
}
二、diff:对比算法
在VUE3中DOM上有没有key属性,会直接影响diff算法的实现。diff算法在renderer.ts
中实现。
1、无key的算法源码实现,分三个步骤:
- 创建元素
- 删除元素
- 新增元素
/*** 无key的算法源码实现* * @param c1 原DOM* @param c2 旧DOM*/
const patchUnkeyedChildren = (c1: VNode[],c2: VNodeArrayChildren,container: RendererElement,anchor: RendererNode | null,parentComponent: ComponentInternalInstance | null,parentSuspense: SuspenseBoundary | null,isSVG: boolean,slotScopeIds: string[] | null,optimized: boolean) => {c1 = c1 || EMPTY_ARRc2 = c2 || EMPTY_ARRconst oldLength = c1.lengthconst newLength = c2.lengthconst commonLength = Math.min(oldLength, newLength)let ifor (i = 0; i < commonLength; i++) {const nextChild = (c2[i] = optimized? cloneIfMounted(c2[i] as VNode): normalizeVNode(c2[i]))// 创建元素:同位置上的元素直接替换patch(c1[i],nextChild,container,null,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized)}// 如果多余出来元素,直接删除if (oldLength > newLength) {// 删除元素unmountChildren(c1,parentComponent,parentSuspense,true,false,commonLength)} else { // 如果少了元素,直接新增一个// 新增元素mountChildren(c2,container,anchor,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized,commonLength)}}
总结:
/*** 下述情况:newNode比oldNode多了一个nE元素,并且对比下来对应关系如下,替换规则为:* nA-替换->oA nB-替换->oB nE-替换->oC 多出来一个nC,直接创建一个nC元素*/
oldNode oA oB oC
newNode nA nB nE nC /*** 下述情况:newNode比oldNode少了一个oC元素,并且对比下来对应关系如下,替换规则为:* nA-替换->oA nB-替换->oB 少一个oC,直接删除oC元素*/
oldNode oA oB oC
newNode nA nB
2、有key的算法源码实现,分三个步骤:
- 前序对比,确定前序最长子序列
i
。 - 尾序对比,确定尾序最长子序列
e1
和e2
。 - 分别比对前序最长子序列
i
和尾序最长子序列e1
和e2
的大小:- 如果前序最长子序列
i
大于尾序最长子序列e1
,并且小于等于尾序最长子序列e2
,说明新DOM比旧DOM长度大,那么需要新增元素。 - 如果前序最长子序列
i
小于等于尾序最长子序列e1
,并且大于尾序最长子序列e2
,说明新DOM比旧DOM长度小,那么需要删除元素。
- 如果前序最长子序列
/*** 有key的算法源码实现* * @param c1 原DOM* @param c2 旧DOM*/const patchKeyedChildren = (c1: VNode[],c2: VNodeArrayChildren,container: RendererElement,parentAnchor: RendererNode | null,parentComponent: ComponentInternalInstance | null,parentSuspense: SuspenseBoundary | null,isSVG: boolean,slotScopeIds: string[] | null,optimized: boolean) => {let i = 0const l2 = c2.lengthlet e1 = c1.length - 1 // prev ending indexlet e2 = l2 - 1 // next ending index// 1. sync from start// (a b) c// (a b) d e/*** 这里实际上就是在进行前序对比,也就是从左到右进行比对,一旦发现两个元素不相同,就结束前序对比,接着进行尾序对比。两个元素相同的依据是:key和type* key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key*/while (i <= e1 && i <= e2) {const n1 = c1[i]const n2 = (c2[i] = optimized? cloneIfMounted(c2[i] as VNode): normalizeVNode(c2[i]))if (isSameVNodeType(n1, n2)) { // 主要是判断元素是不是相同的,通过key和type进行判断,key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key// 新增,patch函数中,如果n1 === n2就会return。patch(n1,n2,container,null,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized)} else {break // 如果发现n1 != n2,直接跳出循环,进行下一步}i++ // 前序对比,下标++,实际上就是在求新旧DOM前序最长子序列}// 2. sync from end// a (b c)// d e (b c)
/*** 这里实际上就是在进行尾序对比,也就是从右到左进行比对,一旦发现两个元素不相同,就结束尾序对比。两个元素相同的依据是:key和type* key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key*/while (i <= e1 && i <= e2) { // 这里i的值最大只能等于e1或者e2,记住i的值,后续有用const n1 = c1[e1]const n2 = (c2[e2] = optimized? cloneIfMounted(c2[e2] as VNode): normalizeVNode(c2[e2]))if (isSameVNodeType(n1, n2)) { // 主要是判断元素是不是相同的,通过key和type进行判断,key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key// 新增,patch函数中,如果n1 === n2就会return。patch(n1,n2,container,null,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized)} else { break // 如果发现n1 != n2,直接跳出循环,进行下一步}// 尾序对比,下标--,实际上就是在求旧DOM尾序最长子序列,因为新旧DOM的长度有可能不一样。所以新旧DOM的尾序最长子序列也可能不一样,需要用两个变量存储e1-- e2--}// 3. common sequence + mount// (a b)// (a b) c// i = 2, e1 = 1, e2 = 2// (a b)// c (a b)// i = 0, e1 = -1, e2 = 0
/*** 这里实际上就是在将多了元素进行删除,少了元素增加,i实际是上按照el和e2中最小的那个,即新旧DOM长度最小的那个长度。*/if (i > e1) { // 如果i比旧DOM的尾序最长子序列大,说明新DOM比旧DOM元素多,那么就需要新增元素if (i <= e2) { // 这种情况下,i的值最大只能等于e2,const nextPos = e2 + 1const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchorwhile (i <= e2) { // i的值最大只能等于e2,此时会循环将e2多出来的元素进行新增patch( // 此时,patch函数的第一个参数是null,也就是e1位置上第i元素为null,e2位置上第i元素不为null,并且新增在该位置上null,(c2[i] = optimized? cloneIfMounted(c2[i] as VNode): normalizeVNode(c2[i])),container,anchor,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized)i++}}}// 4. common sequence + unmount// (a b) c// (a b)// i = 2, e1 = 2, e2 = 1// a (b c)// (b c)// i = 0, e1 = 0, e2 = -1// 如果i比新DOM的尾序最长子序列大,说明新DOM比旧DOM元素少,那么就需要删除元素else if (i > e2) {while (i <= e1) { // 这种情况下,i的值最大只能等于e1,unmount(c1[i], parentComponent, parentSuspense, true)i++}}// 以下是特殊情况,也就是说无序的情况,涉及到元素的移动,对比,查找,有点复杂,暂不做讲解// 5. unknown sequence// [i ... e1 + 1]: a b [c d e] f g// [i ... e2 + 1]: a b [e d c h] f g// i = 2, e1 = 4, e2 = 5else {const s1 = i // prev starting indexconst s2 = i // next starting index// 5.1 build key:index map for newChildrenconst keyToNewIndexMap: Map<string | number | symbol, number> = new Map()for (i = s2; i <= e2; i++) {const nextChild = (c2[i] = optimized? cloneIfMounted(c2[i] as VNode): normalizeVNode(c2[i]))if (nextChild.key != null) {if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {warn(`Duplicate keys found during update:`,JSON.stringify(nextChild.key),`Make sure keys are unique.`)}keyToNewIndexMap.set(nextChild.key, i)}}// 5.2 loop through old children left to be patched and try to patch// matching nodes & remove nodes that are no longer presentlet jlet patched = 0const toBePatched = e2 - s2 + 1let moved = false// used to track whether any node has movedlet maxNewIndexSoFar = 0// works as Map<newIndex, oldIndex>// Note that oldIndex is offset by +1// and oldIndex = 0 is a special value indicating the new node has// no corresponding old node.// used for determining longest stable subsequenceconst newIndexToOldIndexMap = new Array(toBePatched)for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0for (i = s1; i <= e1; i++) {const prevChild = c1[i]if (patched >= toBePatched) {// all new children have been patched so this can only be a removalunmount(prevChild, parentComponent, parentSuspense, true)continue}let newIndexif (prevChild.key != null) {newIndex = keyToNewIndexMap.get(prevChild.key)} else {// key-less node, try to locate a key-less node of the same typefor (j = s2; j <= e2; j++) {if (newIndexToOldIndexMap[j - s2] === 0 &&isSameVNodeType(prevChild, c2[j] as VNode)) {newIndex = jbreak}}}if (newIndex === undefined) {unmount(prevChild, parentComponent, parentSuspense, true)} else {newIndexToOldIndexMap[newIndex - s2] = i + 1if (newIndex >= maxNewIndexSoFar) {maxNewIndexSoFar = newIndex} else {moved = true}patch(prevChild,c2[newIndex] as VNode,container,null,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized)patched++}}// 5.3 move and mount// generate longest stable subsequence only when nodes have movedconst increasingNewIndexSequence = moved? getSequence(newIndexToOldIndexMap): EMPTY_ARRj = increasingNewIndexSequence.length - 1// looping backwards so that we can use last patched node as anchorfor (i = toBePatched - 1; i >= 0; i--) {const nextIndex = s2 + iconst nextChild = c2[nextIndex] as VNodeconst anchor =nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchorif (newIndexToOldIndexMap[i] === 0) {// mount newpatch(null,nextChild,container,anchor,parentComponent,parentSuspense,isSVG,slotScopeIds,optimized)} else if (moved) {// move if:// There is no stable subsequence (e.g. a reverse)// OR current node is not among the stable sequenceif (j < 0 || i !== increasingNewIndexSequence[j]) {move(nextChild, container, anchor, MoveType.REORDER)} else {j--}}}}}
三、nextTick:异步更新DOM
vue中,更新DOM是异步的,但是更新数据是同步的,所有有时候会发现我们获取的DOM上的数据依旧是上次的旧数据,这时候,就需要使用nextTick
函数进行处理。nextTick
函数会获取到DOM更新后的数据。VUE官网的解释如下:当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。nextTick()
可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。以下是nextTick
函数的两种用法:
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'const msg = ref('Hello World!')// onMounted中才能获取到DOM
onMounted(() => {console.log(document.querySelector('#myDiv'))
})// 触发change事件的时候,是无法获取到DOM元素中更改更改后的新值的,必须使用nextTick函数才可以获取到DOM元素中更改更改后的新值的
const change = () => {msg.value = 'world'// 获取到的DOM中的值是Hello World!,即旧值,console.log('msg', document.querySelector('#myDiv')!.innerHTML)// 在nextTick函数中获取到的DOM中的值是world,即新值,nextTick(() => console.log('nextTick--msg', document.querySelector('#myDiv')!.innerHTML))
}// 使用async和await等待nextTick()执行完成之后获取DOM
// const change = async () => {
// msg.value = 'world'
// console.log('msg', document.querySelector('#myDiv')!.innerHTML)
// await nextTick()
// console.log('nextTick--msg', document.querySelector('#myDiv')!.innerHTML)
// }// 在nextTick中使用回调函数
const change = () => {msg.value = 'world'// 获取到的DOM中的值是Hello World!,即旧值,console.log('msg', document.querySelector('#myDiv')!.innerHTML)// 在nextTick函数中获取到的DOM中的值是world,即新值,nextTick(() => console.log('nextTick--msg', document.querySelector('#myDiv')!.innerHTML))
}</script><template><div @click="change" id="myDiv" class="myDiv">{{ msg }}</div>
</template><style scoped lang="css">
.myDiv {color: red;width: 100px;height: 60px;
}
</style>
1、源码解析:
nextTick的源码在packages/runtime-core/src/scheduler.ts
中,是一个方法。
// nextTick是一个函数,接收一个函数,最终返回一个Promise,也就是说,实际上我们传入的函数会被放在一个Promise中去执行,即把我们的函数变成异步去执行
export function nextTick<T = void>(this: T,fn?: (this: T) => void
): Promise<void> {const p = currentFlushPromise || resolvedPromise; // p实际上就是一个Promisereturn fn ? p.then(this ? fn.bind(this) : fn) : p; // 最终返回一个Promise
}// 任务实际上是放在一个队列中的,就像VUE官网说的DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中。
export function queueJob(job: SchedulerJob) {// the dedupe search uses the startIndex argument of Array.includes()// by default the search index includes the current job that is being run// so it cannot recursively trigger itself again.// if the job is a watch() callback, the search will start with a +1 index to// allow it recursively trigger itself - it is the user's responsibility to// ensure it doesn't end up in an infinite loop.if (!queue.length ||!queue.includes(job,isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)) {if (job.id == null) { // 每一个异步任务都会被分配一个id,这个id是Vue自增的,也会被挂在到当前的实例上queue.push(job); // 如果id为空,直接添加到异步队列末尾} else {// // 如果id不为空,那个就根据id计算出来索引,然后将当前job添加到异步队列中// findInsertionIndex实际上就是根据二分查找,找到合适的索引位置,价格job添加到异步队列中,并保证queue中的job都是按照job.id递增排列的,这样做的目的是// 为了保证每一个任务都会被添加到队列中,而不会被遗漏,并且保证任务不会被重复添加,还要保证父组件的操作一定要在子组件中的前边queue.splice(findInsertionIndex(job.id), 0, job); }queueFlush(); // 核心就是创建了一个任务}
}// 实际上就是创建了一个微任务,然后执行flushJobs函数
function queueFlush() {if (!isFlushing && !isFlushPending) {isFlushPending = true;currentFlushPromise = resolvedPromise.then(flushJobs);}
}// flushJobs中最终会执行我们传递的函数,主要的有三个操作:
function flushJobs(seen?: CountMap) {// 省略部分代码// 1、给队列中的任务排序,目的是保证父组件的操作一定要在子组件中的前边或者说跳过一些已经被卸载的组件的更新操作,比如在父组件更新的时候,子组件刚好被卸载了,那么该子组件的更新操作可以被跳过queue.sort(comparator);try {for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {const job = queue[flushIndex];// 实际就是执行job,只不过就是封装处理了异常,真正的会执行我们的函数callWithErrorHandling(job, null, ErrorCodes.SCHEDULER);}} finally {// 这里与配置的执行策略有关系:// watch('', ()=>{}, {flush: 'post'}),如果flush配置的是post就会执行flushPostFlushCbs(seen);}
}
四、监听属性
1、computed:监听计算属性
computed是VUE提供的一个用来监听和计算的api,常用的场景是就是:如果我们的购物车中,增加一个物品或者少一个物品,结算时的总价就要重新计算,而且computed是有缓存的,也就是说当总价依赖的数量和单价没有发生变化的时候,计算逻辑是不会变化的,总价直接获取上一次缓存的值,此时使用computed属性来计算总价是最合适的,下边是一个简单的购物车实现案例,主要利用computed属性实现。
1.1、购物车案例:
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'const searchText = ref('')interface Goods {name: stringprice: numbercolor: stringnum: number
}const goodsData = reactive<Goods[]>([{name: '红上衣',price: 12.5,color: '红色',num: 1},{name: '黑上衣',price: 12.5,color: '黑色',num: 1},{name: '红裤子',price: 12.5,color: '红色',num: 1},{name: '红帽子',price: 12.5,color: '红色',num: 1}
])/*** computed的对象写法,接收一个对象,对象中重写set和get方法。* 这意味着不但可以对监听的对象取值,而且还可以赋值,例如:* 直接操作searchData.value = []完全可以*/
// const searchData = computed({
// get() {
// return goodsData.filter((item: Goods) => item.name.includes(searchText.value))
// },
// set(newValue) {// searchData.value = []操作时,set函数会被触发
// console.log('newValue', newValue)
// }
// })/*** 接收一个函数的写法,该写法只支持获取值,而无法对监听的对象进行赋值操作。* 直接操作searchData.value = []会报错value是一个制度属性*/
const searchData = computed((): Goods[] =>goodsData.filter((item: Goods) => item.name.includes(searchText.value))
)const searchOnlyOne = () => (searchText.value = '上衣')const deleteOne = (index: number) => goodsData.splice(index, 1)const totalePrice = computed((): number =>searchData.value.reduce((prve: number, item: Goods) => prve + item.price * item.num, 0)
)
</script><template><div title="搜索框"><input v-model="searchText" placeholder="请输入……" /><button @click="searchOnlyOne">只搜索上衣</button></div><br /><div><table border="1px"><thead><tr><th>名称</th><th>价格</th><th>颜色</th><th>数量</th><th>操作</th></tr></thead><tbody><tr v-for="(item, index) in searchData" :key="index"><td>{{ item.name }}</td><td>{{ item.price }}</td><td>{{ item.color }}</td><td>{{ item.num }}</td><td><button @click="deleteOne(index)">删除</button></td></tr></tbody><tfoot><tr>总计:{{totalePrice}}</tr></tfoot></table></div>
</template><style scoped lang="css"></style>
1.2、源码阅读:
源码位置:packages/reactivity/src/computed.ts
// computed.ts
export function computed<T>(getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,debugOptions?: DebuggerOptions,isSSR = false
) {let getter: ComputedGetter<T>;let setter: ComputedSetter<T>;// computed两种写法,上边的购物车中案例中的写法:const onlyGetter = isFunction(getterOrOptions); // 如果参数是一个方法,说明只有get函数参数,那么set函数直接报错if (onlyGetter) {getter = getterOrOptions;setter = __DEV__? () => {console.warn("Write operation failed: computed value is readonly");}: NOOP;} else { // 否则是对象参数写法getter = getterOrOptions.get;setter = getterOrOptions.set;}// 将处理好的get和set函数交给 ComputedRefImpl 处理const cRef = new ComputedRefImpl(getter,setter,onlyGetter || !setter,isSSR);if (__DEV__ && debugOptions && !isSSR) {cRef.effect.onTrack = debugOptions.onTrack;cRef.effect.onTrigger = debugOptions.onTrigger;}return cRef as any;
}// ComputedRefImpl实现
export class ComputedRefImpl<T> {public dep?: Dep = undefined;private _value!: T;public readonly effect: ReactiveEffect<T>;public readonly __v_isRef = true;public readonly [ReactiveFlags.IS_READONLY]: boolean = false;public _dirty = true; // 这是一个脏值检测标识。默认为true,也就是第一次进来是需要重新计算值的,computed的缓存与次相关public _cacheable: boolean;constructor(getter: ComputedGetter<T>,private readonly _setter: ComputedSetter<T>,isReadonly: boolean,isSSR: boolean) {// ReactiveEffect类似于响应式原理中effect,专门收集依赖。第一次进来是不会走这块的,只有依赖发生了改变的时候才会走// 实际上第二个参数是一个函数被放在一个调度器中,当依赖发生变化,就会触发trigger函数,而在trigger函数内部,就会执行effect.scheduler()this.effect = new ReactiveEffect(getter, () => {if (!this._dirty) { // 如果为false,说明要重新计算值this._dirty = true; // 设置为true。第二次进来的时候,默认从个缓存中获取。triggerRefValue(this);}});this.effect.computed = this;this.effect.active = this._cacheable = !isSSR;this[ReactiveFlags.IS_READONLY] = isReadonly;}// 这里就是劫持了value,所以computed返回的值里面,必须.value才能获取get value() {// the computed ref may get wrapped by other proxies e.g. readonly() #3376const self = toRaw(this); // 将ref对象转化为原始对象trackRefValue(self);// 脏值检测,如果_dirty为true,重新计算,否则返回上一次值,也就实现了缓存机制if (self._dirty || !self._cacheable) {self._dirty = false;self._value = self.effect.run()!; // 这里实际上是重新获取计算表达式的值,如果_dirty为true,就是需要重新计算值}return self._value; // 返回原来的值}set value(newValue: T) {this._setter(newValue);}
}
总结:
1、computed的对象写法:传入一个对象,对象里面实现get和set方法。computed的函数写法:传入一个函数,该情况下无法实现set值的逻辑。
2、computed参数交给ComputedRefImpl实现,在实现里定义了一个变量_dirty
,默认是true,该变量是实现computed缓存的关键。
3、computed是有缓存的,缓存实际上是通过脏值检测(_dirty
)实现的,当第一次进来的时候,_dirty
为true,会计算一次并奖结果返回且将_dirty
设置为false。如果依赖发生了变化,那么就会走依赖收集的那一套逻辑,并将_dirty
设置为true,因为_dirty
被设置为true,所以需要重新计算一次,否则,依赖没有发生变化的情况下,就不会触发依赖收集,即_dirty
保持为上次设置的false,就不会重新计算,返回上一次的值,进而实现缓存。
2、watch & watchEffect:监听属性
watch & watchEffect是VUE实现的用来监听属性变化的API,可用于监听一些属性的变化来实现特定的逻辑,watch & watchEffect是没有缓存的,也就是当属性变化一次的时候,watch & watchEffect就会被调用一次,以下是使用watch & watchEffect实现的购物车案例,注意和computed做比对。
2.1、购物车案例:
<script setup lang="ts">
import { ref, reactive, watch, watchEffect } from 'vue'const searchText = ref('')interface Goods {name: stringprice: numbercolor: stringnum: number
}let goodsData = reactive<Goods[]>([{name: '红上衣',price: 12.5,color: '红色',num: 1},{name: '黑上衣',price: 12.5,color: '黑色',num: 1},{name: '红裤子',price: 12.5,color: '红色',num: 1},{name: '红帽子',price: 12.5,color: '红色',num: 1}
])let searchData = reactive<Goods[]>(goodsData)
let totalePrice = ref<number>(0)/*** watch:监听值变化,如果变化,则执行指定的操作。** @param target:目标对象,监听多个使用数据传递。* @param cb: 回调函数,第一个参数是旧值,第二个参数是新值。* @param options: 选项。可选项:* immediate:是否在初始化时执行指定的操作。默认为false。* deep:是否深入监听对象的属性(如果对象的属性可用)。如果deep为false,则只监听值,如果为true,则监听对象的子属性。所以是对象的话,一定设置为true* flush:watch执行的时机,'pre': 在页面加载前被调用,'sync': 页面加载时被调用,'post': 页面加载后被调用* @return stop:返回一个watchhandle,用于结束监听*/
// const stop = watch(
// [searchText, goodsData],
// (): Goods[] => {
// searchData = goodsData.filter((item) => item.name.includes(searchText.value))
// totalePrice.value = searchData.reduce((prev, curr) => prev + curr.price * curr.num, 0)
// return searchData
// },
// {
// immediate: true,
// deep: true,
// flush: 'pre' || 'post' || 'sync'
// }
// )// stop() 结束监听// const User = {
// name: 'Demon Slayer',
// age: 18
// }// 如果要监听一个对象的某一个属性的变化而不是全部属性的变化的时候,则可以使用下面的代码
// watch(
// () => User.name,
// (): Goods[] => {
// searchData = goodsData.filter((item) => item.name.includes(searchText.value))
// totalePrice.value = searchData.reduce((prev, curr) => prev + curr.price * curr.num, 0)
// return searchData
// },
// {
// immediate: true,
// deep: true,
// flush: 'pre' || 'post' || 'sync'
// }
// )/*** watchEffect:watch的高级监听,接收一个回调函数,函数里面只需要写入需要监听的对象即可做到监听,默认开启deep属性,* 所以使用watchEffect监听对象,默认就是深度监听,并且默认立即执行*/
watchEffect(() => {searchTextsearchData = goodsData.filter((item) => item.name.includes(searchText.value))totalePrice.value = searchData.reduce((prev, curr) => prev + curr.price * curr.num, 0)
})const deleteOne = (index: number) => {goodsData.splice(index, 1)
}const searchOnlyOne = () => {searchText.value = '上衣'
}
</script><template><div><input v-model="searchText" placeholder="请输入……" /><button @click="searchOnlyOne">只搜索上衣</button></div><div><table border="1px"><thead><tr><th>名称</th><th>价格</th><th>颜色</th><th>数量</th><th>操作</th></tr></thead><tbody><tr v-for="(item, index) in searchData" :key="index"><td>{{ item.name }}</td><td>{{ item.price }}</td><td>{{ item.color }}</td><td>{{ item.num }}</td><td><button @click="deleteOne(index)">删除</button></td></tr></tbody><tfoot><tr>总计:{{totalePrice}}</tr></tfoot></table></div>
</template><style scoped lang="css"></style>
2.2、watch源码阅读:
源码位置:packages/runtime-core/src/apiWatch.ts
// watch的函数定义里面实际上就是调用doWatch方法:
function doWatch(source: WatchSource | WatchSource[] | WatchEffect | object,cb: WatchCallback | null,{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {if (__DEV__ && !cb) {if (immediate !== undefined) {warn(`watch() "immediate" option is only respected when using the ` +`watch(source, callback, options?) signature.`)}if (deep !== undefined) {warn(`watch() "deep" option is only respected when using the ` +`watch(source, callback, options?) signature.`)}}function doWatch(source: WatchSource | WatchSource[] | WatchEffect | object,cb: WatchCallback | null,{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {if (__DEV__ && !cb) {// 省略部分代码const instance =getCurrentScope() === currentInstance?.scope ? currentInstance : null// const instance = currentInstancelet getter: () => anylet forceTrigger = falselet isMultiSource = false// 一开始,要先格式化参数if (isRef(source)) { // 如果target是一个ref对象,直接将source.value赋值gettergetter = () => source.valueforceTrigger = isShallow(source) } else if (isReactive(source)) { // 如果target是一个reactive对象,直接将source赋值gettergetter = () => sourcedeep = true // 如果reactive对象,会直接开启deep,深度监听} else if (isArray(source)) { // 如果target是一个数组对象,那么就要对数组中的每一个对象参数都进行格式化,再次确认属于ref对象还是reactive对象还是其他isMultiSource = trueforceTrigger = source.some(s => isReactive(s) || isShallow(s))getter = () =>source.map(s => {if (isRef(s)) {return s.value} else if (isReactive(s)) {return traverse(s) // 实际就是递归监听每一个属性} else if (isFunction(s)) {return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)} else {__DEV__ && warnInvalidSource(s)}})} else if (isFunction(source)) { // 如果source是一个函数,会先判断下是否有cb,如果有cb,要对source进行封装处理if (cb) {// getter with cbgetter = () =>callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)} else { // 否则如果没有cb,那么就是执行watchEffect的逻辑// no cb -> simple effectgetter = () => {if (instance && instance.isUnmounted) {return}if (cleanup) {cleanup()}return callWithAsyncErrorHandling(source,instance,ErrorCodes.WATCH_CALLBACK,[onCleanup])}}} else {getter = NOOP__DEV__ && warnInvalidSource(source)}// 省略部分代码if (cb && deep) { // 如果有cb和开启了deep,表明要对对象进行深度监听const baseGetter = gettergetter = () => traverse(baseGetter())}let cleanup: () => voidlet onCleanup: OnCleanup = (fn: () => void) => {cleanup = effect.onStop = () => {callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)}}// 省略部分代码// 一开始给oldValue赋值一个空对象let oldValue: any = isMultiSource? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE): INITIAL_WATCHER_VALUEconst job: SchedulerJob = () => {if (!effect.active) {return}if (cb) { // 如果有cb,则计算一次新值newValue// watch(source, cb)const newValue = effect.run()if (deep ||forceTrigger ||(isMultiSource? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])): hasChanged(newValue, oldValue)) ||(__COMPAT__ &&isArray(newValue) &&isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))) {// cleanup before running cb againif (cleanup) {cleanup()}callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [newValue, // 新值已经计算出来了// pass undefined as the old value when it's changed for the first timeoldValue === INITIAL_WATCHER_VALUE // 最开始oldValue默认设置的空对象,那么第一次执行的时候,此时oldValue就被设置为undefined? undefined: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE? []: oldValue, onCleanup])oldValue = newValue // 随后将新值设置为oldValue,那么下一次的时候,oldValue就为上一次的newValue,而newValue在上边会重新计算,从而就会记录oldValue和newValue}} else {// watchEffecteffect.run()}}// important: mark the job as a watcher callback so that scheduler knows// it is allowed to self-trigger (#1727)job.allowRecurse = !!cb;let scheduler: EffectScheduler;if (flush === "sync") { // 如果flush设置为sync,同步执行jobscheduler = job as any; // the scheduler function gets called directly} else if (flush === "post") { // 如果flush设置为post,将job交给queuePostRenderEffect,并在组件加载完之后执行jobscheduler = () => queuePostRenderEffect(job, instance && instance.suspense);} else {// default: 'pre'// 默认flush设置为pre,将job加入到队列中,并设置job.pre = true,说明在组件加载完之前执行jobjob.pre = true;if (instance) job.id = instance.uid;scheduler = () => queueJob(job);}// 最后将job和scheduler交给ReactiveEffect,进行依赖收集,当依赖发生变化的时候,就会处理已开收集,从而去执行jobconst effect = new ReactiveEffect(getter, scheduler);if (__DEV__) {effect.onTrack = onTrack;effect.onTrigger = onTrigger;}// initial runif (cb) { // 如果设置了cb,并且immediate设置为true,则会立即执行jobif (immediate) {job(); // 立马调用一次job函数,在job函数里面,会去计算一次新值。job的定义在上边} else {oldValue = effect.run(); // 否则计算一次oldValue,}} else if (flush === "post") {queuePostRenderEffect(effect.run.bind(effect),instance && instance.suspense);} else {effect.run();}const unwatch = () => {effect.stop();if (instance && instance.scope) {remove(instance.scope.effects!, effect);}};if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch);return unwatch;
}
总结:
1、watch接收三个参数,target、cb、options,并返回一个watchstophandle,用于停止监听。
2、在options中可以设置watch的执行时机,对象的深度监听等。
3、watch最终将函调封装为job调度,依赖发生变化的时候,job被触发,每一次调用通过将上一次的新值赋值给旧值并且重新计算新值,来达到记录新旧值的机制。