# 29|运行时:Vue在浏览器里是怎么跑起来的? 你好,我是大圣。 上一讲我们学习了Vue响应式的大致原理,响应式就是可以把普通的JavaScript对象包裹成响应式对象,这样,我们对对象做的修改,响应式都能够监听到,并且执行effect内部注册的函数来执行数据修改之后的效果。 那今天我就跟你聊一下Vue在浏览器里是如何运行的,照例我们还是对着Vue 3的源码来学习,不过源码复杂,为了帮助你理解主要逻辑,我会直接把源码简化再演示,当然怎么简化源码的一些小技巧也会顺便分享给你。 好了废话不多说,我们马上开始。前端框架需要处理的最核心的两个流程,就是首次渲染和数据更新后的渲染。先来看首次渲染的源码。演示代码会用Vue 3的实际代码,你也可以在 [weiyouyi](https://github.com/shengxinjing/weiyouyi/blob/main/src/runtime-core/apiCreateApp.js#L4) 项目中看到我们课程的mini版本代码。 ## 首次渲染 我们知道,想要启动一个Vue项目,只需要从Vue中引入createApp,传入App组件,并且调用createApp返回的App实例的mount方法,就实现了项目的启动。这个时候Vue也完成了首次渲染,代码逻辑如下: ![](https://static001.geekbang.org/resource/image/39/7c/3974d85351462f5190363869a39b1f7c.png?wh=1622x786) 所以createApp就是项目的初始化渲染入口。 但是这段简单的代码是怎么完成初始化渲染的呢?我们可以在Vue中的runtime-dom中看到createApp的定义,你可以打开 [GitHub链接](https://github.com/vuejs/vue-next/blob/master/packages/runtime-dom/src/index.ts#L66)查看。 这里就有一个看代码的小技巧,分享给你,我们首次查看源码的时候,可以先把一些无用的信息删除,方便自己梳理主体的逻辑。看Vue代码,和今天主题无关的无用信息有哪些,\_\_COMPAT\_\_代码是用来兼容Vue 2的,\_\_DEV\_\_代码是用来调试的,我们可以把这些代码删除之后,得到下面的简化版createApp源码。 再看思路就比较清晰了。我们使用ensureRenderer返回的对象去创建app,并且重写了app.mount方法;在mount方法内部,我们查找mount传递的DOM元素,并且调用ensureRenderer返回的mount方法,进行初始化渲染。如下图所示: ![](https://static001.geekbang.org/resource/image/70/82/7073e9c5b18e105a499e30208bd0c582.jpg?wh=2440x896) 之前我们讲过要会TypeScript,这时你就能感受到TypeScript的好处了,现在即使我们不知道app.mount是什么逻辑,也能知道这个函数的参数只能是Element、ShadowRoot或者string三者之一,也就很好理解内部的normalizeContainer就是把你传递的参数统一变为浏览器的DOM元素,Typescript类型带来的好处,我们在读源码的时候会一直感受得到。 ```javascript export const createApp = ((...args) => { const app = ensureRenderer().createApp(...args) const { mount } = app // 重写mount app.mount = (containerOrSelector: Element | ShadowRoot | string): any => { const container = normalizeContainer(containerOrSelector) if (!container) return const component = app._component if (!isFunction(component) && !component.render && !component.template) { component.template = container.innerHTML } container.innerHTML = '' const proxy = mount(container, false, container instanceof SVGElement) if (container instanceof Element) { container.removeAttribute('v-cloak') container.setAttribute('data-v-app', '') } return proxy } return app }) function normalizeContainer(container){ if (isString(container)) { const res = document.querySelector(container) } return container } ``` 我们继续深入了解ensureRenderer方法,以及ensureRenderer方法返回的createApp方法。 **这里ensureRenderer函数,内部通过createRenderer函数,创建了一个浏览器的渲染器,并且缓存了渲染器renderer**,这种使用闭包做缓存的方式,你在日常开发中也可以借鉴这种思路。 createRenderer函数,我们在自定义渲染器那一讲里学到过,传递的rendererOptions就是浏览器里面标签的增删改查API: ```javascript // 浏览器dom操作 import { nodeOps } from './nodeOps' // 浏览器dom属性更新 import { patchProp } from './patchProp' import { createRenderer } from '@vue/runtime-core' const rendererOptions = extend({ patchProp }, nodeOps) let renderer: Renderer | HydrationRenderer function ensureRenderer() { return ( renderer || (renderer = createRenderer(rendererOptions)) ) } ``` 可以看到,createRenderer函数传递的参数是nodeOps和patchProp的合并对象。 我们继续进入nodeOps和pathProp也可以看到下面的代码,写了很多方法。通过ensureRenderer存储这些操作方法后,createApp内部就可以脱离具体的渲染平台了,这也是Vue 3实现跨端的核心逻辑: ```javascript export const nodeOps: Omit, 'patchProp'> = { insert: (child, parent, anchor) => { parent.insertBefore(child, anchor || null) }, remove: child => { const parent = child.parentNode if (parent) { parent.removeChild(child) } }, createElement: (tag, isSVG, is, props): Element => { const el = isSVG ? doc.createElementNS(svgNS, tag) : doc.createElement(tag, is ? { is } : undefined) if (tag === 'select' && props && props.multiple != null) { ;(el as HTMLSelectElement).setAttribute('multiple', props.multiple) } return el }, createText: text => doc.createTextNode(text), createComment: text => doc.createComment(text), setText: (node, text) => { node.nodeValue = text }, setElementText: (el, text) => { el.textContent = text }, parentNode: node => node.parentNode as Element | null, nextSibling: node => node.nextSibling, querySelector: selector => doc.querySelector(selector), ... } ``` 然后我们就需要进入到rumtime-core模块去看下createRenderer是如何工作的。你可以在这个[GitHub链接](https://github.com/vuejs/vue-next/blob/master/packages/runtime-core/src/renderer.ts#L290)内看到createRenderer的代码逻辑。当然源码比较复杂,我们照样需要简化一下。 createRenderer是调用baseCreateRenderer创建的,baseCreateRenderer函数内部有十几个函数,代码行数合计2000行左右,这也是我们学习Vue源码最复杂的一个函数了。按前面简化源码的思路,先把工具函数的实现折叠起来,精简之后代码主要逻辑其实很简单。 我们一起来看。 首先获取了平台上所有的insert、remove函数,这些函数都是nodeOps传递进来的,然后定义了一些列patch、mount、unmount函数,通过名字我们不难猜出,这就是Vue中更新、渲染组件的工具函数,比如mountElement就是渲染DOM元素、mountComponent就是渲染组件updateComponent就是更新组件。这部分的简化代码,你也可以在[weiyouyi](https://github.com/shengxinjing/weiyouyi/blob/main/src/runtime-core/renderer.js)项目中查看。 ```javascript export function createRenderer< HostNode = RendererNode, HostElement = RendererElement >(options: RendererOptions) { return baseCreateRenderer(options) } function baseCreateRenderer(){ const { insert: hostInsert, remove: hostRemove, patchProp: hostPatchProp, createElement: hostCreateElement, createText: hostCreateText, createComment: hostCreateComment, setText: hostSetText, setElementText: hostSetElementText, parentNode: hostParentNode, nextSibling: hostNextSibling, setScopeId: hostSetScopeId = NOOP, cloneNode: hostCloneNode, insertStaticContent: hostInsertStaticContent } = options const patch = ()=>... //一个函数 const processText = ()=>... const processCommentNode = ()=>... const processElement = ()=>... const mountElement = ()=>... const mountChildren = ()=>... const patchElement = ()=>... const patchBlockChildren = ()=>... const patchProps = ()=>... const processComponent = ()=>... const mountComponent = ()=>... const updateComponent = ()=>... const setupRenderEffect = ()=>... const patchChildren = ()=>... const patchKeyedChildren = ()=>... const unmount = ()=>... const unmountComponent = ()=>... const unmountComponent = ()=>... const unmountComponent = ()=>... const unmountComponent = ()=>... const render: RootRenderFunction = (vnode, container, isSVG) => { if (vnode == null) { if (container._vnode) { unmount(container._vnode, null, null, true) } } else { patch(container._vnode || null, vnode, container, null, null, null, isSVG) } flushPostFlushCbs() container._vnode = vnode } return { render, hydrate, createApp: createAppAPI(render, hydrate) } } ``` 整个createApp函数的执行逻辑如下图所示: ![](https://static001.geekbang.org/resource/image/cf/7b/cfcbf6cd3f3195518f9e0e407338a37b.jpg?wh=2526x2208) 最后返回的createApp方法,实际上是createAPI的返回值,并且给createAPI传递了render方法。render方法内部很简单,就是判断container容器上有没有\_vnode属性,如果有的话就执行unmout方法,没有的话就执行patch方法,最后把vnode信息存储在container.\_vnode上。 那createAppAPI又做了什么呢?我们继续进入createAppAPI源码,看下面的代码。内部创建了一个app对象,app上注册了我们熟悉的use、component和mount等方法: ```javascript export function createAppAPI( render: RootRenderFunction, hydrate?: RootHydrateFunction ): CreateAppFunction { return function createApp(rootComponent, rootProps = null) { const context = createAppContext() let isMounted = false const app: App = (context.app = { _context: context, _instance: null, use(plugin: Plugin, ...options: any[]) , component(name: string, component?: Component): any { if (!component) { return context.components[name] } context.components[name] = component return app }, directive(name: string, directive?: Directive) mount( rootContainer: HostElement, isHydrate?: boolean, isSVG?: boolean ): any { if (!isMounted) { const vnode = createVNode( rootComponent as ConcreteComponent, rootProps ) vnode.appContext = context // 核心的逻辑 if (isHydrate && hydrate) { hydrate(vnode as VNode, rootContainer as any) } else { render(vnode, rootContainer, isSVG) } return getExposeProxy(vnode.component!) || vnode.component!.proxy } }, provide(key, value) { context.provides[key as string] = value return app } }) return app } } ``` 可以看到mount内部执行的是传递进来的render方法,也就是上面的render方法。container 就是我们app.mount中传递的DOM元素,对DOM元素进行处理之后,执行patch函数实现整个应用的加载。 所以我们的下一个任务就是需要搞清楚patch函数的执行逻辑。 ### patch 函数 patch传递的是container.\_vnode,也就是上一次渲染缓存的vnode、本次渲染组件的vnode,以及容器container。 下面就是patch函数的代码,核心代码我添加了注释。其中n1是上次渲染的虚拟DOM,n2是下次要渲染的虚拟DOM。 首先可以把n1和n2做一次判断,如果虚拟DOM的节点类型不同,就直接unmount之前的节点。因为比如之前是Button组件,现在要渲染Container组件,就没有计算diff的必要,直接把Button组件销毁再渲染Container即可。 如果n1和n2类型相同,比如都是Button组件或者都是div标签,我们需要判断具体的类型再去执行不同的函数,比如processText、processFragment、processElement以及processComponent等函数。 看第55行,这里的ShapeFlags用到了位运算的知识,我们后面会通过刷算法题的方式介绍,暂时我们只需要知道,ShapeFlags可以帮助我们快速判断需要操作的类型就可以了。 ```javascript const patch: PatchFn = ( n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren ) => { // 两次虚拟dom完全一样 啥也不用干 if (n1 === n2) { return } // 虚拟dom节点类型不一样, unmount老的虚拟dom,并且n1赋值null if (n1 && !isSameVNodeType(n1, n2)) { anchor = getNextHostNode(n1) unmount(n1, parentComponent, parentSuspense, true) n1 = null } // n2是要渲染的虚拟dom,我们获取type,ref和shapeFlag const { type, ref, shapeFlag } = n2 switch (type) { case Text: // 文本 processText(n1, n2, container, anchor) break case Comment: // 注释 processCommentNode(n1, n2, container, anchor) break case Static: // 静态节点 if (n1 == null) { mountStaticNode(n2, container, anchor, isSVG) } else if (__DEV__) { patchStaticNode(n1, n2, container, isSVG) } break case Fragment: processFragment( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) break default: // 运运算判断操作类型 if (shapeFlag & ShapeFlags.ELEMENT) { // html标签 processElement( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件 processComponent( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } else if (shapeFlag & ShapeFlags.TELEPORT) { ;(type as typeof TeleportImpl).process( n1 as TeleportVNode, n2 as TeleportVNode, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, internals ) } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { ;(type as typeof SuspenseImpl).process( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, internals ) } else if (__DEV__) { warn('Invalid VNode type:', type, `(${typeof type})`) } } // set ref if (ref != null && parentComponent) { setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2) } } ``` 代码的整体执行逻辑如下图所示:![](https://static001.geekbang.org/resource/image/c5/a8/c5c55f140c4573b698265c99bc9cf8a8.jpg?wh=1699x778) 我们首次渲染的App是一个组件,所以要执行的就是processComponent方法。 ### processComponent方法 那我们继续进入到processComponent代码内部,看下面的代码。首次渲染的时候,n1就是null,所以会执行mountComponent;如果是更新组件的时候,n1就是上次渲染的vdom,需要执行updateComponent。 ```javascript 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) } } ``` updateComponent是虚拟DOM的逻辑,我们会在下一讲详细剖析,这一讲主要讲首次渲染的过程。 所以我们进入mountComponent函数中,可以看到mountComponent函数内部会对组件的类型进行一系列的判断,还有一些对Vue 2的兼容代码,核心的渲染逻辑就是setupComponent函数和setupRenderEffect函数。 ```javascript import {setupComponent} from './component' const mountComponent: MountComponentFn = ( ) => { // 2.x compat may pre-creaate the component instance before actually // mounting const compatMountInstance = __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component const instance: ComponentInternalInstance = compatMountInstance || (initialVNode.component = createComponentInstance( initialVNode, parentComponent, parentSuspense )) // resolve props and slots for setup context if (!(__COMPAT__ && compatMountInstance)) { setupComponent(instance) } ( instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized ) if (__DEV__) { popWarningContext() endMeasure(instance, `mount`) } } ``` setupComponent和setupRenderEffect,它俩又做了点什么呢?可以参考下面的示意图这两个实现组件首次渲染的函数: ![](https://static001.geekbang.org/resource/image/d4/51/d4b431396eb7ef90e9ab0e1021f46051.jpg?wh=3213x1529) ### setupComponent 首先看setupComponent,要完成的就是执行我们写的setup函数。 可以看到,内部先初始化了props和slots,并且执行setupStatefulComponent创建组件,而这个函数内部从component中获取setup属性,也就是script setup内部实现的函数,就进入到我们组件内部的reactive、ref等函数实现的逻辑了。 ```javascript 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 } function setupStatefulComponent( instance: ComponentInternalInstance, isSSR: boolean ) { const Component = instance.type as ComponentOptions // 执行setup const { setup } = Component if (setup) { const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null) setCurrentInstance(instance) pauseTracking() const setupResult = callWithErrorHandling( setup, instance, ErrorCodes.SETUP_FUNCTION, [instance.props, setupContext] ) if (isPromise(setupResult)) { setupResult.then(unsetCurrentInstance, unsetCurrentInstance) } else { handleSetupResult(instance, setupResult, isSSR) } } else { finishComponentSetup(instance, isSSR) } } export function callWithErrorHandling( fn: Function, instance: ComponentInternalInstance | null, type: ErrorTypes, args?: unknown[] ) { let res try { res = args ? fn(...args) : fn() } catch (err) { handleError(err, instance, type) } return res } ``` ### setupRenderEffect 另一个setupRenderEffect函数,就是为了后续数据修改注册的函数,我们先梳理一下核心的实现逻辑。 组件首次加载会调用patch函数去初始化子组件,注意setupRenderEffect本身就是在patch函数内部执行的,所以这里就会递归整个虚拟DOM树,然后触发生命周期mounted,完成这个组件的初始化。 页面首次更新结束后,setupRenderEffect不仅实现了组件的递归渲染,还注册了组件的更新机制。 在下面的核心代码中,我们通过ReactiveEffect创建了effect函数,这个概念上一讲我们手写过,然后执行instance.update赋值为effect.run方法,这样结合setup内部的ref和reactive绑定的数据,数据修改之后,就会触发update方法的执行,内部就会componentUpdateFn,内部进行递归的patch调用执行每个组件内部的update方法实现组件的更新。 ```javascript if (!instance.isMounted) { patch( null, subTree, container, anchor, instance, parentSuspense, isSVG ) }else{ // updateComponent } // create reactive effect for rendering const effect = new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update), instance.scope // track it in component's effect scope ) const update = (instance.update = effect.run.bind(effect) as SchedulerJob) update.id = instance.uid update() ``` 这样我们就实现了整个Vue的渲染和更新流程。 ## 总结 今天要学的内容就聊完了,我们来总结一下学到的内容吧,今天我们分析了Vue 3在浏览器中执行的全流程,你可以配合Vue在浏览器中执行的流程图来复习。 ![](https://static001.geekbang.org/resource/image/5f/f7/5f2527dd6eb75120bc3644cdfa5636f7.jpg?wh=6962x3378) Vue通过createApp创建应用,并且执行返回的mount方法实现在浏览器中的挂载,在createApp中,通过传递浏览器平台的操作方法nodeOps创建了浏览器的渲染器renderer。 首次执行Vue项目的时候,通过patch实现组件的渲染,patch函数内部根据节点的不同类型,去分别执行processElement、processComponent、processText等方法去递归处理不同类型的节点,最终通过setupComponent执行组件的setup函数,setupRenderEffect中使用响应式的effect函数监听数据的变化。 你可以先看我们实现的迷你版本项目weiyouyi,然后再去看Vue 3中实际的代码,可以学习代码中很多优秀的设计思路,比如createRenderer中使用闭包作为缓存、使用位运算来提高组件类型的判断效率等。学习优秀框架中的代码设计,这对我们日常开发项目的代码质量也有很好的提高作用。 ## 思考题 最后留一个思考题,mount函数中除了render函数,还有一个hydrate的函数调用,这个函数式干什么用的呢?欢迎在评论区分享你的答案,我们下一讲再见。