千层套路 – Vue 3.0 初始化源码探秘

  • Post author:
  • Post category:vue


关注

若川视野

, 回复”

pdf

” 领取资料,回复”

1

“,可加群长期交流学习

刘崇桢,微医云服务团队前端工程师,左手抱娃、右手持家的非典型码农。

9 月初

Vue.js 3.0

正式发布,代号

"One Piece"

。大秘宝都摆到眼巴前了,再不扒拉扒拉就说不过去了。那我们就从初始化开始。

目标:

  • 弄清楚

    createApp(App).mount("#app")

    到底做了什么

  • 弄清楚

    Vue3.0

    的初始化渲染是怎么样的过程

能收获到什么:

  • 了解

    Vue3.0

    的初始化过程

  • 介绍一个阅读

    Vue3.0

    源码的入口和方向

先跑起来



vue-next

代码克隆到本地,打开

package.json



scripts dev

末尾加上

--sourcemap

然后

yarn dev



vue

目录下的

dist

打包出了一份

vue.global.js

和相应的

sourcemap

文件。这样方便我们一步一步调试代码,查看程序在

call Stack

中的每一步调用。

查看

vue

官方给出的 demo,发现

vue

的使用分为

classic



composition

,我们先用

classic

方式,实现一个最简单的 demo。

const app = {
	data () {
		return {
			counter: 1
		}
	}
}
Vue.createApp(app).mount("#app")

ok,页面跑起来了。我们就在这段代码打个断点,然后一步一步的调试,观察

createApp(App).mount("#app")

到底做了什么,了解

Vue3.0

的初始化过程。

在这之前,简单了解一下整体的背景,我们这次主要涉及到

runtime

运行时的代码。

runtime-dom

我们先跟着代码进入:

createApp(App).mount("#app");

这个

createApp()

来自

runtime-dom

,我们通过这个图可以看到他大致做的事情:

return

了一个注册了

mount

方法

app

。这样我们的 demo 至少能跑起来不报错。


createApp

调用了

ensureRenderer

方法,他确保你能得到一个

renderer

渲染器。

renderer

是通过调用创建渲染器的

createRenderer

来生成的,这个

createRenderer

来自于

runtime-core

,后面我们会看到。

而这个

rendererOptions

是什么呢?

const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps);

export const nodeOps: Omit<RendererOptions<Node, Element>, "patchProp"> = {
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null);
  },
  remove,
  createElement,
  createText,
  // ...
};

是不是就是一些

DOM API

的高阶封装,这个在

vue

的生态中,叫

平台特性

。vue 源码中的平台特性就是针对 web 平台的。如果开发者想要在别的平台上运行 vue,比如 mpvue、weex,就不需要 fork 源码库改源码了,直接把

nodeOps

中的方法按着平台的特性逐一实现就可以了。这也是

createRenderer

等跨平台的代码放到

runtime-core

中的原因。

当然

runtime-dom

远远不只图中这些东西,我们先大致过一下初始化过程,以对

vue3.0

有一个大致的了解。

runtime-core

紧接着,进入

runtime-core

,创建渲染器

我们注意

baseCreateRenderer

这个

fn

,2000 多行的代码量,里面的东西都是渲染的核心代码,从平台特性

options

取出相关 API,实现了 patch、处理节点、处理组件、更新组件、安装组件实例等等方法,最终返回了一个对象。这里我们看到了【2】中渲染器调用的

createApp

方法,他是通过

createAppAPI

创建的。代码进入

createAppAPI

这里我们又看见了熟悉的

Vue2.x

中的 API,挂载在

app

上面。

至此,

Vue.createApp(app).mount("#app")

,创建 app 实例的流程,终于在【7】中

return app

告一段落,我们拿到了【2】中的

app

实例。

大致瞄一眼

app

,我们可以在

apiCreateApp.ts

中找到其实现

初次渲染

.mount("#app")

上面的介绍中,其实有两处

.mount

的实现,一处是在

runtime-dom

【2】中的

mount

,我们叫他

dom-mount

。一处是【7】中的

mount

,我们叫他

core-mount


dom-mount

的实现:

const { mount } = app; // 先暂存'core-mount'
app.mount = (containerOrSelector: Element | string): any => {
  const container = normalizeContainer(containerOrSelector); // #app dom 节点
  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); // 执行'core-mount'
  container.removeAttribute("v-cloak");
  return proxy;
};


dom-mount

并不是重写

core-mount

,而是提取了平台特性的逻辑。比如上面如果

component

不是

function

,又没有

render



template

,就读取 dom 节点内部的 html 作为渲染模板。

然后再执行

core-mount



mount(container)

代码很简单,就两步:

  • 创建根组件的

    vnode

  • 渲染这个

    vnode

创建根组件的

vnode

创建

vnode

,是一个初始化

vnode

的过程,这个阶段中,下面的这些属性被初始化为具体的值(还有很多属性没有罗列,都是初始值)。



vnode

描述不同的事物时,他的属性值也各不相同,这些在

vnode

初始化阶段确定的属性在渲染组件时,能带来非常重要的效率提升。


  • type

    ,标识

    VNode

    的种类

  1. html 标签的描述,type 属性就是一个字符串,即标签的名字

  2. 组件的描述,type 属性就是引用组件类(或函数)本身

  3. 文本节点的描述,type 属性就是 null


  • patchFlag

    ,标识组件变化的地方


  • shapeFlag



    VNode

    的标识,标明

    VNode

    属于哪一类,demo 中的

    shapeFlag



    4



    STATEFUL_COMPONENT

    ,有状态的组件。



packages/shared/src/shapeFlags.ts

中,定义了这些通过将十进制数字

1

左移不同的位数得来的枚举值。

export const enum ShapeFlags {
  ELEMENT = 1, // 1 - html/svg 标签
  FUNCTIONAL_COMPONENT = 1 << 1, // 2 - 函数式组件
  STATEFUL_COMPONENT = 1 << 2, // 4 - 有状态组件
  TEXT_CHILDREN = 1 << 3, // 8
  ARRAY_CHILDREN = 1 << 4, // 16
  SLOTS_CHILDREN = 1 << 5, // 32
  TELEPORT = 1 << 6, // 64
  SUSPENSE = 1 << 7, // 128
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 256 - 需要被 keepAlive 的有状态组件
  COMPONENT_KEPT_ALIVE = 1 << 9, // 512 - 已经被 keepAlive 的有状态组件
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 组件
}

为什么为

VNode

标识这些枚举值呢?在

Vue2.x



patch

过程中,代码通过

createElm

区分

VNode

是 html 还是组件或者 text 文本。

所以

Vue2.x



patch

是一个试错过程,在这个阶段是有很大的性能损耗的。

Vue3.0

把对

VNode

的判断放到了创建的时候,这样在

patch

的时候就能避免消耗性能的判断。

最终,我们看一下 vnode 的结构

export interface VNode<
  HostNode = RendererNode,
  HostElement = RendererElement,
  ExtraProps = { [key: string]: any }
> {
  /**
   * @internal
   */
  __v_isVNode: true // 一个始终为 true 的值,有了它,我们就可以判断一个对象是否是 VNode 对象
  /**
   * @internal 内部属性
   */
  [ReactiveFlags.SKIP]: true
  type: VNodeTypes
  props: (VNodeProps & ExtraProps) | null
  key: string | number | null
  ref: VNodeNormalizedRef | null
  scopeId: string | null // SFC only
  children: VNodeNormalizedChildren
  component: ComponentInternalInstance | null
  dirs: DirectiveBinding[] | null
  transition: TransitionHooks<HostElement> | null

  // DOM 相关
  el: HostNode | null
  anchor: HostNode | null // fragment anchor
  target: HostElement | null // teleport target
  targetAnchor: HostNode | null // teleport target anchor
  staticCount: number // number of elements contained in a static vnode

  // suspense 支持 suspense 的属性
  suspense: SuspenseBoundary | null
  ssContent: VNode | null
  ssFallback: VNode | null

  // optimization only 优化模式中使用的属性
  shapeFlag: number
  patchFlag: number
  dynamicProps: string[] | null
  dynamicChildren: VNode[] | null

  // application root node only
  appContext: AppContext | null
}

渲染这个

vnode

ok,书接上回,我们拿到

根组件的 VNode

,接下来执行到

render

函数。


render

的核心逻辑就是

patch

函数。

patch 函数

patch 有两种含义: 1)整个虚拟 dom 映射到真实 dom 的过程;2)patch 函数。我们这里讲的是函数。


patch

就是

render

渲染组件的关键逻辑,【5】中

baseCreateRenderer

2000 行左右的代码,主要是为了

patch

服务的。

// patching & not same type, unmount old tree
if (n1 && !isSameVNodeType(n1, n2)) {
  anchor = getNextHostNode(n1)
  unmount(n1, parentComponent, parentSuspense, true)
  n1 = null
}
// 对于前后节点类型不同的,vue 是直接卸载之前的然后重新渲染新的,不会考虑可能的子节点复用。
...

const { type, ref, shapeFlag } = n2
switch (type) { // 根据节点类型 type 分发到不同的 process
  case Text:
    processText(n1, n2, container, anchor)
    break
  case Comment:
    processCommentNode(n1, n2, container, anchor)
    break
  case Static:
    ...
  case Fragment: 
    ...
  default: // 根据不同的节点标识 shapeFlag 分发到不同的 process
   if (shapeFlag & ShapeFlags.ELEMENT) { 
      processElement(...) 
    } else if (shapeFlag & ShapeFlags.COMPONENT) {
      processComponent(...)
    ...


patch

根据节点

VNode

(4.1 创建的根组件的 vnode) 的

type



shapeFlags

执行不同的

process


  1. type

    :Text 文本


  2. type

    :Comment 注释


  3. type

    :Static 静态标签


  4. type

    :Fragment 片段:VNode 的类型是 Fragment,就只需要把该 VNode 的子节点渲染到页面。有了他,就没有只能有一个根节点的限制,也可以做到组件平级递归


  5. shapeFlags

    :ShapeFlags.ELEMENT 原生节点,html/svg 标签


  6. shapeFlags

    :ShapeFlags.COMPONENT 组件节点


  7. shapeFlags

    :ShapeFlags.TELEPORT 传送节点,将组件渲染的内容传送到制定的 dom 节点中


  8. shapeFlags

    :ShapeFlags.SUSPENSE 挂起节点(异步渲染)

Vue3 新增组件 – Fragment、Teleport、Suspense,可见此链接 (https://www.yuque.com/hugsun/vue3/component)

我们的 demo 中的根组件

VNode



shapeFlag



4(0100)



ShapeFlags.COMPONENT(0110)

,按位与后结果为非零,代码会进入

processCompoent

processXXX

processXXX 是对挂载(mount)和更新(update)补丁的统一操作入口。


processXXX

会根据节点是否是初次渲染,进行不同的操作。

  • 如果没有老的 VNode,就挂载组件(mount)。首次挂载,递归创建真实节点。

  • 如果有老的 VNode,就更新组件(update)。更新补丁的的渲染系统的介绍放到下下篇来介绍。

挂载

创建组件内部实例

内部实例也会暴露一些实例属性给其他更高级的库或工具使用。组件实例属性很多很重要也能帮助理解,可以在

packages/runtime-core/src/component.ts

查看实例的接口声明

ComponentInternalInstance

。很壮观啊,啪的一下 100 多行属性的定义,主要包括基本属性、响应式 state 相关、suspense 相关、生命周期钩子等等

安装组件实例
  1. 初始化 props 和 slots

  2. 安装有状态的组件,这里会初始化组件的响应式

【15】

setupStatefulComponent

,调用了

setup(props, setupContext)

如果没有

setup

时会调用

applyOptions

,应用

vue2.x



options API

,最终对

data()

的响应式处理也是使用

vue3.0



reactive

上面讲过,安装组件实例触发响应式初始化就发生在这里,具体怎么触发的,这块又是一个千层套路,放到下一篇中。

【16】主要是根据

template

拿到组件的

render

渲染函数和应用

vue2.x



options API

我们看一下

template

模板编译后生成的

render

函数。

我们大致看下生成的 render 函数,有几点需要注意

  1. 这里的

    render

    函数执行后的返回是组件的

    VNode


  2. _createVNode

    函数,用于创建

    VNode


  3. _createVNode

    函数的入参,

    type



    patchFlags



    dynamicProps

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT, // type,标识 VNode 的种类
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0, // 标记节点动态变化的地方
  dynamicProps: string[] | null = null, // 动态 props
  isBlockNode = false
): VNode { ... }


createVNode

在创建根节点的时候就出现过,用于创建虚拟 DOM。这个是内部使用的 API,面向用户的 API 还是

h

函数。

export function h(type: any, propsOrChildren?: any, children?: any): VNode { ... }


h

的实现也是调用

createVNode

,但是没有

patchFlag



dynamicProps



isBlockNode

这三个参数。也就是 h 是没有

optimization

的,应该是因为这三个参数,让用户自己算容易出错。

看来这个

patchFlags

有点意思,

标识组件变化的地方,用于 patch 的 diff 算法优化

export const enum PatchFlags {
  TEXT = 1, // 动态文字内容
  CLASS = 1 << 1, // [2]动态 class 绑定
  STYLE = 1 << 2, // [4]动态样式
  PROPS = 1 << 3, // [8]动态 props,不是 class 和 style 的动态 props
  FULL_PROPS = 1 << 4, // [16]有动态的 key,也就是说 props 对象的 key 不是确定的。key 变化时,进行一次 full diff
  HYDRATE_EVENTS = 1 << 5, // [32]
  STABLE_FRAGMENT = 1 << 6, // [64]children 顺序确定的 fragment
  KEYED_FRAGMENT = 1 << 7, // [128]children 中有带有 key 的节点的 fragment
  UNKEYED_FRAGMENT = 1 << 8, // [256]没有 key 的 children 的 fragment
  NEED_PATCH = 1 << 9, // [512]
  DYNAMIC_SLOTS = 1 << 10, // [1024]动态的插槽
  // SPECIAL FLAGS -------------------------------------------------------------
  // 以下是特殊的 flag,负值
  HOISTED = -1, // 表示他是静态节点,他的内容永远不会改变
  BAIL = -2, // 用来表示一个节点的 diff 应该结束
}

之所以使用位运算,是因为



  • |

    来进行复合,

    TEXT | PROPS

    得到

    0000 1001

    ,即十进制 9。标识他既有动态文字内容,也有动态 props。



  • &

    进行 check,

    patchFlag & TEXT



    0000 1001

    &

    0000 0001

    ,得到

    0000 0001

    ,只要结果大于 0,就说明属性命中。

  • 方便扩展、计算更快…


patchFlag

被赋值到

VNode

的属性中,他在后面更新节点时会被用到。为了配合代码的正常流转,先放一放,代码继续

F10

。如果你去调试代码,会发现这真的是千层套路啊,一直

shift + F11

跳出代码到怀疑人生,才终于回到

mountComponent

总结一下

setupComponent

安装组件实例,主要做了什么事情:initProps、initSlots、响应式初始化、得到模板的

render

函数等等。

回顾前文,跳出到【13】,setup 安装组件实例后,下一步是

setupRenderEffect

激活渲染函数的副作用

激活渲染函数的副作用

setupRenderEffect

实现基于【21】,

effect

副作用,意味着响应式数据变化后引起的变更。

effect

源自

reactive

,传入一个

fn

得到一个

reactiveEffect


effect

的入参

componentEffect

是一个命名函数,会立即执行。

componentEffect

执行过程中,触发响应式数据的

getter

拦截,会在

全局数据响应关系仓库

记录当前

componentEffect

。在响应式对象发生改变时,派发更新,执行

componentEffect

回到

componentEffect

function componentEffect() {
  if (!instance.isMounted) {
    let vnodeHook: VNodeHook | null | undefined
    const { el, props } = initialVNode
    const { bm, m, parent } = instance

    // beforeMount hook 生命周期钩子函数
    if (bm) {
      invokeArrayFns(bm)
    }
    ...
    // subTree 根节点的 subTree,通过 renderComponentRoot 根据 render 生成的 vnode
    //大家回忆一下 render 是什么?是不是根组件的 template 编译后得到的好多_createVNode 的渲染器函数?
    const subTree = (instance.subTree = renderComponentRoot(instance))
      ...
      // 更新
      patch(null, subTree, container, ...)
      ...
      if (m) { // parent 的 mounted 执行之前,先执行 subTree 的 patch
        queuePostRenderEffect(m, parentSuspense)
      }
     ...
      instance.isMounted = true // 标志实例已挂载
 } else { ... }
}

执行前面编译后得到的渲染函数 render,生成

subTree: vnode

最后执行

patch

,上文中渲染根节点的

vnode

时执行过

patch

,这里就进入了一个

大循环

,根据组件的

children



type



shapeFlag



baseCreateRenderer

会继续进行各种

processXXX

处理,直至基于

平台特性



DOM 操作

挂载到各自的父节点中。

这个顺序是深度遍历的过程,子节点的

patch

完成之后再进行父节点的

mounted

patch 循环 && subTree 一览


// subTree 的 模板 template
<div id="app">
  <h1>composition-api</h1>
  <p @click="add" :attr-key="counter">{{counter}}</p>
  <p :class="{'counter': counter % 2}">{{doubleCounter}}</p>
</div>

// patchFlag: 64 
// STABLE_FRAGMENT = 1 << 6, // 64 表示:children 顺序确定的 fragment
// shapeFlag: 16
// ARRAY_CHILDREN = 1 << 4, // 16 
  1. 观察上面这个模板,

    Vue2.x

    中的模板只能有一个根元素,

    Vue3.0

    的这个 demo 中有三个根元素,这得益于新增的

    fragment

    组件。


  2. vnode

    标识出来

    patchFlag:64

    ,表示 children 顺序确定的

    fragment


  3. vnode

    标识出来

    shapeFlag:16

    ,表示当前节点是一个孩子数组。


  4. vnode

    标识出来

    dynamicChildren

    ,标识动态变化的孩子节点。显然是两个

    p

    标签,可以想象这个数组的元素也是当前呈现的

    vnode

    ,只不过具体属性值不同罢了

等等,还有 4 吗,我不知道…

当然还有,

processxxx

中一般都会判断是挂载还是更新,更新的时候就会用到

patchFlag

,比如

patchElement

… 下次一定

等等,还有 5 吗,我不知道…

当然还有,第五层我就已经裂开了啊…

あ:あげない      あ:不给你哦~ ????????????
い:いらない,    い:不要了啦~ ????????????
う:うごけない    う:动不了了~ ????????????
え:えらべない    え:不会选嘛~ ????????????
お:おせない      お:按不到耶~ [裂开][裂开][裂开]

刚看源码不久,只能靠

F11

、参考其他文档,凭自己的理解写出这样的文章,肯定有很多理解不对的地方,希望得到批判指正。

附录

  • Vue3初始化.drawio (https://www.yuque.com/office/yuque/0/2020/drawio/441847/1605880555730-4e18923f-c087-4082-af06-ec51986ba658.drawio?from=https%3A%2F%2Fwww.yuque.com%2Fdocs%2Fshare%2F64bd5cdc-3086-4154-a447-04032d161830%3F%23)

推荐阅读


我在阿里招前端,我该怎么帮你?(现在还可以加模拟面试群)



如何拿下阿里巴巴 P6 的前端 Offer



如何准备阿里P6/P7前端面试–项目经历准备篇



大厂面试官常问的亮点,该如何做出?



如何从初级到专家(P4-P7)打破成长瓶颈和有效突破



若川知乎问答:2年前端经验,做的项目没什么技术含量,怎么办?



若川知乎高赞:有哪些必看的 JS库?

末尾

你好,我是

若川,江湖人称菜如若川,历时一年只写了一个学习源码整体架构系列

~(点击蓝字了解我)

  1. 关注

    若川视野

    ,回复”pdf” 领取优质前端书籍pdf,回复”1″,可加群长期交流学习

  2. 我的博客地址:https://lxchuan12.gitee.io 欢迎收藏

  3. 觉得文章不错,可以点个

    在看

    呀^_^另外欢迎

    留言

    交流~

小提醒:若川视野公众号面试、源码等文章合集在菜单栏中间

【源码精选】

按钮,欢迎点击阅读,也可以星标我的公众号,便于查找