]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
refactor(runtime-vapor): simplify directive mechanism (#278)
authorKevin Deng 三咲智子 <sxzz@sxzz.moe>
Wed, 13 Nov 2024 00:41:02 +0000 (08:41 +0800)
committerGitHub <noreply@github.com>
Wed, 13 Nov 2024 00:41:02 +0000 (08:41 +0800)
* feat: custom directive v2

* wip

* fix: directive

* fix

* refactor

* refactor: remove ref for el

packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/apiRender.ts
packages/runtime-vapor/src/componentLifecycle.ts
packages/runtime-vapor/src/directives.ts
packages/runtime-vapor/src/directives/vModel.ts
packages/runtime-vapor/src/directives/vShow.ts
packages/runtime-vapor/src/index.ts

index dabeb27e75a684b0a282710d42f46b9dd863b4bd..8fcddf3174371332bf601fe051f61391cf138601 100644 (file)
@@ -405,7 +405,7 @@ function getItem(
   } else if (typeof source === 'number') {
     return [idx + 1, idx, undefined]
   } else if (isObject(source)) {
-    if (source && source[Symbol.iterator as any]) {
+    if (source[Symbol.iterator as any]) {
       source = Array.from(source as Iterable<any>)
       return [source[idx], idx, undefined]
     } else {
index db4eed5339da45e9807d78b34ee09a80ee6667bd..d691a48dc0c5d8837a7660054d6ec326bb07801b 100644 (file)
@@ -128,7 +128,7 @@ function mountComponent(
   }
 
   // hook: beforeMount
-  invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_MOUNT, 'beforeMount')
+  invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_MOUNT)
 
   insert(instance.block!, instance.container)
 
@@ -136,7 +136,6 @@ function mountComponent(
   invokeLifecycle(
     instance,
     VaporLifecycleHooks.MOUNTED,
-    'mounted',
     instance => (instance.isMounted = true),
     true,
   )
@@ -156,7 +155,7 @@ export function unmountComponent(instance: ComponentInternalInstance): void {
   const { container, scope } = instance
 
   // hook: beforeUnmount
-  invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_UNMOUNT, 'beforeUnmount')
+  invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_UNMOUNT)
 
   scope.stop()
   container.textContent = ''
@@ -165,7 +164,6 @@ export function unmountComponent(instance: ComponentInternalInstance): void {
   invokeLifecycle(
     instance,
     VaporLifecycleHooks.UNMOUNTED,
-    'unmounted',
     instance => queuePostFlushCb(() => (instance.isUnmounted = true)),
     true,
   )
index 41e9edb2e3701f519f58ebba24053647275f5ea1..4c2918c3eb76d43d6782a799a5baa26992067aac 100644 (file)
@@ -2,12 +2,10 @@ import { invokeArrayFns } from '@vue/shared'
 import type { VaporLifecycleHooks } from './enums'
 import { type ComponentInternalInstance, setCurrentInstance } from './component'
 import { queuePostFlushCb } from './scheduler'
-import type { DirectiveHookName } from './directives'
 
 export function invokeLifecycle(
   instance: ComponentInternalInstance,
   lifecycle: VaporLifecycleHooks,
-  directive: DirectiveHookName,
   cb?: (instance: ComponentInternalInstance) => void,
   post?: boolean,
 ): void {
@@ -27,8 +25,6 @@ export function invokeLifecycle(
   }
 
   function invokeSub() {
-    instance.comps.forEach(comp =>
-      invokeLifecycle(comp, lifecycle, directive, cb, post),
-    )
+    instance.comps.forEach(comp => invokeLifecycle(comp, lifecycle, cb, post))
   }
 }
index b6a09c322a9a8a934f7929d2b5fe69f00b052c3c..58eeb7a0480a2be79b5d70df4d4fdd1fc4557152 100644 (file)
@@ -1,54 +1,30 @@
 import { isBuiltInDirective } from '@vue/shared'
-import { type ComponentInternalInstance, currentInstance } from './component'
+import {
+  type ComponentInternalInstance,
+  currentInstance,
+  isVaporComponent,
+} from './component'
 import { warn } from './warning'
+import { normalizeBlock } from './dom/element'
+import { getCurrentScope } from '@vue/reactivity'
+import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
 
 export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
 
 export interface DirectiveBinding<T = any, V = any, M extends string = string> {
   instance: ComponentInternalInstance
-  source?: () => V
-  value: V
-  oldValue: V | null
+  source: () => V
   arg?: string
   modifiers?: DirectiveModifiers<M>
-  dir: ObjectDirective<T, V, M>
+  dir: Directive<T, V, M>
 }
 
 export type DirectiveBindingsMap = Map<Node, DirectiveBinding[]>
 
-export type DirectiveHook<
-  T = any | null,
-  V = any,
-  M extends string = string,
-> = (node: T, binding: DirectiveBinding<T, V, M>) => void
-
-// create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted`
-// effect update -> `beforeUpdate` -> node updated -> `updated`
-// `beforeUnmount`-> node unmount -> `unmounted`
-export type DirectiveHookName =
-  | 'created'
-  | 'beforeMount'
-  | 'mounted'
-  | 'beforeUpdate'
-  | 'updated'
-  | 'beforeUnmount'
-  | 'unmounted'
-export type ObjectDirective<T = any, V = any, M extends string = string> = {
-  [K in DirectiveHookName]?: DirectiveHook<T, V, M> | undefined
-} & {
-  /** Watch value deeply */
-  deep?: boolean | number
-}
-
-export type FunctionDirective<
-  T = any,
-  V = any,
-  M extends string = string,
-> = DirectiveHook<T, V, M>
-
-export type Directive<T = any, V = any, M extends string = string> =
-  | ObjectDirective<T, V, M>
-  | FunctionDirective<T, V, M>
+export type Directive<T = any, V = any, M extends string = string> = (
+  node: T,
+  binding: DirectiveBinding<T, V, M>,
+) => void
 
 export function validateDirectiveName(name: string): void {
   if (isBuiltInDirective(name)) {
@@ -77,7 +53,54 @@ export function withDirectives<T extends ComponentInternalInstance | Node>(
     return nodeOrComponent
   }
 
-  // NOOP
+  let node: Node
+  if (isVaporComponent(nodeOrComponent)) {
+    const root = getComponentNode(nodeOrComponent)
+    if (!root) return nodeOrComponent
+    node = root
+  } else {
+    node = nodeOrComponent
+  }
+
+  const instance = currentInstance!
+  const parentScope = getCurrentScope()
+
+  if (__DEV__ && !parentScope) {
+    warn(`Directives should be used inside of RenderEffectScope.`)
+  }
+
+  for (const directive of directives) {
+    let [dir, source = () => undefined, arg, modifiers] = directive
+    if (!dir) continue
+
+    const binding: DirectiveBinding = {
+      dir,
+      source,
+      instance,
+      arg,
+      modifiers,
+    }
+
+    callWithAsyncErrorHandling(dir, instance, VaporErrorCodes.DIRECTIVE_HOOK, [
+      node,
+      binding,
+    ])
+  }
 
   return nodeOrComponent
 }
+
+function getComponentNode(component: ComponentInternalInstance) {
+  if (!component.block) return
+
+  const nodes = normalizeBlock(component.block)
+  if (nodes.length !== 1) {
+    warn(
+      `Runtime directive used on component with non-element root node. ` +
+        `The directives will not function as intended.`,
+    )
+    return
+  }
+
+  return nodes[0]
+}
index b212437fd4565483ba93ad934dee739ddc70b0de..1c8ad5adf1ed2b6e785d36d071b98627036b8df7 100644 (file)
@@ -6,16 +6,18 @@ import {
   looseIndexOf,
   looseToNumber,
 } from '@vue/shared'
-import type {
-  DirectiveBinding,
-  DirectiveHook,
-  DirectiveHookName,
-  ObjectDirective,
-} from '../directives'
+import type { Directive } from '../directives'
 import { addEventListener } from '../dom/event'
 import { nextTick } from '../scheduler'
 import { warn } from '../warning'
 import { MetadataKind, getMetadata } from '../componentMetadata'
+import {
+  onBeforeMount,
+  onBeforeUnmount,
+  onBeforeUpdate,
+  onMounted,
+} from '../apiLifecycle'
+import { renderEffect } from '../renderEffect'
 
 type AssignerFn = (value: any) => void
 function getModelAssigner(el: Element): AssignerFn {
@@ -41,12 +43,12 @@ const assigningMap = new WeakMap<HTMLElement, boolean>()
 
 // We are exporting the v-model runtime directly as vnode hooks so that it can
 // be tree-shaken in case v-model is never used.
-export const vModelText: ObjectDirective<
+export const vModelText: Directive<
   HTMLInputElement | HTMLTextAreaElement,
   any,
   'lazy' | 'trim' | 'number'
-> = {
-  beforeMount(el, { modifiers: { lazy, trim, number } = {} }) {
+> = (el, { source, modifiers: { lazy, trim, number } = {} }) => {
+  onBeforeMount(() => {
     const assigner = getModelAssigner(el)
     assignFnMap.set(el, assigner)
 
@@ -78,12 +80,15 @@ export const vModelText: ObjectDirective<
       // fires "change" instead of "input" on autocomplete.
       addEventListener(el, 'change', onCompositionEnd)
     }
-  },
-  // set value on mounted so it's after min/max for type="range"
-  mounted(el, { value }) {
+  })
+
+  onMounted(() => {
+    const value = source()
     el.value = value == null ? '' : value
-  },
-  beforeUpdate(el, { value, modifiers: { lazy, trim, number } = {} }) {
+  })
+
+  renderEffect(() => {
+    const value = source()
     assignFnMap.set(el, getModelAssigner(el))
 
     // avoid clearing unresolved text. #2302
@@ -108,29 +113,31 @@ export const vModelText: ObjectDirective<
     }
 
     el.value = newValue
-  },
+  })
 }
 
-export const vModelRadio: ObjectDirective<HTMLInputElement> = {
-  beforeMount(el, { value }) {
-    el.checked = looseEqual(value, getValue(el))
+export const vModelRadio: Directive<HTMLInputElement> = (el, { source }) => {
+  onBeforeMount(() => {
+    el.checked = looseEqual(source(), getValue(el))
     assignFnMap.set(el, getModelAssigner(el))
     addEventListener(el, 'change', () => {
       assignFnMap.get(el)!(getValue(el))
     })
-  },
-  beforeUpdate(el, { value, oldValue }) {
+  })
+
+  renderEffect(() => {
+    const value = source()
     assignFnMap.set(el, getModelAssigner(el))
-    if (value !== oldValue) {
-      el.checked = looseEqual(value, getValue(el))
-    }
-  },
+    el.checked = looseEqual(value, getValue(el))
+  })
 }
 
-export const vModelSelect: ObjectDirective<HTMLSelectElement, any, 'number'> = {
-  // <select multiple> value need to be deep traversed
-  deep: true,
-  beforeMount(el, { value, modifiers: { number = false } = {} }) {
+export const vModelSelect: Directive<HTMLSelectElement, any, 'number'> = (
+  el,
+  { source, modifiers: { number = false } = {} },
+) => {
+  onBeforeMount(() => {
+    const value = source()
     const isSetModel = isSet(value)
     addEventListener(el, 'change', () => {
       const selectedVal = Array.prototype.filter
@@ -153,15 +160,17 @@ export const vModelSelect: ObjectDirective<HTMLSelectElement, any, 'number'> = {
     })
     assignFnMap.set(el, getModelAssigner(el))
     setSelected(el, value, number)
-  },
-  beforeUpdate(el) {
+  })
+
+  onBeforeUnmount(() => {
     assignFnMap.set(el, getModelAssigner(el))
-  },
-  updated(el, { value, modifiers: { number = false } = {} }) {
+  })
+
+  renderEffect(() => {
     if (!assigningMap.get(el)) {
-      setSelected(el, value, number)
+      setSelected(el, source(), number)
     }
-  },
+  })
 }
 
 function setSelected(el: HTMLSelectElement, value: any, number: boolean) {
@@ -223,27 +232,12 @@ function getCheckboxValue(el: HTMLInputElement, checked: boolean) {
   return checked
 }
 
-const setChecked: DirectiveHook<HTMLInputElement> = (
-  el,
-  { value, oldValue },
-) => {
-  if (isArray(value)) {
-    el.checked = looseIndexOf(value, getValue(el)) > -1
-  } else if (isSet(value)) {
-    el.checked = value.has(getValue(el))
-  } else if (value !== oldValue) {
-    el.checked = looseEqual(value, getCheckboxValue(el, true))
-  }
-}
-
-export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
-  // #4096 array checkboxes need to be deep traversed
-  deep: true,
-  beforeMount(el, binding) {
+export const vModelCheckbox: Directive<HTMLInputElement> = (el, { source }) => {
+  onBeforeMount(() => {
     assignFnMap.set(el, getModelAssigner(el))
 
     addEventListener(el, 'change', () => {
-      const modelValue = binding.value
+      const modelValue = source()
       const elementValue = getValue(el)
       const checked = el.checked
       const assigner = assignFnMap.get(el)!
@@ -269,36 +263,38 @@ export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
         assigner(getCheckboxValue(el, checked))
       }
     })
-  },
-  // set initial checked on mount to wait for true-value/false-value
-  mounted: setChecked,
-  beforeUpdate(el, binding) {
+  })
+
+  onMounted(() => {
+    setChecked()
+  })
+
+  onBeforeUpdate(() => {
     assignFnMap.set(el, getModelAssigner(el))
-    setChecked(el, binding)
-  },
+    setChecked()
+  })
+
+  function setChecked() {
+    const value = source()
+    if (isArray(value)) {
+      el.checked = looseIndexOf(value, getValue(el)) > -1
+    } else if (isSet(value)) {
+      el.checked = value.has(getValue(el))
+    } else {
+      el.checked = looseEqual(value, getCheckboxValue(el, true))
+    }
+  }
 }
 
-export const vModelDynamic: ObjectDirective<
+export const vModelDynamic: Directive<
   HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
-> = {
-  beforeMount(el, binding) {
-    callModelHook(el, binding, 'beforeMount')
-  },
-  mounted(el, binding) {
-    callModelHook(el, binding, 'mounted')
-  },
-  beforeUpdate(el, binding) {
-    callModelHook(el, binding, 'beforeUpdate')
-  },
-  updated(el, binding) {
-    callModelHook(el, binding, 'updated')
-  },
+> = (el, binding) => {
+  const type = el.getAttribute('type')
+  const modelToUse = resolveDynamicModel(el.tagName, type)
+  modelToUse(el, binding)
 }
 
-function resolveDynamicModel(
-  tagName: string,
-  type: string | null,
-): ObjectDirective {
+function resolveDynamicModel(tagName: string, type: string | null): Directive {
   switch (tagName) {
     case 'SELECT':
       return vModelSelect
@@ -315,14 +311,3 @@ function resolveDynamicModel(
       }
   }
 }
-
-function callModelHook(
-  el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
-  binding: DirectiveBinding,
-  hook: DirectiveHookName,
-) {
-  const type = el.getAttribute('type')
-  const modelToUse = resolveDynamicModel(el.tagName, type)
-  const fn = modelToUse[hook]
-  fn && fn(el, binding)
-}
index 80a22d5018e39a43f74cfeacac861e5e22cdfaf9..e320b74f0dd62c8ac4ac9441c17b9f03088f9e92 100644 (file)
@@ -1,23 +1,21 @@
-import type { ObjectDirective } from '../directives'
+import type { Directive } from '../directives'
+import { renderEffect } from '../renderEffect'
 
-const vShowMap = new WeakMap<HTMLElement, string>()
+export const vShowOriginalDisplay: unique symbol = Symbol('_vod')
+export const vShowHidden: unique symbol = Symbol('_vsh')
 
-export const vShow: ObjectDirective<HTMLElement> = {
-  beforeMount(node, { value }) {
-    vShowMap.set(node, node.style.display === 'none' ? '' : node.style.display)
-    setDisplay(node, value)
-  },
-
-  updated(node, { value, oldValue }) {
-    if (!value === !oldValue) return
-    setDisplay(node, value)
-  },
+export interface VShowElement extends HTMLElement {
+  // _vod = vue original display
+  [vShowOriginalDisplay]: string
+  [vShowHidden]: boolean
+}
 
-  beforeUnmount(node, { value }) {
-    setDisplay(node, value)
-  },
+export const vShow: Directive<VShowElement> = (el, { source }) => {
+  el[vShowOriginalDisplay] = el.style.display === 'none' ? '' : el.style.display
+  renderEffect(() => setDisplay(el, source()))
 }
 
-function setDisplay(el: HTMLElement, value: unknown): void {
-  el.style.display = value ? vShowMap.get(el)! : 'none'
+function setDisplay(el: VShowElement, value: unknown): void {
+  el.style.display = value ? el[vShowOriginalDisplay] : 'none'
+  el[vShowHidden] = !value
 }
index 1e49010f43debc1d2048f360dfe0d882e3dcd1e7..b7b7592f59511704eeaeccb05ed8d459b15889df 100644 (file)
@@ -76,9 +76,6 @@ export {
   withDirectives,
   type Directive,
   type DirectiveBinding,
-  type DirectiveHook,
-  type ObjectDirective,
-  type FunctionDirective,
   type DirectiveArguments,
   type DirectiveModifiers,
 } from './directives'