Browse Source

feat(runtime-vapor): resolve assets of components & directives (#214)

Doctor Wu 1 year ago
parent
commit
107569b922

+ 1 - 3
packages/runtime-core/src/component.ts

@@ -67,10 +67,10 @@ import {
   extend,
   getGlobalThis,
   isArray,
+  isBuiltInTag,
   isFunction,
   isObject,
   isPromise,
-  makeMap,
 } from '@vue/shared'
 import type { Data } from '@vue/runtime-shared'
 import type { SuspenseBoundary } from './components/Suspense'
@@ -761,8 +761,6 @@ export const unsetCurrentInstance = () => {
   internalSetCurrentInstance(null)
 }
 
-const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component')
-
 export function validateComponentName(
   name: string,
   { isNativeTag }: AppConfig,

+ 104 - 0
packages/runtime-vapor/__tests__/helpers/resolveAssets.spec.ts

@@ -0,0 +1,104 @@
+import {
+  type Component,
+  type Directive,
+  createVaporApp,
+  resolveComponent,
+  resolveDirective,
+} from '@vue/runtime-vapor'
+import { makeRender } from '../_utils'
+
+const define = makeRender()
+
+describe('resolveAssets', () => {
+  test('todo', () => {
+    expect(true).toBeTruthy()
+  })
+  test('should work', () => {
+    const FooBar = () => []
+    const BarBaz = { mounted: () => null }
+    let component1: Component | string
+    let component2: Component | string
+    let component3: Component | string
+    let component4: Component | string
+    let directive1: Directive
+    let directive2: Directive
+    let directive3: Directive
+    let directive4: Directive
+    const Root = define({
+      render() {
+        component1 = resolveComponent('FooBar')!
+        directive1 = resolveDirective('BarBaz')!
+        // camelize
+        component2 = resolveComponent('Foo-bar')!
+        directive2 = resolveDirective('Bar-baz')!
+        // capitalize
+        component3 = resolveComponent('fooBar')!
+        directive3 = resolveDirective('barBaz')!
+        // camelize and capitalize
+        component4 = resolveComponent('foo-bar')!
+        directive4 = resolveDirective('bar-baz')!
+        return []
+      },
+    })
+    const app = createVaporApp(Root.component)
+    app.component('FooBar', FooBar)
+    app.directive('BarBaz', BarBaz)
+    const root = document.createElement('div')
+    app.mount(root)
+    expect(component1!).toBe(FooBar)
+    expect(component2!).toBe(FooBar)
+    expect(component3!).toBe(FooBar)
+    expect(component4!).toBe(FooBar)
+    expect(directive1!).toBe(BarBaz)
+    expect(directive2!).toBe(BarBaz)
+    expect(directive3!).toBe(BarBaz)
+    expect(directive4!).toBe(BarBaz)
+  })
+  test('maybeSelfReference', async () => {
+    let component1: Component | string
+    let component2: Component | string
+    let component3: Component | string
+    const Foo = () => []
+    const Root = define({
+      name: 'Root',
+      render() {
+        component1 = resolveComponent('Root', true)
+        component2 = resolveComponent('Foo', true)
+        component3 = resolveComponent('Bar', true)
+        return []
+      },
+    })
+    const app = createVaporApp(Root.component)
+    app.component('Foo', Foo)
+    const root = document.createElement('div')
+    app.mount(root)
+    expect(component1!).toMatchObject(Root.component) // explicit self name reference
+    expect(component2!).toBe(Foo) // successful resolve take higher priority
+    expect(component3!).toMatchObject(Root.component) // fallback when resolve fails
+  })
+  describe('warning', () => {
+    test('used outside render() or setup()', () => {
+      resolveComponent('foo')
+      expect(
+        '[Vue warn]: resolveComponent can only be used in render() or setup().',
+      ).toHaveBeenWarned()
+      resolveDirective('foo')
+      expect(
+        '[Vue warn]: resolveDirective can only be used in render() or setup().',
+      ).toHaveBeenWarned()
+    })
+    test('not exist', () => {
+      const Root = define({
+        setup() {
+          resolveComponent('foo')
+          resolveDirective('bar')
+        },
+      })
+      const app = createVaporApp(Root.component)
+      const root = document.createElement('div')
+      app.mount(root)
+      expect('Failed to resolve component: foo').toHaveBeenWarned()
+      expect('Failed to resolve directive: bar').toHaveBeenWarned()
+    })
+  })
+})

+ 55 - 2
packages/runtime-vapor/src/apiCreateVaporApp.ts

@@ -1,14 +1,16 @@
-import { isFunction, isObject } from '@vue/shared'
+import { NO, isFunction, isObject } from '@vue/shared'
 import {
   type Component,
   type ComponentInternalInstance,
   createComponentInstance,
+  validateComponentName,
 } from './component'
 import { warn } from './warning'
-import { version } from '.'
+import { type Directive, version } from '.'
 import { render, setupComponent, unmountComponent } from './apiRender'
 import type { InjectionKey } from './apiInject'
 import type { RawProps } from './componentProps'
+import { validateDirectiveName } from './directives'
 
 export function createVaporApp(
   rootComponent: Component,
@@ -60,6 +62,35 @@ export function createVaporApp(
       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): any {
       if (!instance) {
         instance = createComponentInstance(
@@ -119,11 +150,14 @@ export function createAppContext(): AppContext {
   return {
     app: null as any,
     config: {
+      isNativeTag: NO,
       errorHandler: undefined,
       warnHandler: undefined,
       globalProperties: {},
     },
     provides: Object.create(null),
+    components: {},
+    directives: {},
   }
 }
 
@@ -151,6 +185,11 @@ export interface App {
   ): this
   use<Options>(plugin: Plugin<Options>, options: Options): this
 
+  component(name: string): Component | undefined
+  component<T extends Component>(name: string, component: T): this
+  directive<T = any, V = any>(name: string): Directive<T, V> | undefined
+  directive<T = any, V = any>(name: string, directive: Directive<T, V>): this
+
   mount(
     rootContainer: ParentNode | string,
     isHydrate?: boolean,
@@ -163,6 +202,9 @@ export interface App {
 }
 
 export interface AppConfig {
+  // @private
+  readonly isNativeTag: (tag: string) => boolean
+
   errorHandler?: (
     err: unknown,
     instance: ComponentInternalInstance | null,
@@ -180,6 +222,17 @@ export interface AppContext {
   app: App // for devtools
   config: AppConfig
   provides: Record<string | symbol, any>
+
+  /**
+   * Resolved component registry, only for components with mixins or extends
+   * @internal
+   */
+  components: Record<string, Component>
+  /**
+   * Resolved directive registry, only for components with mixins or extends
+   * @internal
+   */
+  directives: Record<string, Directive>
 }
 
 /**

+ 24 - 4
packages/runtime-vapor/src/component.ts

@@ -1,5 +1,11 @@
 import { isRef } from '@vue/reactivity'
-import { EMPTY_OBJ, hasOwn, isArray, isFunction } from '@vue/shared'
+import {
+  EMPTY_OBJ,
+  hasOwn,
+  isArray,
+  isBuiltInTag,
+  isFunction,
+} from '@vue/shared'
 import type { Block } from './apiRender'
 import {
   type ComponentPropsOptions,
@@ -24,7 +30,11 @@ import {
 } from './componentSlots'
 import { VaporLifecycleHooks } from './apiLifecycle'
 import { warn } from './warning'
-import { type AppContext, createAppContext } from './apiCreateVaporApp'
+import {
+  type AppConfig,
+  type AppContext,
+  createAppContext,
+} from './apiCreateVaporApp'
 import type { Data } from '@vue/runtime-shared'
 import { BlockEffectScope } from './blockEffectScope'
 
@@ -233,7 +243,6 @@ export interface ComponentInternalInstance {
   // [VaporLifecycleHooks.SERVER_PREFETCH]: LifecycleHook<() => Promise<unknown>>
 }
 
-// TODO
 export let currentInstance: ComponentInternalInstance | null = null
 
 export const getCurrentInstance: () => ComponentInternalInstance | null = () =>
@@ -256,7 +265,7 @@ const emptyAppContext = createAppContext()
 
 let uid = 0
 export function createComponentInstance(
-  component: ObjectComponent | FunctionalComponent,
+  component: Component,
   rawProps: RawProps | null,
   slots: Slots | null,
   dynamicSlots: DynamicSlots | null,
@@ -367,6 +376,17 @@ export function isVaporComponent(
   return !!val && hasOwn(val, componentKey)
 }
 
+export function validateComponentName(
+  name: string,
+  { isNativeTag }: AppConfig,
+) {
+  if (isBuiltInTag(name) || isNativeTag(name)) {
+    warn(
+      'Do not use built-in or reserved HTML elements as component id: ' + name,
+    )
+  }
+}
+
 function getAttrsProxy(instance: ComponentInternalInstance): Data {
   return (
     instance.attrsProxy ||

+ 7 - 1
packages/runtime-vapor/src/directives.ts

@@ -1,4 +1,4 @@
-import { invokeArrayFns, isFunction } from '@vue/shared'
+import { invokeArrayFns, isBuiltInDirective, isFunction } from '@vue/shared'
 import {
   type ComponentInternalInstance,
   currentInstance,
@@ -72,6 +72,12 @@ export type Directive<T = any, V = any, M extends string = string> =
   | ObjectDirective<T, V, M>
   | FunctionDirective<T, V, M>
 
+export function validateDirectiveName(name: string) {
+  if (isBuiltInDirective(name)) {
+    warn('Do not use built-in directive ids as custom directive id: ' + name)
+  }
+}
+
 export type DirectiveArguments = Array<
   | [Directive | undefined]
   | [Directive | undefined, () => any]

+ 92 - 4
packages/runtime-vapor/src/helpers/resolveAssets.ts

@@ -1,7 +1,95 @@
-export function resolveComponent() {
-  // TODO
+import { camelize, capitalize } from '@vue/shared'
+import { type Directive, warn } from '..'
+import { type Component, currentInstance } from '../component'
+import { getComponentName } from '../warning'
+
+export const COMPONENTS = 'components'
+export const DIRECTIVES = 'directives'
+
+export type AssetTypes = typeof COMPONENTS | typeof DIRECTIVES
+
+export function resolveComponent(name: string, maybeSelfReference?: boolean) {
+  return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name
+}
+
+export function resolveDirective(name: string) {
+  return resolveAsset(DIRECTIVES, name)
+}
+
+/**
+ * @private
+ * overload 1: components
+ */
+function resolveAsset(
+  type: typeof COMPONENTS,
+  name: string,
+  warnMissing?: boolean,
+  maybeSelfReference?: boolean,
+): Component | undefined
+// overload 2: directives
+function resolveAsset(
+  type: typeof DIRECTIVES,
+  name: string,
+): Directive | undefined
+// implementation
+function resolveAsset(
+  type: AssetTypes,
+  name: string,
+  warnMissing = true,
+  maybeSelfReference = false,
+) {
+  const instance = currentInstance
+  if (instance) {
+    const Component = instance.component
+
+    // explicit self name has highest priority
+    if (type === COMPONENTS) {
+      const selfName = getComponentName(
+        Component,
+        false /* do not include inferred name to avoid breaking existing code */,
+      )
+      if (
+        selfName &&
+        (selfName === name ||
+          selfName === camelize(name) ||
+          selfName === capitalize(camelize(name)))
+      ) {
+        return Component
+      }
+    }
+
+    const res =
+      // global registration
+      resolve(instance.appContext[type], name)
+
+    if (!res && maybeSelfReference) {
+      // fallback to implicit self-reference
+      return Component
+    }
+
+    if (__DEV__ && warnMissing && !res) {
+      const extra =
+        type === COMPONENTS
+          ? `\nIf this is a native custom element, make sure to exclude it from ` +
+            `component resolution via compilerOptions.isCustomElement.`
+          : ``
+      warn(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`)
+    }
+
+    return res
+  } else if (__DEV__) {
+    warn(
+      `resolve${capitalize(type.slice(0, -1))} ` +
+        `can only be used in render() or setup().`,
+    )
+  }
 }
 
-export function resolveDirective() {
-  // TODO
+function resolve(registry: Record<string, any> | undefined, name: string) {
+  return (
+    registry &&
+    (registry[name] ||
+      registry[camelize(name)] ||
+      registry[capitalize(camelize(name))])
+  )
 }

+ 2 - 0
packages/shared/src/general.ts

@@ -93,6 +93,8 @@ export const isReservedProp = /*#__PURE__*/ makeMap(
     'onVnodeBeforeUnmount,onVnodeUnmounted',
 )
 
+export const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component')
+
 export const isBuiltInDirective = /*#__PURE__*/ makeMap(
   'bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo',
 )