vue3 中渲染器api的设计

November 14, 2020

vue2的代码中将vdom渲染成dom的方法封装在内部,如果要将vue渲染到其他一些平台,从代码组织上就很啰嗦。比如mp-vue就是直接从fork了一份vue的代码,然后修改渲染vdom的那部分代码。比如weex因为和vue有合作,所以代码是直接放在vue里的platform目录下。而vue3则彻底将运行时和将vdom渲染到页面上的平台相关代码进行了分离。从代码的组织上就能看出来了,runtime-core是平台无关的运行时,runtime-dom是和dom相关的api,除了vue实现的dom 平台之外,vue还将api设计成友好的可拓展的方式,可以让开发者自定义渲染器。所以本文将从源码解析vue 关于渲染器部分的api设计与实现。

首先来看下,如何在web页面上渲染一个app

const Counter = {
  data() {
    return {
      counter: 0
    }
  }
}

Vue.createApp(Counter).mount('#counter')
new Vue({
  el: '#counter',
  render: (h) => <Counter />
})

vue3的api设计也和vue2 有很大的区别

vue3 引入了一个新的 概念application,通过createApp可以创建一个application,然后可以在application上可以注册这个application上全局的指令。

通过源码,可以看到

export const createApp = ((...args) => {
  const app = ensureRenderer().createApp(...args)

  if (__DEV__) {
    injectNativeTagCheck(app)
  }

  const { mount } = app
  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')
    container.setAttribute('data-v-app', '')
    return proxy
  }

  return app
}) as CreateAppFunction<Element>

这里通过调用ensureRenderer() 返回一个渲染器,然后调用渲染器的createApp方法得到app,然后重写app的mount方法。

所以在vue里渲染器是什么呢?可以看看ensureRender()函数

// 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> | HydrationRenderer

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

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

这里的渲染器就是通过createRenderer函数创建的,传递的参数 是 一些操作dom的方法,比如nodeOps

const nodeOps = {
    insert() {},
    remove() {},
    createElement() {},
    createText() {},
    createComment() {},
    setText() {},
    setElementText() {},
    parentNode() {},
    nextSibling() {},
    querySelector() {},
    setScopeId() {},
    cloneNode() {},
    insertStaticContent() {}
}

都是一些常规的操作dom的方法。

再看下createRenderer的实现,在runtime-core包里面

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

createRenderer函数是被导出的,可以提供给开发者使用。这也是我们自定义渲染器需要使用的方法。接下来看下baseCreateRenderer的实现

function baseCreateRender(options, createHydrationFns) {
    const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    forcePatchProp: hostForcePatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    createComment: hostCreateComment,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    setScopeId: hostSetScopeId = NOOP,
    cloneNode: hostCloneNode,
    insertStaticContent: hostInsertStaticContent
  } = options
    // ...
    
    const render = (vnode, contianer) => {
        //
    }
    let hydrate: ReturnType<typeof createHydrationFunctions>[0] | undefined
  let hydrateNode: ReturnType<typeof createHydrationFunctions>[1] | undefined
  if (createHydrationFns) {
    ;[hydrate, hydrateNode] = createHydrationFns(internals as RendererInternals<
      Node,
      Element
    >)
  }

    return {
        render,
        hydrate,
        createApp: createAppAPI(render, hydrate)
    }
}

这里可以看到,baseCreateRenderer 函数没有导出,这是因为这是通用的渲染器定义,在这个函数 返回了三个方法

  • render: 将vnode渲染到指定容器中
  • hydrate: ssr相关
  • createApp: 创建应用

在这个函数中,从options对象中取出操作dom的那些方法,然后得到一个render 函数,这个render 函数作用就是将vnode 渲染到指定的节点上,这其中就会利用options传过来的那些原生dom方法。

可以看到一个渲染器包含了 render,hydrate,createApp三个部分。

我们再看下createApp的实现

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,

      version,

      get config() {
        return context.config
      },

      set config(v) {
        if (__DEV__) {
          warn(
            `app.config cannot be replaced. Modify individual options instead.`
          )
        }
      },

      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)) {
          installedPlugins.add(plugin)
          plugin.install(app, ...options)
        } else if (isFunction(plugin)) {
          installedPlugins.add(plugin)
          plugin(app, ...options)
        } else if (__DEV__) {
          warn(
            `A plugin must either be a function or an object with an "install" ` +
              `function.`
          )
        }
        return app
      },

      mixin(mixin: ComponentOptions) {
        if (__FEATURE_OPTIONS_API__) {
          if (!context.mixins.includes(mixin)) {
            context.mixins.push(mixin)
            // global mixin with props/emits de-optimizes props/emits
            // normalization caching.
            if (mixin.props || mixin.emits) {
              context.deopt = true
            }
          } else if (__DEV__) {
            warn(
              'Mixin has already been applied to target app' +
                (mixin.name ? `: ${mixin.name}` : '')
            )
          }
        } else if (__DEV__) {
          warn('Mixins are only available in builds supporting Options API')
        }
        return app
      },

      component(name: string, component?: Component): any {
        if (__DEV__) {
          validateComponentName(name, context.config)
        }
        if (!component) {
          return context.components[name]
        }
        if (__DEV__ && context.components[name]) {
          warn(`Component "${name}" has already been registered in target app.`)
        }
        context.components[name] = component
        return app
      },

      directive(name: string, directive?: Directive) {
        if (__DEV__) {
          validateDirectiveName(name)
        }

        if (!directive) {
          return context.directives[name] as any
        }
        if (__DEV__ && context.directives[name]) {
          warn(`Directive "${name}" has already been registered in target app.`)
        }
        context.directives[name] = directive
        return app
      },

      mount(rootContainer: HostElement, isHydrate?: 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)
            }
          }

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

          if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
            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__) {
            devtoolsUnmountApp(app)
          }
        } else if (__DEV__) {
          warn(`Cannot unmount an app that is not mounted.`)
        }
      },

      provide(key, value) {
        if (__DEV__ && (key as string | symbol) in context.provides) {
          warn(
            `App already provides property with key "${String(key)}". ` +
              `It will be overwritten with the new value.`
          )
        }
        // TypeScript doesn't allow symbols as index type
        // https://github.com/Microsoft/TypeScript/issues/24587
        context.provides[key as string] = value

        return app
      }
    })

    return app
  }
}

前面我们说到 vue3 与vue2有一个很大的区别在于,vue3引入了application的概念,在createAppAPI这里可以看到,返回的app就是一个application对象,这个对象实现了 集成注册插件,注册指令,注册组件等方法,实现都很简单,就是将这些资源的注册到app 的context中而不是像vue3那样注册到全局Vue中。

这个createAppAPI 里有一个mount方法 就是将application 挂载到某一个dom节点上,实现上就是利用之前的render 方法。

看到这里,或许你对vue3渲染器的API设计与实现已经有一部分了解了。接下来说一些有意思的点。

  1. render方法

在runtime-dom中 对渲染器的render方法是暴露给了开发者的。

// use explicit type casts here to avoid import() calls in rolled-up d.ts
export const render = ((...args) => {
  ensureRenderer().render(...args)
}) as RootRenderFunction<Element>

而这个render方法 的实现就是 渲染器里面的render方法,作用就是将vdom渲染到固定容器中。也就是说如下代码也是可以渲染出来的。

import { h } from 'vue'
import App from './App.vue'

render(h(App), document.getElementById('app'))

而实际使用过程,我们大都使用createApp创建一个application这种方式,原因是这种方式更加符合vue3的设计,将资源注册到application 会让代码更加模块化。

render方法在测试中会有很多应用

import { createStaticVNode, h, render } from '../src'

describe('static vnode handling', () => {
  const content = `<div>hello</div><p>world</p>`
  const content2 = `<p>foo</p><div>bar</div><span>baz</span>`

  const s = createStaticVNode(content, 2)
  const s2 = createStaticVNode(content2, 3)

  test('should mount from string', () => {
    const root = document.createElement('div')
    render(h('div', [s]), root)
    expect(root.innerHTML).toBe(`<div>${content}</div>`)
  })
})
  1. 如何实现一个渲染器

根据vue 的api设计,我们只需要传入一组 操作原生 内容的api,就可以得到一个针对特定平台的渲染器

import { createRenderer, h } from 'vue'
// 参考runtime-dom/src/nodeOps.ts
const options = {
    insert() {},
    remove() {},
    createElement() {},
    createText() {},
    createComment() {},
    setText() {},
    setElementText() {},
    parentNode() {},
    nextSibling() {},
    querySelector() {},
    setScopeId() {},
    cloneNode() {},
    insertStaticContent() {},
    patchProp() {},
    forcePatchProp() {}
}

let myRenderer
function ensureMyRenderer() {
    return myRenderer || (myRenderer = createRenderer(options))
}

function createMyRendererApp(...args) {
    const app = ensureMyRenderer().createApp(...args)

    // 重写mount
    const { mount } = app
    app.mount = (containerOrSelector) => {
        // ..
        const proxy = mount(container)
        return proxy
    }
}

createMyRendererApp(h(<App />), 'app')

Profile picture

Written by Colgin who lives and works in China, focus on web development. You can comment on github