Vue3源码
起源
本次我看 Vue3 源码的初衷是因为,在目前 Vue3 的设计中,给子组件绑定原生事件的时候,比如 onClick,在父组件 re-render 的时候,子组件上的 onClick 也会重新生成,这就会导致子组件的 re-render。特别是在使用 v-for 的时候,那这个子组件更新的频率就很高了,如果要优化的话有点像 react 的 useCallback 了。虽然 Vue3 也提供了 v-memo 让我们手动去设置依赖(使用 memo 的时候甚至会略过 VNode 的生成),但是如果这原本就可以从底层去实现的话,岂不是更好。所以我就开始基于这个问题去构思一下是从可以给 Vue3 提个 pr。
不过既然准备开始看 Vue3 源码,那也还是记录一下,常看常新。
入口
runtime-dom/index.ts 暴露出 createApp Api,其中需要通过 createRenderer 生成 baseRenderer,其中 baseRenderer 中包含了 patch 等各种底层 api。而 baseRenderer 导出项为:
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate),
};
导出的 createApp 函数中,会返回 app 对象,其中有熟知的 mount,unmount,component,mixin,directive,use 等 api。
在执行 mount 的时候,会基于 rootComponent,通过 createVNode 来生成 rootVNode,这里的 node 是包含第一层的,并不会直接遍历子节点。
if (isHydrate && hydrate) {
// 主要用于ssr,用来复用原本已经存在的DOM 节点,减少重新生成节点以及删除原本DOM 节点的开销,来加速初次渲染的功能,应该是这样
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
// 我们客户端使用的时候就会调用这个
// 在这里就会变遍历子节点同时render
render(vnode, rootContainer, isSVG)
}
而这里的 render 函数,就是 baseRenderer 中导出的 render。
const render: RootRenderFunction = (vnode, container, isSVG) => {
if (vnode == null) {
if (container._vnode) {
unmount(container._vnode, null, null, true);
}
} else {
// 因为首次render的时候是没有_vnode的,所以会直接去生成节点
patch(container._vnode || null, vnode, container, null, null, null, isSVG);
}
flushPreFlushCbs();
flushPostFlushCbs();
container._vnode = vnode;
};
既然我们主要去看组件的 patch,那就直接看 processComponent 的实现。
// renderer.ts
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
n2.slotScopeIds = slotScopeIds
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
isSVG,
optimized
)
} else {
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
} else {
updateComponent(n1, n2, optimized)
}
}
// 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
}
}
// componentRenderUtils.ts
export function shouldUpdateComponent(
prevVNode: VNode,
nextVNode: VNode,
optimized?: boolean
): boolean {
const { props: prevProps, children: prevChildren, component } = prevVNode
const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
const emits = component!.emitsOptions
// Parent component's render function was hot-updated. Since this may have
// caused the child component's slots content to have changed, we need to
// force the child to update as well.
if (__DEV__ && (prevChildren || nextChildren) && isHmrUpdating) {
return true
}
// force child update for runtime directive or transition on component vnode.
if (nextVNode.dirs || nextVNode.transition) {
return true
}
if (optimized && patchFlag >= 0) {
if (patchFlag & PatchFlags.DYNAMIC_SLOTS) {
// slot content that references values that might have changed,
// e.g. in a v-for
return true
}
if (patchFlag & PatchFlags.FULL_PROPS) {
if (!prevProps) {
return !!nextProps
}
// presence of this flag indicates props are always non-null
return hasPropsChanged(prevProps, nextProps!, emits)
} else if (patchFlag & PatchFlags.PROPS) {
const dynamicProps = nextVNode.dynamicProps!
for (let i = 0; i < dynamicProps.length; i++) {
const key = dynamicProps[i]
if (
nextProps![key] !== prevProps![key] &&
!isEmitListener(emits, key)
) {
return true
}
}
}
} else {
...
}
return false
}
这里可以看到,首先我们更新的时候 patchFlag 并不是 DYNAMIC_SLOTS 也不是 FULL_PROPS,其实就是 dynamicProps 上我们绑定的原生事件前后 2 个 VNode 对比不一样了,所以就 update 了。那是不是我们只要把我们自己绑定的原生事件从 dynamicProps 上去掉就可以了呢?如果不是原生事件就依旧保留在 dynamicProps。
那我们就要去查看生成 VNode 函数的地方了,结果发现 dynamicProps 是一个参数,那我们还是根据第一次组件挂载的顺序去看代码吧。
function _createVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag: number = 0,
dynamicProps: string[] | null = null,
isBlockNode = false
): VNode {
...
}
第一次挂载的时候,将根组件 processComponent,然后会执行 mountComponent,接着在 mountComponent 中执行 setupComponent(instance)来进一步完善组件实例。
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false
) {
isInSSRComponentSetup = isSSR;
const { props, children } = instance.vnode;
const isStateful = isStatefulComponent(instance);
initProps(instance, props, isStateful, isSSR);
initSlots(instance, children);
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined;
isInSSRComponentSetup = false;
return setupResult;
}
这里会去初始化 props 和 slots,然后会根据 setup 是否返回了值来决定是否要执行 setupStatefulComponent,在 setupStatefulComponent,中还会执行 finishComponentSetup,会获取这个 component 的 render 函数。
执行完 setupComponent 后就会去执行 setupRenderEffect。在 setupRenderEffect 中,因为已经拿到了组件的 render 函数,然后就会根据 render 函数去执行 renderComponentRoot 获得 subTree,其中会执行 instance.render,而这里的 render 函数其实就是 Component.render(在 finishComponentSetup 中获取到的)。
其中 Component.render 是基于 Vue 的 compileToFunction 来获得的。
// vue/src/index.ts
function compileToFunction(
template: string | HTMLElement,
options?: CompilerOptions
): RenderFunction {
if (!isString(template)) {
if (template.nodeType) {
template = template.innerHTML
} else {
__DEV__ && warn(`invalid template option: `, template)
return NOOP
}
}
const key = template
const cached = compileCache[key]
if (cached) {
return cached
}
if (template[0] === '#') {
const el = document.querySelector(template)
if (__DEV__ && !el) {
warn(`Template element not found or is empty: ${template}`)
}
// __UNSAFE__
// Reason: potential execution of JS expressions in in-DOM template.
// The user must make sure the in-DOM template is trusted. If it's rendered
// by the server, the template should not contain any user data.
template = el ? el.innerHTML : ``
}
const opts = extend(
{
hoistStatic: true,
onError: __DEV__ ? onError : undefined,
onWarn: __DEV__ ? e => onError(e, true) : NOOP
} as CompilerOptions,
options
)
if (!opts.isCustomElement && typeof customElements !== 'undefined') {
opts.isCustomElement = tag => !!customElements.get(tag)
}
const { code } = compile(template, opts)
function onError(err: CompilerError, asWarning = false) {
const message = asWarning
? err.message
: `Template compilation error: ${err.message}`
const codeFrame =
err.loc &&
generateCodeFrame(
template as string,
err.loc.start.offset,
err.loc.end.offset
)
warn(codeFrame ? `${message}\n${codeFrame}` : message)
}
// The wildcard import results in a huge object with every export
// with keys that cannot be mangled, and can be quite heavy size-wise.
// In the global build we know `Vue` is available globally so we can avoid
// the wildcard object.
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
// mark the function as runtime compiled
;(render as InternalRenderFunction)._rc = true
return (compileCache[key] = render)
}
这里的 render 函数会先通过 compile 来获取编译完的代码块,其中包含了静态提升等一些编译优化。
//template
<div id="demo">
<item v-for="(i, index) in arr" :key="index" :is-spread="i.age" @click="ageUp( index)" ></item>
<h2 @click="aUp"></h2>
</div>
const arr = ref([
{age:1},
{age:10},
{age:30},
{age:50},
])
const a = ref(1)
const ageUp = (index) => {arr.value[index].age += 1}
const aUp = () => a.value++
// 编译之后的code
const _Vue = Vue
const { createElementVNode: _createElementVNode } = _Vue
const _hoisted_1 = ["onClick"]
return function render(_ctx, _cache) {
with (_ctx) {
const { renderList: _renderList, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock, resolveComponent: _resolveComponent, createBlock: _createBlock, toDisplayString: _toDisplayString, createElementVNode: _createElementVNode } = _Vue
const _component_item = _resolveComponent("item")
return (_openBlock(), _createElementBlock(_Fragment, null, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(arr, (i, index) => {
return (_openBlock(), _createBlock(_component_item, {
key: index,
"is-spread": i.age,
onClick: $event => (ageUp( index))
}, null, 8 /* PROPS */, ["is-spread", "onClick"]))
}), 128 /* KEYED_FRAGMENT */)),
_createElementVNode("h2", { onClick: aUp }, _toDisplayString(a), 9 /* TEXT, PROPS */, _hoisted_1)
], 64 /* STABLE_FRAGMENT */))
}
}
在我拿到的 code 中可以看到,首先 h2 标签的 onClick 是被静态提升了,但是组件 item 的 onClick 并没有,那我就要去分析这是为什么了。
compile -> baseCompile -> tramsform -> hoistStatic -> walk 发现只有单纯的元素节点或者文本才有机会去执行静态提升,所以这里的 h2 的 onClick 会被静态提升,但是组件 item 的就不会。
if (
child.type === NodeTypes.ELEMENT &&
child.tagType === ElementTypes.ELEMENT
){
...
if (codegenNode.dynamicProps) {
codegenNode.dynamicProps = context.hoist(codegenNode.dynamicProps)
}
}
还是回到之前的 dynamicProps。 compile -> transform -> traverseNode -> nodeTransforms(transformElement) -> buildProps。 在 buildProps 之后,我们就可以拿到组件上的 dynamicProps,接着再通过静态标记,来把所需要静态提升的变量给提取出来。
最终回到原来的问题,如果我们想自动忽略由于父组件更新导致子组件 onClick 事件更新并重新渲染的问题,最好还是通过静态提升来实现。因为 dynamicProps 属性的获取属于一个普遍的方法,在这里去处理适用性很差,而且也会对别的元素节点产生影响,耦合比较严重。在静态提升那部分代码中,感觉可以判断单独判断一下组件的 for 循环,将部分原生事件的直接静态提升,不如这个改动的破坏性应该比较大,还需要好好思考怎么优化。