Vue3架构分析(三)—— 运行时之渲染器和Vue App的设计与新建

大家好,我是原心。

提示: 本系列文章都是基于vue 3.4.21源码进行解读梳理,文章中关键的地方会链接到github源码文件对应位置

上一篇文章我们分析了Vue3编译器vue sfc组件编译成JS代码的过程。接下来,我们来看看Vue3渲染器App结构的设计与实现。

一、应用启动流程概览

在开始分析之前,我们先来看一张Vue应用启动的流程概览图:

上图已经较为详细地梳理出了Vue应用启动,运行时内部(@vue/runtime-dom@vue/runtime-core)的执行过程,基于上图我们可以看到Vue应用启动过程经过了如下步骤:

  1. 用户创建应用: 程序员通过createApp(AppComponent)调用vue运行时提供的createApp函数,并传入用户实现的自定义组件App,这时我们传入的App组件就是我们应用的根组件,而我们的VNode树(虚拟Dom树)的根节点,也将根据它的定义来创建。
  2. 获取渲染器: Vue运行时在收到创建应用的调用后,会先判断当前运行时是否有合适的渲染器可以使用,如果没有的话则会创建渲染器,创建渲染器的时候,会通过createAppAPI工厂函数创建一个createApp函数,而真正创建App实例的工作将由这个函数来完成。
  3. 创建并返回App实例: 这一步会首先创建App上下文用于保存App在整个运行过程中的配置和状态,以及暴露一些公开的辅助函数。
  4. 用户侧调用App实例的mount('#app')函数: 用户侧调用mount函数,将用户提供的自定义组件AppComponent挂载到idapp的dom节点上。
  5. 创建VNode根节点和根节点组件实例: 紧接着Vue运行时此时会以AppComponent为标的创建一个VNode节点,不过这个VNode节点上很多信息都还是空的,不过没关系,紧接着又创建一个AppComponent对应的实例,不过此时的实例只是一个满足ComponentInternalInstance接口的对象。
  6. 初始化AppComponent实例: 接着便会执行AppComponentsetup函数,不过在执行setup函数之前,会先为实例初始化Props初始值和Slots初始值,这也使得我们在setup函数里面可以通过形参拿到propsslots,执行完setup函数之后,我们会根据执行的结果更新组件实例VNode实例
  7. 处理选项式API相关初始化过程: 图中的灰底虚线边框部分流程,都是处理**选项式API组件(Options API)**的流程,首先会调用beforeCreate钩子,然后依次处理injectdatacomputedwatchprovide属性,然后调用created钩子,最后注册options对象提供的生命周期函数。
  8. 设置渲染副作用: 最后,会创建一个当前组件的渲染副作用对象,用于收集当前组件的渲染函数执行期间,响应式数据依赖收集,以便在被依赖的响应式数据变化时,自动调度渲染副作用,重新执行渲染。
  9. 执行渲染: 渲染的过程就是深度遍历VNode树,并生成对应的DOM节点的过程,这个过程后面我们再详细讲解。

接下来,我们来看看Vue渲染器是怎么设计的。

二、渲染器的设计与创建

下方类图应该能表达出渲染器的代码结构设计,他同时用到了单例模式和工厂模式:

提示: 上图是为了便于理解其代码的设计结构而使用类图进行表达,Vue3实际代码实现中,并不存在RendererSingleton类、RendererFactoryRendererBuilder类,因为JS或TS中要表达这些设计思想,并不需要使用类。

从上图可以看出,Vue3渲染器 使用了三个创建型设计模式(单例模式工厂方法模式建造者模式 ),这样做的主要目的应该是为了可扩展性和简化创建渲染器实例的过程,下面我们就基于上图尝试结合源码做一些分析:

  1. 对渲染器的抽象: 从上图看出,Vue3对渲染器进行了高度的抽象,我们通过源码来看看Vue3对渲染器接口的定义 查看源码
ts 复制代码
export interface Renderer<HostElement = RendererElement> {
  render: RootRenderFunction<HostElement>
  createApp: CreateAppFunction<HostElement>
}

// 用于SSR场景
export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
  hydrate: RootHydrateFunction
}

Vue3渲染器 只有render(负责渲染)和createApp(负责创建应用)这两个函数,而如果是SSR场景,客户端通过HydrationRenderer提供的hydrate方法进行水合(激活)。

  1. 渲染器创建过程的抽象: 通过上图,我们看到图中的RendererBuilder(渲染器建造者 )类是作为创建Renderer(渲染器 )的封装,其中有两个名为baseCreateRenderer的函数,分别用于创建两种不同场景下使用的渲染器。
    而我们的RendererBuilder类依赖RendererOptionsCreateHydrationFunctions接口,这两个被依赖的接口我们可以将其称为"原材料"。这里打个比方,RendererBuilder就好比是一个专门制造渲染器的工人,只要你给他提供符合RendererOptionsCreateHydrationFunctions这两个接口的"原材料",他就能帮你生产出你想要的渲染器,至于制造出来的渲染器的具体功能和适用场景,取决于你提供的原材料。
ts 复制代码
// overload 1: no hydration
function baseCreateRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement,
>(options: RendererOptions<HostNode, HostElement>): Renderer<HostElement>

// overload 2: with hydration
function baseCreateRenderer(
  options: RendererOptions<Node, Element>,
  createHydrationFns: typeof createHydrationFunctions,
): HydrationRenderer

// implementation
function baseCreateRenderer(
  options: RendererOptions,
  // 注意这里的类型是 typeof createHydrationFunctions 
  // 下面的 createHydrationFunctions 函数刚好就是这个类型
  createHydrationFns?: typeof createHydrationFunctions,
): any {
  ... // 此处省略了 2071 行代码,这就是我们创建者模式的核心价值,使得外部在创建渲染器的时候,关注点不要在这些复杂细节上
  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate),
  }
}
ts 复制代码
// 渲染器原材料之一,该接口主要约束了具体的节点操作入参和出参,至于实现,那是另外需要考虑的,大家可以根据注释感受一下这个接口的含义
export interface RendererOptions<
  HostNode = RendererNode,
  HostElement = RendererElement,
> {
  // 合并属性(也就是当节点更新时,属性的更新怎么打补丁)
  patchProp(
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
    namespace?: ElementNamespace,
    prevChildren?: VNode<HostNode, HostElement>[],
    parentComponent?: ComponentInternalInstance | null,
    parentSuspense?: SuspenseBoundary | null,
    unmountChildren?: UnmountChildrenFn,
  ): void
  // 插入节点
  insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
  // 移除节点
  remove(el: HostNode): void
  // 创建元素
  createElement(
    type: string,
    namespace?: ElementNamespace,
    isCustomizedBuiltIn?: string,
    vnodeProps?: (VNodeProps & { [key: string]: any }) | null,
  ): HostElement
  createText(text: string): HostNode
  createComment(text: string): HostNode
  setText(node: HostNode, text: string): void
  setElementText(node: HostElement, text: string): void
  parentNode(node: HostNode): HostElement | null
  nextSibling(node: HostNode): HostNode | null
  querySelector?(selector: string): HostElement | null
  setScopeId?(el: HostElement, id: string): void
  cloneNode?(node: HostNode): HostNode
  insertStaticContent?(
    content: string,
    parent: HostElement,
    anchor: HostNode | null,
    namespace: ElementNamespace,
    start?: HostNode | null,
    end?: HostNode | null,
  ): [HostNode, HostNode]
}
// 返回SSR环境激活应用和激活节点所需的函数
export function createHydrationFunctions(
  rendererInternals: RendererInternals<Node, Element>,
) {
  ... 
  // 此处省略了636行代码,其实这个函数本身也是一个建造者模式的实例,都是隐藏了创建 hydrate, hydrateNode 函数的复杂性
  // 让使用者将关注点转移到 RendererInternals 这个接口的约定
  return [hydrate, hydrateNode] as const
}

这样做的好处有两点:
一是: baseCreateRenderer封装了创建渲染器的复杂过程,使得使用时不再关注创建渲染器的复杂过程,转而只需要关注"原材料"的说明书(即:RendererOptionsCreateHydrationFunctions接口的定义),至于需要实现何种功能的渲染器,只需提供合适的"原材料"即可。
二是: 将可自定义的节点操作相关实现从渲染器抽离到RendererOptions中,使得渲染器的实现与具体渲染运行环境解耦,只要在对应环境提供相应实现,那么渲染器仍然能够正常工作

如果上面的第二点好处您没有get到的话,那么我们举个例子:假设我们要将Vue3的应用渲染到canvas上,应该怎么做呢?我们可以提供基于canvas图形API的RendererOptions接口实现,并传递给baseCreateRenderer创建我们需要的渲染器,然后我们通过这个渲染器创建出来的应用在渲染的时候就会使用我们的RendererOptions实现,将对应的Vue组件渲染到canvas上了。不过这个实现可能非常具有挑战性

  1. 渲染器创建过程的再次抽象: 由于在同一个环境下,Vue3运行时应该只需要一个渲染器,因此Vue3在这里使用了单例模式,而在创建的时候,为了更加方便地创建渲染器,中间又加入了类似工厂方法的一层封装。
ts 复制代码
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer

let enabledHydration = false

function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
  )
}

function ensureHydrationRenderer() {
  // 1. 如果已经创建了具有水合功能的渲染器,则直接返回当前的渲染器
  // 2. 如果没有创建过具有水合功能的渲染器,则创建一个具有水合功能的渲染器,并赋值给渲染器
  renderer = enabledHydration
    ? renderer
    : createHydrationRenderer(rendererOptions)
  enabledHydration = true
  return renderer as HydrationRenderer
}

从代码可以看出,这是一个典型的懒汉模式的单例实现。

ts 复制代码
export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement,
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

export function createHydrationRenderer(
  options: RendererOptions<Node, Element>,
) {
  // 这里的 createHydrationFunctions 使用了内部默认实现
  return baseCreateRenderer(options, createHydrationFunctions)
}

值得注意的是: 单例部分的代码是在@vue/runtime-dom包中,而工厂方法的实现是在@vue/runtime-core中,相对于@vue/runtime-dom来说@vue/runtime-core算外部依赖,因此为了方便外部使用,@vue/runtime-core提供工厂方法我非常好的,可以提升使用体验。

三、App的设计与创建

渲染器已经创建好了,现在就可以通过渲染器来创建应用了,我们首先通过一个UML类图来认识Vue应用的主体结构:

从上图可以看出,Vue应用的主体结构包含三部分AppAppConfigAppContext组成,它们的职责应该是这样的:

  1. App: App描述了Vue应用的基本信息,包括版本号唯一标识根组件挂载点等信息,以及暴露usecomponents,directivemount等一些我们常用的方法。
  2. AppContext: 主要用于保存应用当前运行时状态,包括应用当前状态下的组件指令以及选项、props、emits缓存等。
  3. AppConfig: 主要存放应用的全局配置信息以及兼容Options API的选项合并策略等。

现在,我们已经了解了Vue App的基本结构,接下来我们看看Vue App的创建过程:

  1. 创建Vue App主流程: 下面是我们精简后的代码 查看源码
ts 复制代码
function createApp(rootComponent, rootProps = null) {
  ...
  // 创建AppContext
  const context = createAppContext()
  // 用于判断一个插件是否已经安装过
  const installedPlugins = new WeakSet()

  let isMounted = false

  const app: App = (context.app = {
    _uid: uid++,
    _component: rootComponent as ConcreteComponent,
    _props: rootProps,
    _container: null,
    // 将AppContext于App进行关联
    _context: context,
    _instance: null,

    version,

    // 与AppConfig建立依赖
    get config() {
      return context.config
    },

    set config(v) {
    },

    // 安装插件的实现
    use(plugin: Plugin, ...options: any[]) {
      if (installedPlugins.has(plugin)) {
        __DEV__ && warn(`Plugin has already been applied to target app.`)
      } else if (plugin && isFunction(plugin.install)) {
        // 安装 {install: (Vue, ...any[]) => void } 这种类型的插件
        installedPlugins.add(plugin)
        plugin.install(app, ...options)
      } else if (isFunction(plugin)) {
        // 安装 (Vue, ...any[]) => void  这种类型的插件
        installedPlugins.add(plugin)
        plugin(app, ...options)
      }
      return app
    },

    // 混入
    mixin(mixin: ComponentOptions) {
      if (__FEATURE_OPTIONS_API__) {
        if (!context.mixins.includes(mixin)) {
          context.mixins.push(mixin)
        }
      }
      return app
    },

    component(name: string, component?: Component): any {
      if (!component) {
        return context.components[name]
      }
      context.components[name] = component
      return app
    },

    directive(name: string, directive?: Directive) {
      if (!directive) {
        return context.directives[name] as any
      }
      context.directives[name] = directive
      return app
    },

    // 挂载,这部分后续讲首次渲染与挂载的时候再详细来说
    mount(
      rootContainer: HostElement,
      isHydrate?: boolean,
      namespace?: boolean | ElementNamespace,
    ): any {
      if (!isMounted) {
        const vnode = createVNode(rootComponent, rootProps)
        vnode.appContext = context

        if (namespace === true) {
          namespace = 'svg'
        } else if (namespace === false) {
          namespace = undefined
        }

        if (isHydrate && hydrate) {
          hydrate(vnode as VNode<Node, Element>, rootContainer as any)
        } else {
          render(vnode, rootContainer, namespace)
        }
        isMounted = true
        app._container = rootContainer
        ;(rootContainer as any).__vue_app__ = app

        return getExposeProxy(vnode.component!) || vnode.component!.proxy
      }
    },

    unmount() {
      if (isMounted) {
        // 卸载很简单,就是将null渲染到应用的挂载点中
        render(null, app._container)
        delete app._container.__vue_app__
      }
    },

    provide(key, value) {
      context.provides[key as string | symbol] = value
      return app
    },

    // 这个函数主要是用来在某些函数内部执行一些需要当前应用上下文的函数
    runWithContext(fn) {
      const lastApp = currentApp
      currentApp = app
      try {
        return fn()
      } finally {
        currentApp = lastApp
      }
    },
  })

  return app
}

从上面的代码可以看出,Vue3中并不存在实现App接口的类,App实际上就是一个普通的对象,只是这个对象中包含了App接口要求的属性和函数。

  1. 创建AppContextAppConfig 查看源码
ts 复制代码
export function createAppContext(): AppContext {
  return {
    app: null as any,
    // AppConfig
    config: {
      isNativeTag: NO,
      performance: false,
      globalProperties: {},
      optionMergeStrategies: {},
      errorHandler: undefined,
      warnHandler: undefined,
      compilerOptions: {},
    },
    mixins: [],
    components: {},
    directives: {},
    provides: Object.create(null),
    optionsCache: new WeakMap(),
    propsCache: new WeakMap(),
    emitsCache: new WeakMap(),
  }
}

从上面的代码可以看到,AppAppContextAppConfig这几个接口都没有实现类,转而在创建的时候在函数中完成初始化。

我想这样实现可能是为了防止多个应用之间行为的相互影响吧,假如是通过class AppClass implements App来实现App接口,然后再通过new AppClass创建应用的话,假设有人通过AppClass.prototype函数实现,那么会影响所有App的行为。

本篇我们先到这里吧,下一篇我将详细分析应用的首次渲染与挂载的过程。

总结

本篇我们主要分析了Vue3运行时渲染器和App的设计及创建:

  1. 渲染器 将可变的与运行平台相关的Api抽离到了RendererOptions接口中,其它复杂实现则内聚到了baseCreateRenderer函数中,同时为了简化创建渲染器 的过程和提升获取渲染器的效率,同时使用了单例工厂方法建造者三个创建型设计模式。
  2. Vue App 主要包括应用信息载体App接口,运行时状态载体AppContext和应用配置信息载体AppConfig三部分。

由于笔者的水平所限,文章可能存在不足和谬误,恳请不吝指正。

如果文章对您有用,还望不吝三连^_^

【原创声明】

本作品(包括但不限于文字、图片、音频、视频等)为(原心<yunsin@vip.qq.com>)原创作品,版权归原作者所有。未经授权,任何组织、机构、企业、个人不得以任何形式进行复制、转载、摘编、发表、发布、散布、传播等任何行为。

任何在未经授权的情况下使用本作品的行为均被视作侵权行为,我们将保留追究法律责任的权利。如需使用本作品,请联系(原心<yunsin@vip.qq.com>)并注明出处及署名,我们将酌情考虑授权。

本声明的最终解释权归(原心<yunsin@vip.qq.com>)所有,如有疑问请联系(微信:iamyunsin 邮箱: yunsin@vip.qq.com)。

相关推荐
@解忧杂货铺5 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js
苹果酱05676 小时前
「Mysql优化大师一」mysql服务性能剖析工具
java·vue.js·spring boot·mysql·课程设计
车载诊断技术8 小时前
电子电气架构 --- 什么是EPS?
网络·人工智能·安全·架构·汽车·需求分析
武子康8 小时前
大数据-258 离线数仓 - Griffin架构 配置安装 Livy 架构设计 解压配置 Hadoop Hive
java·大数据·数据仓库·hive·hadoop·架构
web1309332039810 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
supermapsupport12 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
m0_7482548812 小时前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui
9527华安13 小时前
FPGA多路MIPI转FPD-Link视频缩放拼接显示,基于IMX327+FPD953架构,提供2套工程源码和技术支持
fpga开发·架构·音视频
苹果醋313 小时前
Golang的文件加密工具
运维·vue.js·spring boot·nginx·课程设计
关你西红柿子14 小时前
小程序app封装公用顶部筛选区uv-drop-down
前端·javascript·vue.js·小程序·uv