refactor(runtime-vapor): simplify directive mechanism (#278)

* feat: custom directive v2

* wip

* fix: directive

* fix

* refactor

* refactor: remove ref for el
This commit is contained in:
Kevin Deng 三咲智子 2024-11-13 08:41:02 +08:00 committed by GitHub
parent eed7d1d4fd
commit c574faa8f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 154 additions and 157 deletions

View File

@ -405,7 +405,7 @@ function getItem(
} else if (typeof source === 'number') { } else if (typeof source === 'number') {
return [idx + 1, idx, undefined] return [idx + 1, idx, undefined]
} else if (isObject(source)) { } else if (isObject(source)) {
if (source && source[Symbol.iterator as any]) { if (source[Symbol.iterator as any]) {
source = Array.from(source as Iterable<any>) source = Array.from(source as Iterable<any>)
return [source[idx], idx, undefined] return [source[idx], idx, undefined]
} else { } else {

View File

@ -128,7 +128,7 @@ function mountComponent(
} }
// hook: beforeMount // hook: beforeMount
invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_MOUNT, 'beforeMount') invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_MOUNT)
insert(instance.block!, instance.container) insert(instance.block!, instance.container)
@ -136,7 +136,6 @@ function mountComponent(
invokeLifecycle( invokeLifecycle(
instance, instance,
VaporLifecycleHooks.MOUNTED, VaporLifecycleHooks.MOUNTED,
'mounted',
instance => (instance.isMounted = true), instance => (instance.isMounted = true),
true, true,
) )
@ -156,7 +155,7 @@ export function unmountComponent(instance: ComponentInternalInstance): void {
const { container, scope } = instance const { container, scope } = instance
// hook: beforeUnmount // hook: beforeUnmount
invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_UNMOUNT, 'beforeUnmount') invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_UNMOUNT)
scope.stop() scope.stop()
container.textContent = '' container.textContent = ''
@ -165,7 +164,6 @@ export function unmountComponent(instance: ComponentInternalInstance): void {
invokeLifecycle( invokeLifecycle(
instance, instance,
VaporLifecycleHooks.UNMOUNTED, VaporLifecycleHooks.UNMOUNTED,
'unmounted',
instance => queuePostFlushCb(() => (instance.isUnmounted = true)), instance => queuePostFlushCb(() => (instance.isUnmounted = true)),
true, true,
) )

View File

@ -2,12 +2,10 @@ import { invokeArrayFns } from '@vue/shared'
import type { VaporLifecycleHooks } from './enums' import type { VaporLifecycleHooks } from './enums'
import { type ComponentInternalInstance, setCurrentInstance } from './component' import { type ComponentInternalInstance, setCurrentInstance } from './component'
import { queuePostFlushCb } from './scheduler' import { queuePostFlushCb } from './scheduler'
import type { DirectiveHookName } from './directives'
export function invokeLifecycle( export function invokeLifecycle(
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
lifecycle: VaporLifecycleHooks, lifecycle: VaporLifecycleHooks,
directive: DirectiveHookName,
cb?: (instance: ComponentInternalInstance) => void, cb?: (instance: ComponentInternalInstance) => void,
post?: boolean, post?: boolean,
): void { ): void {
@ -27,8 +25,6 @@ export function invokeLifecycle(
} }
function invokeSub() { function invokeSub() {
instance.comps.forEach(comp => instance.comps.forEach(comp => invokeLifecycle(comp, lifecycle, cb, post))
invokeLifecycle(comp, lifecycle, directive, cb, post),
)
} }
} }

View File

@ -1,54 +1,30 @@
import { isBuiltInDirective } from '@vue/shared' import { isBuiltInDirective } from '@vue/shared'
import { type ComponentInternalInstance, currentInstance } from './component' import {
type ComponentInternalInstance,
currentInstance,
isVaporComponent,
} from './component'
import { warn } from './warning' 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 type DirectiveModifiers<M extends string = string> = Record<M, boolean>
export interface DirectiveBinding<T = any, V = any, M extends string = string> { export interface DirectiveBinding<T = any, V = any, M extends string = string> {
instance: ComponentInternalInstance instance: ComponentInternalInstance
source?: () => V source: () => V
value: V
oldValue: V | null
arg?: string arg?: string
modifiers?: DirectiveModifiers<M> modifiers?: DirectiveModifiers<M>
dir: ObjectDirective<T, V, M> dir: Directive<T, V, M>
} }
export type DirectiveBindingsMap = Map<Node, DirectiveBinding[]> export type DirectiveBindingsMap = Map<Node, DirectiveBinding[]>
export type DirectiveHook< export type Directive<T = any, V = any, M extends string = string> = (
T = any | null, node: T,
V = any, binding: DirectiveBinding<T, V, M>,
M extends string = string, ) => void
> = (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 function validateDirectiveName(name: string): void { export function validateDirectiveName(name: string): void {
if (isBuiltInDirective(name)) { if (isBuiltInDirective(name)) {
@ -77,7 +53,54 @@ export function withDirectives<T extends ComponentInternalInstance | Node>(
return nodeOrComponent 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 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]
}

View File

@ -6,16 +6,18 @@ import {
looseIndexOf, looseIndexOf,
looseToNumber, looseToNumber,
} from '@vue/shared' } from '@vue/shared'
import type { import type { Directive } from '../directives'
DirectiveBinding,
DirectiveHook,
DirectiveHookName,
ObjectDirective,
} from '../directives'
import { addEventListener } from '../dom/event' import { addEventListener } from '../dom/event'
import { nextTick } from '../scheduler' import { nextTick } from '../scheduler'
import { warn } from '../warning' import { warn } from '../warning'
import { MetadataKind, getMetadata } from '../componentMetadata' import { MetadataKind, getMetadata } from '../componentMetadata'
import {
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
} from '../apiLifecycle'
import { renderEffect } from '../renderEffect'
type AssignerFn = (value: any) => void type AssignerFn = (value: any) => void
function getModelAssigner(el: Element): AssignerFn { 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 // 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. // be tree-shaken in case v-model is never used.
export const vModelText: ObjectDirective< export const vModelText: Directive<
HTMLInputElement | HTMLTextAreaElement, HTMLInputElement | HTMLTextAreaElement,
any, any,
'lazy' | 'trim' | 'number' 'lazy' | 'trim' | 'number'
> = { > = (el, { source, modifiers: { lazy, trim, number } = {} }) => {
beforeMount(el, { modifiers: { lazy, trim, number } = {} }) { onBeforeMount(() => {
const assigner = getModelAssigner(el) const assigner = getModelAssigner(el)
assignFnMap.set(el, assigner) assignFnMap.set(el, assigner)
@ -78,12 +80,15 @@ export const vModelText: ObjectDirective<
// fires "change" instead of "input" on autocomplete. // fires "change" instead of "input" on autocomplete.
addEventListener(el, 'change', onCompositionEnd) 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 el.value = value == null ? '' : value
}, })
beforeUpdate(el, { value, modifiers: { lazy, trim, number } = {} }) {
renderEffect(() => {
const value = source()
assignFnMap.set(el, getModelAssigner(el)) assignFnMap.set(el, getModelAssigner(el))
// avoid clearing unresolved text. #2302 // avoid clearing unresolved text. #2302
@ -108,29 +113,31 @@ export const vModelText: ObjectDirective<
} }
el.value = newValue el.value = newValue
}, })
} }
export const vModelRadio: ObjectDirective<HTMLInputElement> = { export const vModelRadio: Directive<HTMLInputElement> = (el, { source }) => {
beforeMount(el, { value }) { onBeforeMount(() => {
el.checked = looseEqual(value, getValue(el)) el.checked = looseEqual(source(), getValue(el))
assignFnMap.set(el, getModelAssigner(el)) assignFnMap.set(el, getModelAssigner(el))
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
assignFnMap.get(el)!(getValue(el)) assignFnMap.get(el)!(getValue(el))
}) })
}, })
beforeUpdate(el, { value, oldValue }) {
renderEffect(() => {
const value = source()
assignFnMap.set(el, getModelAssigner(el)) 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'> = { export const vModelSelect: Directive<HTMLSelectElement, any, 'number'> = (
// <select multiple> value need to be deep traversed el,
deep: true, { source, modifiers: { number = false } = {} },
beforeMount(el, { value, modifiers: { number = false } = {} }) { ) => {
onBeforeMount(() => {
const value = source()
const isSetModel = isSet(value) const isSetModel = isSet(value)
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
const selectedVal = Array.prototype.filter const selectedVal = Array.prototype.filter
@ -153,15 +160,17 @@ export const vModelSelect: ObjectDirective<HTMLSelectElement, any, 'number'> = {
}) })
assignFnMap.set(el, getModelAssigner(el)) assignFnMap.set(el, getModelAssigner(el))
setSelected(el, value, number) setSelected(el, value, number)
}, })
beforeUpdate(el) {
onBeforeUnmount(() => {
assignFnMap.set(el, getModelAssigner(el)) assignFnMap.set(el, getModelAssigner(el))
}, })
updated(el, { value, modifiers: { number = false } = {} }) {
renderEffect(() => {
if (!assigningMap.get(el)) { if (!assigningMap.get(el)) {
setSelected(el, value, number) setSelected(el, source(), number)
} }
}, })
} }
function setSelected(el: HTMLSelectElement, value: any, number: boolean) { function setSelected(el: HTMLSelectElement, value: any, number: boolean) {
@ -223,27 +232,12 @@ function getCheckboxValue(el: HTMLInputElement, checked: boolean) {
return checked return checked
} }
const setChecked: DirectiveHook<HTMLInputElement> = ( export const vModelCheckbox: Directive<HTMLInputElement> = (el, { source }) => {
el, onBeforeMount(() => {
{ 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) {
assignFnMap.set(el, getModelAssigner(el)) assignFnMap.set(el, getModelAssigner(el))
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
const modelValue = binding.value const modelValue = source()
const elementValue = getValue(el) const elementValue = getValue(el)
const checked = el.checked const checked = el.checked
const assigner = assignFnMap.get(el)! const assigner = assignFnMap.get(el)!
@ -269,36 +263,38 @@ export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
assigner(getCheckboxValue(el, checked)) assigner(getCheckboxValue(el, checked))
} }
}) })
}, })
// set initial checked on mount to wait for true-value/false-value
mounted: setChecked, onMounted(() => {
beforeUpdate(el, binding) { setChecked()
})
onBeforeUpdate(() => {
assignFnMap.set(el, getModelAssigner(el)) 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 HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
> = { > = (el, binding) => {
beforeMount(el, binding) { const type = el.getAttribute('type')
callModelHook(el, binding, 'beforeMount') const modelToUse = resolveDynamicModel(el.tagName, type)
}, modelToUse(el, binding)
mounted(el, binding) {
callModelHook(el, binding, 'mounted')
},
beforeUpdate(el, binding) {
callModelHook(el, binding, 'beforeUpdate')
},
updated(el, binding) {
callModelHook(el, binding, 'updated')
},
} }
function resolveDynamicModel( function resolveDynamicModel(tagName: string, type: string | null): Directive {
tagName: string,
type: string | null,
): ObjectDirective {
switch (tagName) { switch (tagName) {
case 'SELECT': case 'SELECT':
return vModelSelect 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)
}

View 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> = { export interface VShowElement extends HTMLElement {
beforeMount(node, { value }) { // _vod = vue original display
vShowMap.set(node, node.style.display === 'none' ? '' : node.style.display) [vShowOriginalDisplay]: string
setDisplay(node, value) [vShowHidden]: boolean
},
updated(node, { value, oldValue }) {
if (!value === !oldValue) return
setDisplay(node, value)
},
beforeUnmount(node, { value }) {
setDisplay(node, value)
},
} }
function setDisplay(el: HTMLElement, value: unknown): void { export const vShow: Directive<VShowElement> = (el, { source }) => {
el.style.display = value ? vShowMap.get(el)! : 'none' el[vShowOriginalDisplay] = el.style.display === 'none' ? '' : el.style.display
renderEffect(() => setDisplay(el, source()))
}
function setDisplay(el: VShowElement, value: unknown): void {
el.style.display = value ? el[vShowOriginalDisplay] : 'none'
el[vShowHidden] = !value
} }

View File

@ -76,9 +76,6 @@ export {
withDirectives, withDirectives,
type Directive, type Directive,
type DirectiveBinding, type DirectiveBinding,
type DirectiveHook,
type ObjectDirective,
type FunctionDirective,
type DirectiveArguments, type DirectiveArguments,
type DirectiveModifiers, type DirectiveModifiers,
} from './directives' } from './directives'