前言#
我发现被流放到处理bug可以专心于某一项内容,并且有时间去专研,舒服。
当然,看别人写的代码也是一件很痛苦的事情。。但就整体而言,还是舒服过开发需求的。
而开发需求的时候我压根不想去思考这些东西,先把需求梭完再说,而梭完之后就是无尽的bug/改需求等着处理,也没时间和心情去专研是为什么。。。
bug#
扯远了,我们来说下这个问题。
有一段js代码在死循环执行,直接卡死整个小程序,包括渲染线程。
环境#
vue2(bug环境), vue3(分析环境)
相关代码#
我们先来看下相关代码
computed: {
bbb() {
if (!xxx) {
return {};
}
return a(array);
},
}
function a(array) {
// ...
for (let i = 0; i < array.length; i++) {
// tl排个序,方便后续比较
let xxx = (array[i].xx || []).sort((a, b) => {
// ...
});
// ...
}
// ...
}具体代码我就不放了,是公司的资产。
上面这段代码就是核心问题点。
bug原因#
相信你看完上面的代码你已经知道是为什么了,对,sort: Array.prototype.sort() - JavaScript | MDN (mozilla.org) 会改变被排序的数据

我之前也写过一篇关于Array.prototype.sort的原理分析:前端Array.prototype.sort学习 + 了解原理 - 知乎 (zhihu.com)
以及相关算法的实现:坏蛋Dan:基于Typescript实现timsort算法
感兴趣的可以看下。
回到我们的问题中,
根据我们学到的computed: Reactivity API: Core | Vue.js (vuejs.org)知识,代码中的arr实际上被computed代理了,那么这个数据在变化的时候会导致这个computed也跟着变化,那么这就是个死循环。
(PS: 我写过computed相关的源码分析,感兴趣的可以去看下:坏蛋Dan:vue runtime源码分析学习——响应式原理day2: computed)
bug处理方案#
知道问题,那就简单了,直接给它deepCopy一下,断开引用即可。
问题整理#
这个bug很简单,一般开发也不应该放任到生产环境,因为vue会给一个相关报错:

相信这个warn大家都见过。
但是这个问题在开发环境下是没问题的(当然,会出现上面的warn),可以继续执行。
而生产环境就有会卡死。
而production是我们开发控制的,所以这里我们可以分析的出,出问题的在我们的包中,而非小程序自身。
结合上面computed抛出的warn,我们可以得出下面这个点:
computed在dev和production场景下的差异。
源码跟踪#
这个过程可能会有些长,慢慢来。当然,直接看调用栈会快很多,但是分析嘛,一点一点的来。
这下面涉及到的调度相关的内容其实我在另一篇文章中有详细分析,还是建议去看下的:vue runtime源码分析学习——day8:patch打补丁part4:processComponent处理组件 - 知乎 (zhihu.com)
computed源码相关#
我们来看下相关的vue源码
代码位置:packages\reactivity\src\computed.ts
export class ComputedRefImpl<T> {
public readonly effect: ReactiveEffect<T>
constructor(
getter: ComputedGetter<T>,
// ...
) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
})
this.effect.computed = this
// ...
}
}
export function computed<T>(
// ...
) {
// ...
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
// ...
}这是我们需要分析的一块,其它代码我们先忽略。
这里简单地说,你的整个函数比如我们上面提到的bbb就是一个getter,它返回的值就是cache,代理的数据一有变化不会立即触发这个getter,只有下次再来读这个bbb的时候才会再触发这个getter获取最新的值, getter中被读到的数据都会被这个computed代理(指的是被代理的数据的依赖中存放着这个computed,以后数据更新会通知这个computed去更新)。
这里有一点比较重要:别的数据也可以依赖这个computed bbb,这一点很重要,这就意味着,这个computed的依赖中也会有这些个数据,当computed更新的时候就会去通知这些个数据去更新。
所以这里其实有两层关系:
比如数据a,b, c,computed d, 依赖于computed d的e和f。

那么computed的基础逻辑就讲到这里。
回到我们的代码中
这大段代码中,new ReactiveEffect(getter,()=>{if(!this._dirty){this._dirty =truetriggerRefValue(this)}})这一段是重点。
这一块代码我们就不看了,一方面篇幅较长,另一方面这不是我们的目标。
简单的说下,这里是依赖收集的核心,也是set触发组件等更新的核心。
set的时候会触发一个叫trigger的方法,这个方法会去通知所有的依赖去更新。
在这里,我们的依赖指的上面的bbb。
这里我们只看我们需要的地方
function triggerEffect(
effect: ReactiveEffect,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
}
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}我们这里是有传入调度器的,也就是前面computed源码中的
() => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
}
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
ref = toRaw(ref)
if (ref.dep) {
if (__DEV__) {
triggerEffects(ref.dep, {
target: ref,
type: TriggerOpTypes.SET,
key: 'value',
newValue: newVal
})
} else {
triggerEffects(ref.dep)
}
}
}注意这里的_dirty就是用来控制是否需要更新的,为true的时候是不会触发更新的。
稍微简单的说下这个_dirty什么时候变成false
get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
trackRefValue(self)
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
return self._value
}这里是computed的get,只要_dirty是true,那就需要跑一下getter,更新下数据。
结合更新的代码,我们可以知道,当computed依赖的数据更新,就会通知这个computed去更新,也就是这个scheduler,而这个scheduler会触发triggerRefValue就是在通知依赖于bbb的其它数据。
这个时候会把_dirty置为true,那么下次读computed的时候就会重新执行getter更新缓存的数据,这也就是为什么computed只需要读一次,其它时候都是缓存的原因。
数据如何更新渲染#
OK, 关于computed相关的代码我们就分析到这里,是不是觉得离题离的很离谱?
是的,因为我们没有结合渲染相关的内容,我们这里只有数据会更新,但是没想过渲染这块的。
实际上你这个数据更新的时候,就会通知组件自己去update了。
vue的runtime有一个任务队列调度器,看名字就知道是用来存放更新队列的。当数据更新的时候会去触发组件自身的update,然后把组件自己加入到调度器里的任务队列中。
(可能你在想我又扯远了,实际上是有联系的,别急,快到了)
简单的来看下这块相关的代码
代码位置:packages\runtime-core\src\renderer.ts
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!
if (shouldUpdateComponent(n1, n2, optimized)) {
if (
__FEATURE_SUSPENSE__ &&
instance.asyncDep &&
!instance.asyncResolved
) {
// async & still pending - just update props and slots
// since the component's reactive effect for render isn't set-up yet
if (__DEV__) {
pushWarningContext(n2)
}
updateComponentPreRender(instance, n2, optimized)
if (__DEV__) {
popWarningContext()
}
return
} else {
// normal update
instance.next = n2
// in case the child component is also queued, remove it to avoid
// double updating the same child component in the same flush.
invalidateJob(instance.update)
// instance.update is the reactive effect.
instance.update()
}
} else {
// no update needed. just copy over properties
n2.el = n1.el
instance.vnode = n2
}
}这里面的instance就是组件 ,可以看到instance.update(),即对应我前面说的:会触发组件的update()。注意这里的update不是生命周期函数,而是调度任务。
不过这不是重点,重点是我们已经知道了这里会触发组件的重新渲染,我们只需要知道什么时候触发这个updateComponent就打通了整个数据更新到组件更新(重新渲染)的流程。
在同一文件中有这么一个function: setupRenderEffect。他会在组件创建的时候执行。
我们来看下这个函数核心的代码
// create reactive effect for rendering
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update),
instance.scope // track it in component's effect scope
))
const update: SchedulerJob = (instance.update = () => effect.run())
update.id = instance.uid组件在创建的时候会创建一个ReactiveEffect的对象,没错,和computed一样,都是会创建一个ReactiveEffect对象,作用大家也都知道了,在这里,数据和渲染的隔阂被打通了。
也就是当数据更新的时候,这个effect也会被通知更新,然后触发update,这里的update就是执行effect.run也就是componentUpdateFn,而componentUpdateFn又会触发updateComponent,重新渲染。
Ok,到这里数据更新触发渲染的流程就简单的说完了。
你是不是觉得我跑大题了?
实际上是的

才不是,其实我们想要的内容就包含在这个过程中的一段代码中,只有知道整个流程,知道了上下文,我们才好去总结为什么这里开发环境会有报错,而生产环境却没有。
回归主题#
我们先来搞个测试用例
由于代码涉及到渲染,所以并不能只测试reactivity(响应式部分)这部分,还需要结合runtime-core(实现渲染部分)部分。
我们随便在一个runtime-core/__tests__文件夹下的文件中添加一个我们自己的测试用例,当然,也可以直接搞个demo项目来调试。
test('computed', async () => {
const Comp = defineComponent({
data() {
return {
foo: 1,
a: [1, 5, 4, 6, 4, 2, 3]
}
},
computed: {
bar(): number {
return this.foo + 1
},
baz: (vm): number => vm.bar + 1,
b () {
return this.a.sort((a, b) => (a - b))
}
},
render() {
return h(
'div',
{
onClick: () => {
this.a.push(6)
}
},
this.b
)
}
})
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>5</div>`)
triggerEvent(root.children[0] as TestElement, 'click')
await nextTick()
expect(serializeInner(root)).toBe(`<div>7</div>`)
})然后我们来跑一下。
关于如何执行测试用例可以去看我之前写的文章,这里不多说。

在vue3中,已经不是infinite update loop这种报错,因为代码的逻辑做了调整,不过大体上是一致的。
来看下这块的代码
const RECURSION_LIMIT = 100
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
if (!seen.has(fn)) {
seen.set(fn, 1)
} else {
const count = seen.get(fn)!
if (count > RECURSION_LIMIT) {
const instance = fn.ownerInstance
const componentName = instance && getComponentName(instance.type)
warn(
`Maximum recursive updates exceeded${
componentName ? ` in component <${componentName}>` : ``
}. ` +
`This means you have a reactive effect that is mutating its own ` +
`dependencies and thus recursively triggering itself. Possible sources ` +
`include component template, render function, updated hook or ` +
`watcher source function.`
)
return true
} else {
seen.set(fn, count + 1)
}
}
}这段代码很好懂,就是这个组件在这次update的时候自刷超过了const RECURSION_LIMIT也就是100次的时候就会被警告。
那么知道原因了,我们调用栈再往下走几步,去掉调用这个函数的地方
type CountMap = Map<SchedulerJob, number>
export function flushPreFlushCbs(
seen?: CountMap,
// if currently flushing, skip the current job itself
i = isFlushing ? flushIndex + 1 : 0
) {
if (__DEV__) {
seen = seen || new Map()
}
for (; i < queue.length; i++) {
const cb = queue[i]
if (cb && cb.pre) {
if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
continue
}
queue.splice(i, 1)
i--
cb()
}
}
}这个函数是用来执行一些生命周期的,任务调度在组价更新/渲染之后才会执行。
注意这里的seek,它是一个对象,它的key是任务,value是执行的次数。
也就是说,如果这个任务在这一次更新中,执行了超过100次,那么它就可能是在自我无限更新。
然后你也注意到了if(__DEV__ &&checkRecursiveUpdates(seen!, cb)){continue}这段代码。
这也回答了我们的问题:为什么开发阶段只有warn警告,但是代码还是能跑,而生产环境直接报错的原因。
当然,这个 flushPreFlushCbs不是导致我们小程序完全卡死的原因,而是执行渲染的那个任务调度中。
function flushJobs(seen?: CountMap) {
if (__DEV__) {
seen = seen || new Map()
}
// ...
const check = __DEV__
? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
: NOOP
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
if (__DEV__ && check(job)) {
continue
}
// ...
}
} 这个flushJobs才是执行组件更新调度任务的地方
这里也是,在dev阶段会跳过并警告,但是生产环境下就不会,所以会无限执行。
另外为什么在vue中大部分时候只有computed会有这个问题呢?因为computed作为代理层,每个依赖的数据更新它都得跟着更新,代理的内容多了,自然容易爆了。
其它#
响应式中的读和写#
这里补充点知识,什么是get和set。 (以下代码均是从vue3源码中精简得出)
比如下面的代码:
let a = {};
let readCount = 0;
let setCount = 0;
function get(target, key, receiver) {
// ...
const res = Reflect.get(target, key, receiver)
// ...
readCount += 1;
return res
}
function set(
target,
key,
value,
receiver
) {
// ...
const result = Reflect.set(target, key, value, receiver)
// don't trigger if target is something up in the prototype chain of original
// if (target === toRaw(receiver)) {
// if (!hadKey) {
// trigger(target, TriggerOpTypes.ADD, key, value)
// } else if (hasChanged(value, oldValue)) {
// trigger(target, TriggerOpTypes.SET, key, value, oldValue)
// }
// }
setCount += 1;
return result
}
function createReactiveObject(
target,
baseHandlers,
) {
// ...
const proxy = new Proxy(
target,
baseHandlers
)
return proxy
}
const proxyA = createReactiveObject(a, {
get,
set
})
proxyA.b = 123;
proxyA.c = 456;
const c = proxyA.b;
console.log(readCount, setCount);proxyA被读一次,写两次

只要解析器解析到这个proxyA.b表达式的时候,它就会被读,注意,不一定是在等号右侧。
比如下面这种
// ...
const d = proxyA.b + proxyA.c;
console.log(d);
打印的结果是它也被读了。
写就很好理解了,不多说。
不过需要特别注意对象的处理,比如下面这样:
function get(target, key, receiver) {
// ...
const res = Reflect.get(target, key, receiver)
// ...
if ((typeof res).includes('object')) {
return createReactiveObject(res, { get, set })
}
readCount += 1;
return res
}
// ...
const proxyA = createReactiveObject(a, {
get,
set
})
proxyA.b = 123;
proxyA.c = 456;
const c = proxyA.b;
proxyA.d = { e: 666 }
proxyA.d.e = 777;
console.log(proxyA.d);
console.log(proxyA)
console.log(readCount, setCount);
这个时候只有一次读,但是有四次写。
关于reactive或者ref相关的源码分析可以看我之前写的文章:坏蛋Dan:vue runtime源码分析学习——响应式原理day1: ref和reactive
总结#
我们跟着这个缺陷,走了一遍数据更新到组件更新的流程。
这个过程中表达可能有些草率,因为一开始并不是打算分析这个流程,而是简单的说下为什么报错和源代码理解。。所以和之前的文章比来说,少了很多源码以及分析。
如果觉得有用,请务必点个赞~。
最后简单的总结下这个流程:
我们的例子是:
<template>
<div @click="handleChangeA">
{{ b }}
</div>
</template>
<script setup lang="ts">
const a = [1, 2, 3, 5, 6, 0, 8, 7, 2, 4, 5];
function handleChangeA () {
a.push(11);
}
const b = computed(() => a.sort((a, b) => (a - b)))
</script>
<style scoped>
</style>然后这里触发的流程:

补充下,这里面提到的组件指的是组件实例,千万要和组件这个概念分隔开,一个父组件实例可能存在多个子组件实例。
还有一点就是为什么vue在开发阶段会有提示,但是生产环境会放行这个问题的思考,我个人认为这里面想表达的意思是:
我(vue)在开发的时候已经给你警告了,如果你无视,那么我(vue)生产环境自然就认为你觉得这是正常的,那么我也没理由给你改。
编辑于 2023-12-21 21:58・IP 属地广东
