Vue源码解析系列-渲染器

虚拟DOM

在正式分析vue渲染器之前需要先简单了解一下虚拟DOM。虚拟DOM是一种思想,它本质是利用js对象去描述真实dom节点,这与AST(抽象语法树)有些相似。一个虚拟节点(简称vnode)包含了描述真实dom节点的一切信息,包括元素标签名、属性等,通过vnode可以创建一个真实的dom节点,而多个vnode可以构建与真实dom树相对应的vnode树,也就是说可以通过vnode树来描述真实的dom树(视图)。

渲染器

前面我们已经讲过Vue的模板编译,Vue先将模板进行解析(parse)得到模板AST,然后进行转换(transform)得到JavaScript AST(codegenNode),最后生成(generate)渲染函数。这些步骤是编译时完成的,最终的结果是一个渲染函数的静态代码,最终要转换为视图还需要Vue渲染器的帮助。

createApp

一个Vue项目的入口文件是main.js文件,在这个文件中会调用createApp来创建一个app对象,并且这个时候还会调用mount 函数来挂载组件。
它看起来是这样:
import APP from "./src/App.vue"; import { createApp } from "@vue/runtime-dom"; createApp(APP).mount("#app");
vue单页面组件经过编译后最终得到的是一个组件对象。例如
<script setup> import { ref } from 'vue' import Child from "./Child.vue" const msg = ref('Hello World!') </script> <template> <h1>{{ msg }}</h1> <Child /> </template>
编译后生成
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" import { ref } from 'vue' import Child from "./Child.vue" const __sfc__ = { setup(__props) { const msg = ref('Hello World!') return (_ctx, _cache) => { return (_openBlock(), _createElementBlock(_Fragment, null, [ _createElementVNode("h1", null, _toDisplayString(msg.value), 1 /* TEXT */), _createVNode(Child) ], 64 /* STABLE_FRAGMENT */)) } } } __sfc__.__file = "App.vue" export default __sfc__
createApp(APP) 实际上是将根组件对象传递到createAPP函数中调用。

Renderer

createApp函数的定义位于runtime-coreapiCreateApp.ts文件下的createRenderer 函数中,createRenderer 函数的作用是创建renderer。createRenderer 函数的内部声明了很多函数,但是我们目前只关注它的返回值。
export function createRenderer(){ // ... return { render, hydrate, createApp: createAppAPI(render, hydrate) } }
createRenderer函数返回了包含三个方法的对象:
  1. render::将vnode转换成视图的函数。
  1. hydrate:用于SSR,略。
  1. createApp:就是我们前面提到的那个函数。
这里可以清晰地看出createApp函数通过createAppAPI函数创建,我们重点来看看createAppAPI函数。
createAppAPI的核心逻辑代码在runtime-core下的apiCreateApp.ts文件中。
export function createAppAPI<HostElement>( render: RootRenderFunction, hydrate?: RootHydrateFunction ): CreateAppFunction<HostElement> { return function createApp(rootComponent, rootProps = null) { if (rootProps != null && !isObject(rootProps)) { __DEV__ && warn(`root props passed to app.mount() must be an object.`) rootProps = null } const context = createAppContext() const installedPlugins = new Set() let isMounted = false const app: App = (context.app = { _uid: uid++, _component: rootComponent as ConcreteComponent, _props: rootProps, _container: null, _context: context, _instance: null, version, // ... use(plugin: Plugin, ...options: any[]) { // ... }, mixin(mixin: ComponentOptions) { // ... }, component(name: string, component?: Component): any { // ... }, directive(name: string, directive?: Directive) { // ... }, mount( rootContainer: HostElement, isHydrate?: boolean, isSVG?: boolean ): any { if (!isMounted) { const vnode = createVNode( rootComponent as ConcreteComponent, rootProps ) // store app context on the root VNode. // this will be set on the root instance on initial mount. vnode.appContext = context // HMR root reload if (__DEV__) { context.reload = () => { render(cloneVNode(vnode), rootContainer, isSVG) } } if (isHydrate && hydrate) { hydrate(vnode as VNode<Node, Element>, rootContainer as any) } else { render(vnode, rootContainer, isSVG) } isMounted = true app._container = rootContainer // for devtools and telemetry ;(rootContainer as any).__vue_app__ = app if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { app._instance = vnode.component devtoolsInitApp(app, version) } return vnode.component!.proxy } else if (__DEV__) { warn( `App has already been mounted.\n` + `If you want to remount the same app, move your app creation logic ` + `into a factory function and create fresh app instances for each ` + `mount - e.g. \`const createMyApp = () => createApp(App)\`` ) } }, unmount() { if (isMounted) { render(null, app._container) if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { app._instance = null devtoolsUnmountApp(app) } delete app._container.__vue_app__ } else if (__DEV__) { warn(`Cannot unmount an app that is not mounted.`) } }, provide(key, value) { // ... } }) if (__COMPAT__) { installAppCompatProperties(app, context, render) } return app } }
这里有很多我们熟知的usecomponent等方法,但是现在我们主要关注mount方法,因为通过它组件才能被转换成我们看到的视图。
mount函数主要做了两件事(本文不考虑SSR):
  • 将根组件对象转换成vnode,然后再将vnode传入render函数执行。
  • 如果在开发环境下,则声明context.reload方法,克隆vnode并重新调用render函数。
可以发现,vue将渲染函数最终转换成视图的秘诀就在于render函数中。并且createAppAPI函数所调用的render函数就是createRenderer函数中的render函数。让我们来看看render函数声明:
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 }
render函数接收三个参数,其中最重要的是前两个:
  1. vnode:虚拟节点。
  1. container:绑定视图的真实DOM节点。
参数中的vnode被作为一个新的vnode,当render执行完毕前会将其挂载在container._vnode ,目的是方便新旧vnode进行比较。
  • 如果旧vnode存在但是新vnode不存在,说明要删除该节点。
  • 如果新旧节点都不存在,那么什么也不需要做。
  • 如果新节点存在但是旧节点不存在,那么说明需要添加节点。
  • 如果新旧节点都存在,那么说明需要patch(打补丁)。
从代码中可以看出,新增节点和新旧vnode都存在的这两种情况下,vue都采用了patch操作。
那么说了这么多,patch到底是什么呢?

Patch

什么是Patch

Patch是Vue优化性能的一种方法,它是在虚拟DOM的基础上实现的。Patch提升性能的思想是利用js的算力来降低DOM更新的性能代价。
试想下一个vue组件被渲染为真实的dom节点后,当组件更新时要如何更新视图(真实DOM)?最简单直接的方法是先将dom节点全删除,然后再重新渲染新的dom节点。但是这样的方法非常浪费性能,因为操作DOM的性能消耗是非常大的,尤其是当操作大量dom节点时。
vue采用了另一种思路,先用虚拟DOM来描述真实的DOM,旧vnode树与当前真实DOM对应,新vnode树表示更新后的真实DOM,然后当需要更新时比较新旧两个vnode树,用js计算出两者的不同之处,然后只针对不同之处进行相应的修改,相同的地方就不用去改,这样就达到了“尽量少地操作dom节点”的目的。这一操作就像打补丁一样,vue将其称为Patch。
以下面的代码为例:
<a href="{a}" >Link</a> const a = ref('https:xxx.com') // 更改a a.value = 'https:yyy.com'
标签a更新前后只有属性href改变,因此只要调用element.setAttribute更改href属性即可,不需要删除a节点再重新渲染。

patch函数

patch函数代码如下,其中n1,n2分别是旧vnode和新vnode。
const patch: PatchFn = ( n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren ) => { if (n1 === n2) { return } // patching & not same type, unmount old tree if (n1 && !isSameVNodeType(n1, n2)) { anchor = getNextHostNode(n1) unmount(n1, parentComponent, parentSuspense, true) n1 = null } if (n2.patchFlag === PatchFlags.BAIL) { optimized = false n2.dynamicChildren = null } 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) { 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) } }
patch函数并不复杂,首先检查旧vnode是否存在且新旧vnode类型是否相同,如果旧vnode存在且新旧vnode类型不相同,则会先卸载旧vnode后,再去渲染新vnode。
vue会通过新vnode的类型来调用不同函数去进行patch(打补丁),在vue template中其实常用的node类型也就只有文本节点、元素节点和注释节点,但是vue额外地定了Fragment、Static等类型,因此vue vnode总共有这几种类型:
  • Component
  • Fragment
  • Static
  • Text
  • Element
  • Comment
  • TeleportImpl
  • SuspenseImpl
TeleportImpl和SuspenseImpl类型主要是为内置组件准备的,Static类型是指静态节点,也就是不包含任何响应式状态的节点,这种节点不需要随着随着组件状态更新而更新,因此认为它们是“静态的”,Fragment则表示一个片段,例如包含多个根节点的模板。

patchElement

patchText和patchComment相对而已比较简单,更改的地方比较少,patchElement相对而言复杂很多,因为Element可能会包含子节点,这就会涉及到子节点的比较。
patchElement函数声明代码如下(省略了部分代码):
const patchElement = ( n1: VNode, n2: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean, slotScopeIds: string[] | null, optimized: boolean ) => { // ... if (__DEV__ && isHmrUpdating) { // HMR updated, force full diff patchFlag = 0 optimized = false dynamicChildren = null } if (dynamicChildren) { patchBlockChildren( n1.dynamicChildren!, dynamicChildren, el, parentComponent, parentSuspense, areChildrenSVG, slotScopeIds ) if (__DEV__ && parentComponent && parentComponent.type.__hmrId) { traverseStaticChildren(n1, n2) } } else if (!optimized) { // full diff patchChildren( n1, n2, el, null, parentComponent, parentSuspense, areChildrenSVG, slotScopeIds, false ) }
dynamicChildrenchildren 属性的作用都是用于存储元素子节点,但是区别在于dynamicChildren 只会保存动态节点,这其实是一个性能优化的点,因为在更新时静态内容是不需要改变的,需要重新渲染的只有动态节点。一个包含dynamicChildren 属性的vnode被称为Block。
在patchElement过程时,如果存在dynamicChildren 属性会直接更新dynamicChildren 属性中的子节点,只有当不存在dynamicChildren 属性,且不需要进行优化时(例如热更新)才会进行全比较(full diff)。
我们重点看看patchChildren的情况,全比较时元素children时有这几种情况:
  • children是Text
  • children是数组
  • children为空,即没有子节点
当children是数组时,也分这三种情况情况:
  • v-for创建且子节点全部或部分有key属性
  • v-for创建且子节点都没key属性
  • 没用v-for创建的多个子节点
这两种情况可以在编译时发现,在生成渲染函数时通过设置patchFlag的值来标记是哪种情况。
vue首先会去判断使用v-for的两种情况,然后再去处理情况情况。
代码如下:
const patchChildren: PatchChildrenFn = ( n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized = false ) => { const c1 = n1 && n1.children const prevShapeFlag = n1 ? n1.shapeFlag : 0 const c2 = n2.children const { patchFlag, shapeFlag } = n2 // fast path if (patchFlag > 0) { if (patchFlag & PatchFlags.KEYED_FRAGMENT) { // this could be either fully-keyed or mixed (some keyed some not) // presence of patchFlag means children are guaranteed to be arrays patchKeyedChildren( c1 as VNode[], c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) return } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) { // unkeyed patchUnkeyedChildren( c1 as VNode[], c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) return } } // children has 3 possibilities: text, array or no children. if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // text children fast path if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { unmountChildren(c1 as VNode[], parentComponent, parentSuspense) } if (c2 !== c1) { hostSetElementText(container, c2 as string) } } else { if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // prev children was array if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // two arrays, cannot assume anything, do full diff patchKeyedChildren( c1 as VNode[], c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } else { // no new children, just unmount old unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true) } } else { // prev children was text OR null // new children is array OR null if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) { hostSetElementText(container, '') } // mount new if array if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { mountChildren( c2 as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } } } }

patchUnkeyedChildren

当使用v-for创建子节点列表且都没有key属性时,这个时候处理比较简单直接,直接用一个指针从两数组的第一个元素同时向后遍历即可,如:
a b c 更新前vnode子元素列表 a b 更新后vnode子元素列表 👆(指针)
然后将指向的更新前后vnode传入patch进行调用,patch是一个递归函数。前后vnode children长度不一定是相同的,但是处理也很简单:
  • 如果旧节点列表比新节点列表长,那么多出的节点就是不需要的节点,需要删除。
  • 如果旧节点列表比新节点列表短,那么少了的节点就是需要添加的节点,需要创建添加。
具体代码如下:
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_ARR c2 = c2 || EMPTY_ARR const oldLength = c1.length const newLength = c2.length const commonLength = Math.min(oldLength, newLength) let i for (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) { // remove old unmountChildren( c1, parentComponent, parentSuspense, true, false, commonLength ) } else { // mount new mountChildren( c2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, commonLength ) } }

patchKeyedChildren

patchKeyedChildren函数相较于patchUnkeyedChildren函数复杂很多,因为patchKeyedChildren包含两种情况:
  1. 都有key属性
  1. 部分由key属性
假如children都有key属性,那么只需要判断key值就可以找到对应的新旧vnode,但是由于存在部分子元素没有key属性的情况,所以处理逻辑更加复杂。
总共有五个步骤:
第一步:首先会将新旧vnode列表从头对齐进行比较:
[ a b ] f c [ a b ] e c 👆
如果新旧vnode类型和key 值都相同,则直接对两vnode进行patch操作。
第二步:与第一步相同,只是换成了从后面对齐遍历:
a b f [ c ] a b e [ c ] 👆
第三步:判断旧vnode列表是否已经都处理完了,且新vnode列表是否还有节点没有被处理完。如果旧vnode都处理完毕且新vnode还有未被处理的,说明多出来的是新增的vnode,需要创建添加到DOM中。
第四步:和上一步相同,不过是判断新vnode,如果新vnode都处理完毕且旧vnode还有未被处理的,说明多出来的是不再需要的节点,需要删除。
第五步:如果新旧两vnode列表都还有节点未被处理,则进行最后的处理。例如以下这种情况:
a b [c d e] f g a b [e d c h] f g
第五个步骤又可以分成三个小步骤,用5.1、5.2来表示
步骤5.1:首先构建新vnode的key映射表:
// map的key为新vnode的key值,value是在新vnode列表中的下标 const 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:然后遍历旧vnode列表,如果旧vnode存在key值,则通过5.1步骤创建的keyToNewIndexMap 映射表找到对应新vnode,两者进行patch,否则就遍历新vnode,找到类型与旧vnode相同且也没有key值的vnode,两者进行patch。如果最终仍然找不到对应的新vnode,则说明该vnode是多余的节点,需要删除。
例外如果在遍历过程中发现新vnode列表都已经处理完毕了,那么就无需再遍历了,未遍历的旧vnode就是需要删除的节点。
let j let patched = 0 const toBePatched = e2 - s2 + 1 // 标记是否发生了节点移动的情况 let moved = false // 指向所有处理过的新vnode中index最大的节点(即最靠后的新vnode) let maxNewIndexSoFar = 0 const newIndexToOldIndexMap = new Array(toBePatched) for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 for (i = s1; i <= e1; i++) { const prevChild = c1[i] if (patched >= toBePatched) { // all new children have been patched so this can only be a removal unmount(prevChild, parentComponent, parentSuspense, true) continue } let newIndex if (prevChild.key != null) { newIndex = keyToNewIndexMap.get(prevChild.key) } else { // key-less node, try to locate a key-less node of the same type for (j = s2; j <= e2; j++) { if ( newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(prevChild, c2[j] as VNode) ) { newIndex = j break } } }
在这一过程中也会创建一个Map newIndexToOldIndexMap,它的作用是记录新vnode和对应旧vnode的位置,为下一个步骤的移动节点做准备,以及标记当前新vnode是否被处理过。
if (newIndex === undefined) { unmount(prevChild, parentComponent, parentSuspense, true) } else { // i+1是因为位置不能为0,为0就表示该vnode没有被处理过,见步骤5.3 newIndexToOldIndexMap[newIndex - s2] = i + 1 if (newIndex >= maxNewIndexSoFar) { maxNewIndexSoFar = newIndex } else { // 当出现有新vnode的下标小于maxNewIndexSoFar的情况,例如两key相同的新旧vnode, // 此时两者的在对应vnode列表中的下标应该是不同的,说明需要移动 moved = true } patch( prevChild, c2[newIndex] as VNode, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) patched++ } }
步骤5.3:移动节点,上一个步骤中虽然已经对新旧vnode都进行了patch操作,但是并未调整vnode在列表中的位置,例如:
(b-d、c-e两两类型相同且都没key值) a [ b c ] / / [ d e ] a
这个时候虽然已经将vnode都patch了,但是a的位置还未调整,因此最后一步就是跳转位置,调整的方法依靠上一步创建的newIndexToOldIndexMap
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : EMPTY_ARR j = increasingNewIndexSequence.length - 1 // looping backwards so that we can use last patched node as anchor for (i = toBePatched - 1; i >= 0; i--) { const nextIndex = s2 + i const nextChild = c2[nextIndex] as VNode const anchor = nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor // 前面都没有处理过的新节点 if (newIndexToOldIndexMap[i] === 0) { // mount new // ... ) } else if (moved) { // move if: // There is no stable subsequence (e.g. a reverse) // OR current node is not among the stable sequence if (j < 0 || i !== increasingNewIndexSequence[j]) { move(nextChild, container, anchor, MoveType.REORDER) } else { j-- } } }

操作DOM节点

前面已经实现了新旧vnode的diff,通过diff找到新旧vnode的不同之处,接下来就是针对不同之处进行实际的DOM修改。
事实上在patch过程中已经在操作DOM节点了,因为旧vnode与真实DOM对应,因此diff过程中对旧vnode的操作其实就是对真实dom节点的操作。
我们以Text节点为例,假设新旧vnode都是Text类型,那么patch过程中就会调用processText函数。而在
const patch: PatchFn = () => { // ... const { type, ref, shapeFlag } = n2 switch (type) { case Text: processText(n1, n2, container, anchor) break // ... } const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => { if (n1 == null) { hostInsert( (n2.el = hostCreateText(n2.children as string)), container, anchor ) } else { const el = (n2.el = n1.el!) if (n2.children !== n1.children) { hostSetText(el, n2.children as string) } } }
这里的hostSetTexthostInsert 等函数其实就是对真实DOM的操作。例如hostSetText
const { setText: hostSetText, } = options setText: (node, text) => { node.nodeValue = text },
这样的函数还有很多,它们都包含对真实DOM的操作。
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
因此在patch过程中,真实dom逐渐地被修改,直到与新vnode一致。
 

小结

首先我们了解了什么是虚拟DOM,虚拟DOM其实就是通过js对象来描述真实dom节点的一种思想。接下来我们分析了vue的渲染器(renderer),以及它是如何将编译器生成的渲染函数转变为视图的。
渲染函数最终转换成视图(即DOM)是通过render函数实现的,渲染器中最重要的操作是patch,通过diff找到新vnode和旧vnode的不同之处,然后针对不同之处修改真实DOM节点,最终使视图与新vnode一致。
vue渲染器将渲染函数转换成视图的起点在于createApp(app).mount(’#app’)语句,它会先创建一个app对象,然后在mount操作调用render函数去递归执行patch,在开发环境下,还会在reload时重新调用render,以此来实现页面热更新。