Vue3源碼解析計(jì)劃之組件渲染,VNode如何轉(zhuǎn)為真實(shí)DOM
1寫在前面
在VUE中,組件是一個(gè)非常重要的概念,整個(gè)應(yīng)用的頁(yè)面都是通過(guò)組件進(jìn)行渲染實(shí)現(xiàn)的,但是我們?cè)诰帉懡M件時(shí),它們內(nèi)部又是如何進(jìn)行工作的呢?從我們開始編寫組件,到最終轉(zhuǎn)為真實(shí)DOM,是一個(gè)怎樣的轉(zhuǎn)變過(guò)程呢?那么我們應(yīng)該先來(lái)了解vue3中組件時(shí)如何渲染的?
2組件
組件是一個(gè)抽象概念,它是對(duì)一棵DOM樹的抽象,在頁(yè)面寫一個(gè)組件節(jié)點(diǎn):
- <template>
- <div class="test">
- <p>hello world</p>
- </div>
- </template>
那么,一個(gè)組件想要真正渲染成DOM需要以下幾個(gè)步驟:
- 創(chuàng)建VNode
- 渲染VNode
- 生成真實(shí)DOM
這里的VNode是什么,其實(shí)就是能夠描述組件信息的Javascript對(duì)象。
3應(yīng)用程序初始化
一個(gè)組件可以通過(guò)"模板+對(duì)象描述"的方式創(chuàng)建組件,創(chuàng)建好后又是如何被調(diào)用并進(jìn)行初始化的呢?
因?yàn)檎麄€(gè)組件樹是從根組件開始進(jìn)行渲染的,要尋找到根組件的渲染入口,需要從應(yīng)用程序的初始化過(guò)程開始分析。
我們分別看下vue2和vue3初始化應(yīng)用代碼有啥區(qū)別,但其實(shí)沒多大區(qū)別。
- //vue2
- import Vue from "vue";
- import App from "./App";
- const app = new Vue({
- render:h=>h(App);
- })
- app.$mount("#app");
- //vue3
- import {createApp} from "vue";
- import App from "./app";
- const app = createApp(App);
- app.mount("#app");
接下來(lái)我們看看createApp內(nèi)部實(shí)現(xiàn):
- export const createApp = ((...args) => {
- //創(chuàng)建app對(duì)象
- const app = ensureRenderer().createApp(...args)
- if (__DEV__) {
- injectNativeTagCheck(app)
- }
- const { mount } = app
- //重寫mount方法
- app.mount = (containerOrSelector: Element | string): any => {
- const container = normalizeContainer(containerOrSelector)
- if (!container) return
- const component = app._component
- if (!isFunction(component) && !component.render && !component.template) {
- component.template = container.innerHTML
- }
- // clear content before mounting
- container.innerHTML = ''
- const proxy = mount(container)
- container.removeAttribute('v-cloak')
- return proxy
- }
- return app
- }) as CreateAppFunction<Element>
我們看到const app = ensureRenderer().createApp(...args)用來(lái)創(chuàng)建app對(duì)象,那么其內(nèi)部是如何實(shí)現(xiàn)的:
- //渲染相關(guān)的一些配置,比如:更新屬性的方法,操作DOM的方法
- const rendererOptions = {
- patchProp, // 處理 props 屬性
- ...nodeOps // 處理 DOM 節(jié)點(diǎn)操作
- }
- // lazy create the renderer - this makes core renderer logic tree-shakable
- // in case the user only imports reactivity utilities from Vue.
- let renderer: Renderer | HydrationRenderer
- let enabledHydration = false
- // 我們看到中文翻譯就是:延時(shí)創(chuàng)建渲染器,當(dāng)用戶只依賴響應(yīng)式包的時(shí)候,不會(huì)立即創(chuàng)建渲染器,
- // 可以通過(guò)tree-shakable移除核心渲染邏輯相關(guān)的代碼
- function ensureRenderer() {
- return renderer || (renderer = createRenderer(rendererOptions))
- }
渲染器,這是為了跨平臺(tái)渲染做準(zhǔn)備的,簡(jiǎn)單理解就是:包含平臺(tái)渲染邏輯的js對(duì)象。我們看到創(chuàng)建渲染器,是通過(guò)調(diào)用createRenderer來(lái)實(shí)現(xiàn)的,其通過(guò)調(diào)用baseCreateRenderer函數(shù)進(jìn)行返回,其中就有我們要找的createApp: createAppAPI(render, hydrate)。
- export function createRenderer<
- HostNode = RendererNode,
- HostElement = RendererElement
- >(options: RendererOptions<HostNode, HostElement>) {
- return baseCreateRenderer<HostNode, HostElement>(options)
- }
- //
- function baseCreateRenderer(
- options: RendererOptions,
- createHydrationFns?: typeof createHydrationFunctions
- ): any {
- 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
- // ....此處省略兩千行,我們先不管
- return {
- render,
- hydrate,
- createApp: createAppAPI(render, hydrate)
- }
- }
我們看到createAppAPI(render, hydrate)方法接受兩個(gè)參數(shù):根組件渲染函數(shù)render,可選參數(shù)hydrate是在SSR場(chǎng)景下應(yīng)用的,這里先不關(guān)注。
- export function createAppAPI<HostElement>(
- render: RootRenderFunction,
- hydrate?: RootHydrateFunction
- ): CreateAppFunction<HostElement> {
- //createApp方法接受的兩個(gè)參數(shù):根組件的對(duì)象和prop
- 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
- }
- // 創(chuàng)建默認(rèn)APP配置
- const context = createAppContext()
- const installedPlugins = new Set()
- let isMounted = false
- const app: App = {
- _component: rootComponent as Component,
- _props: rootProps,
- _container: null,
- _context: context,
- get config() {
- return context.config
- },
- set config(v) {
- if (__DEV__) {
- warn(
- `app.config cannot be replaced. Modify individual options instead.`
- )
- }
- },
- // 都是一些眼熟的方法
- use() {},
- mixin() {},
- component() {},
- directive() {},
- //用于掛載組件
- mount(rootContainer){
- //創(chuàng)建根組件的VNode
- const vnode = createVNode(rootComponent,rootProps);
- //利用渲染器渲染VNode
- render(vnode,rootContainer);
- app._container = rootComponent;
- return vnode.component.proxy;
- }
- // ...
- }
- return app
- }
- }
在整個(gè)app對(duì)象的創(chuàng)建過(guò)程中,vue.js利用 閉包和函數(shù)柯里化 的技巧,很好的實(shí)現(xiàn)參數(shù)保留。如:在執(zhí)行app.mount的時(shí)候,不需要傳入渲染器render,因?yàn)樵趫?zhí)行createAppAPI的時(shí)候,渲染器render參數(shù)已經(jīng)被保留下來(lái)。
我們知道在vue源碼中已經(jīng)將mount方法已經(jīng)進(jìn)行封裝,但是在我們使用時(shí)為什么還要進(jìn)行重寫,而不是直接把相關(guān)邏輯放在app對(duì)象的mount方法內(nèi)部實(shí)現(xiàn)呢?
重寫的目的是:實(shí)現(xiàn)既能讓用戶在使用API時(shí)更加靈活,也可以兼容Vue2的寫法。
這是因?yàn)関ue.js不僅僅是為web平臺(tái)服務(wù)的,其設(shè)計(jì)的目標(biāo)是"星辰大海"--實(shí)現(xiàn)支持跨平臺(tái)渲染,內(nèi)部不能夠包含任何指定平臺(tái)的內(nèi)容,createApp函數(shù)內(nèi)部的app.mount方法是一個(gè)標(biāo)準(zhǔn)的可跨平臺(tái)的組件渲染流程:先創(chuàng)建VNode,再渲染VNode。
- mount(rootContainer){
- //創(chuàng)建根組件的VNode
- const vnode = createVNode(rootComponent,rootProps);
- //利用渲染器渲染VNode
- render(vnode,rootContainer);
- app._container = rootComponent;
- return vnode.component.proxy;
- }
我們看到app.mount重寫的代碼如下:
- //重寫mount方法
- app.mount = (containerOrSelector: Element | string): any => {
- //標(biāo)準(zhǔn)化容器
- const container = normalizeContainer(containerOrSelector)
- //如果容器為空對(duì)象,就直接返回呢
- if (!container) return
- const component = app._component
- //如果組件對(duì)象沒有定義render函數(shù)和template模板,則直接取出容器的innerHTML方法作為組件模板內(nèi)容
- if (!isFunction(component) && !component.render && !component.template) {
- component.template = container.innerHTML
- }
- //在掛載前清空容器內(nèi)容 clear content before mounting
- container.innerHTML = ''
- //實(shí)現(xiàn)真正的掛載
- const proxy = mount(container)
- container.removeAttribute('v-cloak')
- return proxy
- }
4核心渲染流程:創(chuàng)建VNode和渲染VNode
vnode本質(zhì)上用來(lái)描述DOM的Javascript對(duì)象,它在vue中可以描述不同節(jié)點(diǎn),比如:普通元素節(jié)點(diǎn)、組件節(jié)點(diǎn)等。
我們可以使用vnode來(lái)表示button標(biāo)簽:
- type:標(biāo)簽的類型
- props:標(biāo)簽的DOM屬性信息
- children:DOM的子節(jié)點(diǎn),vnode數(shù)組
- const vnode = {
- //標(biāo)簽的類型
- type:"button",
- //標(biāo)簽的DOM屬性信息
- props:{
- "class":"btn",
- style:{
- width:"100px",
- height:"100px"
- }
- },
- //dom的子節(jié)點(diǎn),vnode數(shù)組
- children:"確認(rèn)"
- }
那么,我們可以使用vnode來(lái)對(duì)抽象事物的描述,比如用來(lái)表示組件標(biāo)簽
- const HelloWorld = {
- //定義組件對(duì)象信息
- }
- const vnode = {
- type:HelloWorld,
- props:{
- msg:"test"
- }
- }
我們?cè)谙耄簐node到底有什么優(yōu)勢(shì),為什么一定要設(shè)計(jì)成vnode這樣的數(shù)據(jù)結(jié)構(gòu)?
- 抽象:引入vnode,可以將渲染過(guò)程抽象化,從而使得組件的抽象能力有所提升。
- 跨平臺(tái):因?yàn)閜atch vnode過(guò)程不同平臺(tái)可以有自己的實(shí)現(xiàn),給予vnode再做服務(wù)端渲染、weex平臺(tái)、小程序平臺(tái)的渲染。
但是呢,注意:使用vnode并不意味著不用操作真實(shí)DOM。很多人會(huì)誤認(rèn)為vnode的性能一定會(huì)比手動(dòng)操作DOM好,但其實(shí)并不是一定的。這是因?yàn)椋?/p>
- 基于vnode實(shí)現(xiàn)的MVVM框架,在每次render to vnode過(guò)程中,渲染組件會(huì)有一定的javascript耗時(shí),尤其是大組件
- 當(dāng)我們?nèi)ジ陆M件時(shí),可以感覺到明顯的卡頓現(xiàn)象。雖然diff算法在減少DOM操作方面足夠優(yōu)秀,但最終還是免不了操作DOM,所以性能并不能說(shuō)是絕對(duì)優(yōu)勢(shì)
創(chuàng)建VNode
我們前面捋了一遍源碼,知道vue中是通過(guò)createVNode函數(shù)創(chuàng)建根組件的vnode的。
- const vnode = createVNode(rootComponent,rootProps);
- //createVNode函數(shù)的大致實(shí)現(xiàn)流程
- function createVNode(type,props=null,children=null){
- if(props){
- //處理props的相關(guān)邏輯,標(biāo)準(zhǔn)化class和style
- }
- //對(duì)于vnode類型信息編碼
- const shapeFlag = isString(type)
- ? 1/*ELEMENT*/ : isSuspense(type)
- ? 128 /*SUSPENSE*/ : isTeleport(type)
- ? 64 /*TELEPORT*/ : isObject(type)
- ? 4 /*STATEFUL_COMPONENT*/ : isFunction(type)
- ? 2 /*FUNCTIONAL_COMPONENT*/ : 0
- const vnode = {
- type,
- props,
- shapeFlag,
- //其他屬性
- }
- //標(biāo)準(zhǔn)化子節(jié)點(diǎn),把不同數(shù)據(jù)類型的children轉(zhuǎn)成數(shù)組或文本類型
- normalizeChildren(vnode,children)
- return vnode
- }
渲染VNode
- render(vnode,rootContainer)
- function render(vnode,rootContainer){
- //判斷是否為空
- if(vnode == null){
- //如果為空,執(zhí)行銷毀組件的邏輯
- if(container._vnode){
- unmount(container._vnode,null,null,true)
- }
- }else{
- //創(chuàng)建或更新組件
- patch(container._vnode||null,vnode,container)
- }
- //緩存vnode節(jié)點(diǎn),表示已經(jīng)渲染
- container._vnode = vnode
- }
那么在渲染vnode過(guò)程中涉及道到的patch補(bǔ)丁函數(shù)是如何實(shí)現(xiàn)的:
- function patch(
- n1,//舊的vnode,當(dāng)n1==null時(shí),表示時(shí)一次掛載的過(guò)程
- n2,//新的vnode,后續(xù)會(huì)根據(jù)這個(gè)vnode類型執(zhí)行不同的處理邏輯
- container,//表示dom容器,在vnode渲染生成DOM后,會(huì)掛載到container下面
- anchor=null,
- parentComponent=null,
- parentSuspense=null,
- isSVG=false,
- optimized=false
- ){
- //如果存在新舊節(jié)點(diǎn),且新舊節(jié)點(diǎn)類型不同,則銷毀舊節(jié)點(diǎn)
- if(n1&&!isSameVNodeType(n1,n2)){
- anchor = getNextHostNode(n1);
- unmount(n1,parentComponent,parentSuspense,true);
- n1 = null;
- }
- const {type,shapeFlag} = n2;
- switch(type){
- case Test:
- //處理文本節(jié)點(diǎn)
- break
- case Comment:
- //處理注釋節(jié)點(diǎn)
- break
- case Static:
- //處理靜態(tài)節(jié)點(diǎn)
- break
- case Fragment:
- //處理Fragment元素
- break
- default:
- if(shapeFlag & 1 /*ELEMENT*/){
- //處理普通DOM元素
- processElemnt(
- n1,
- n2,
- container,
- anchor,
- parentComponent,
- parentSuspense,
- isSVG,
- optimized
- )
- }else if(shapeFlag & 64 /*TELEPORT*/){
- //處理普通TELEPORT
- processElemnt(
- n1,
- n2,
- container,
- anchor,
- parentComponent,
- parentSuspense,
- isSVG,
- optimized
- )
- }else if(){
- }else if(){
- }else if(){
- }
- }
- }
我們看下處理組件的parentComponent函數(shù)的實(shí)現(xiàn):
- function parentComponent(
- n1,
- n2,
- container,
- anchor,
- parentComponent,
- parentSuspense,
- isSVG,
- optimized
- ){
- if(n1==null){
- //掛載組件
- mountComponent(
- n1,
- n2,
- container,
- anchor,
- parentComponent,
- parentSuspense,
- isSVG,
- optimized
- )
- }else{
- //更新組件
- updateComponent(
- n1,
- n2,
- parentComponent,
- optimized
- )
- }
- }
關(guān)于組件實(shí)例:
- 創(chuàng)建組件實(shí)例:內(nèi)部通過(guò)對(duì)象的方式創(chuàng)建了當(dāng)前渲染的組件實(shí)例
- 設(shè)置組件實(shí)例:instance保留了很多組件相關(guān)的數(shù)據(jù),維護(hù)了組件的上下文,包括對(duì)props、插槽以及其他實(shí)例的屬性的初始化處理
初始渲染主要做兩件事情:
- 渲染組件生成subTree
- 把subTree掛載到container中
再回到我們夢(mèng)開始的地方,我們看到在HelloWorld組件內(nèi)部,整個(gè)DOM節(jié)點(diǎn)對(duì)應(yīng)的vnode執(zhí)行renderComponentRoot渲染生成對(duì)應(yīng)的subTree,我們可以把它成為"子樹vnode"。
- <template>
- <div class="test">//test被稱為子樹vnode
- <p>hello world</p>
- </div>
- </template>
如果是其它平臺(tái)比如weex等,hostCreateElment方法就不再是操作DOM,而是平臺(tái)相關(guān)的API,這些平臺(tái)相關(guān)的方法是在創(chuàng)建渲染器階段作為參數(shù)傳入的。
創(chuàng)建完DOM節(jié)點(diǎn)后,要判斷如果有props,就給這個(gè)DOM節(jié)點(diǎn)添加相關(guān)的class、style、event等屬性,并在hostPatchProp函數(shù)內(nèi)部做相關(guān)的處理邏輯。
5嵌套組件
在生產(chǎn)開發(fā)中,App和hello組件的例子就是嵌套組件的場(chǎng)景,組件vnode主要維護(hù)著組件的定義對(duì)象,組件上的各種props,而組件本身是一個(gè)抽象節(jié)點(diǎn),它自身的渲染其實(shí)是通過(guò)執(zhí)行組件定義的render函數(shù)渲染生成的子樹vnode完成的,然后再通過(guò)patch這種遞歸方式,無(wú)論組件嵌套層級(jí)多深,都可以完成整個(gè)組件樹的渲染。
6參考文章
- 《Vue3核心源碼解析》
- 《Vue中文社區(qū)》
- 《Vue3中文文檔》
7寫在最后
本文主要分析總結(jié)了組件的渲染流程,從入口開始層層分析組件渲染過(guò)程的源碼,我們知道了一個(gè)組件想要真正渲染成DOM需要以下三個(gè)步驟:
- 創(chuàng)建VNode
- 渲染VNode
- 生成真實(shí)DOM