feat(ssr): support custom directive getSSRProps in optimized compilation

close #5304
This commit is contained in:
Evan You 2022-02-04 08:58:28 +08:00
parent a51f935b72
commit 60cf175d88
14 changed files with 228 additions and 45 deletions

View File

@ -5,7 +5,8 @@ import {
ErrorCodes, ErrorCodes,
BindingTypes, BindingTypes,
NodeTransform, NodeTransform,
transformExpression transformExpression,
baseCompile
} from '../../src' } from '../../src'
import { import {
RESOLVE_COMPONENT, RESOLVE_COMPONENT,
@ -66,6 +67,7 @@ function parseWithBind(template: string, options?: CompilerOptions) {
return parseWithElementTransform(template, { return parseWithElementTransform(template, {
...options, ...options,
directiveTransforms: { directiveTransforms: {
...options?.directiveTransforms,
bind: transformBind bind: transformBind
} }
}) })
@ -932,7 +934,11 @@ describe('compiler: element transform', () => {
}) })
test('NEED_PATCH (vnode hooks)', () => { test('NEED_PATCH (vnode hooks)', () => {
const { node } = parseWithBind(`<div @vnodeUpdated="foo" />`) const root = baseCompile(`<div @vnodeUpdated="foo" />`, {
prefixIdentifiers: true,
cacheHandlers: true
}).ast
const node = (root as any).children[0].codegenNode
expect(node.patchFlag).toBe(genFlagText(PatchFlags.NEED_PATCH)) expect(node.patchFlag).toBe(genFlagText(PatchFlags.NEED_PATCH))
}) })

View File

@ -54,7 +54,9 @@ export {
export { export {
transformElement, transformElement,
resolveComponentType, resolveComponentType,
buildProps buildProps,
buildDirectiveArgs,
PropsExpression
} from './transforms/transformElement' } from './transforms/transformElement'
export { processSlotOutlet } from './transforms/transformSlotOutlet' export { processSlotOutlet } from './transforms/transformSlotOutlet'
export { generateCodeFrame } from '@vue/shared' export { generateCodeFrame } from '@vue/shared'

View File

@ -29,7 +29,8 @@ import {
isObject, isObject,
isReservedProp, isReservedProp,
capitalize, capitalize,
camelize camelize,
isBuiltInDirective
} from '@vue/shared' } from '@vue/shared'
import { createCompilerError, ErrorCodes } from '../errors' import { createCompilerError, ErrorCodes } from '../errors'
import { import {
@ -665,7 +666,7 @@ export function buildProps(
directiveImportMap.set(prop, needRuntime) directiveImportMap.set(prop, needRuntime)
} }
} }
} else { } else if (!isBuiltInDirective(name)) {
// no built-in transform, this is a user custom directive. // no built-in transform, this is a user custom directive.
runtimeDirectives.push(prop) runtimeDirectives.push(prop)
// custom dirs may use beforeUpdate so they need to force blocks // custom dirs may use beforeUpdate so they need to force blocks
@ -853,7 +854,7 @@ function mergeAsArray(existing: Property, incoming: Property) {
} }
} }
function buildDirectiveArgs( export function buildDirectiveArgs(
dir: DirectiveNode, dir: DirectiveNode,
context: TransformContext context: TransformContext
): ArrayExpression { ): ArrayExpression {

View File

@ -37,14 +37,11 @@ exports[`compiler: transform v-model input w/ dynamic v-bind 2`] = `
return function render(_ctx, _cache) { return function render(_ctx, _cache) {
with (_ctx) { with (_ctx) {
const { vModelDynamic: _vModelDynamic, resolveDirective: _resolveDirective, withDirectives: _withDirectives, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue const { vModelDynamic: _vModelDynamic, withDirectives: _withDirectives, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
const _directive_bind = _resolveDirective(\\"bind\\")
return _withDirectives((_openBlock(), _createElementBlock(\\"input\\", { return _withDirectives((_openBlock(), _createElementBlock(\\"input\\", {
\\"onUpdate:modelValue\\": $event => ((model) = $event) \\"onUpdate:modelValue\\": $event => ((model) = $event)
}, null, 8 /* PROPS */, [\\"onUpdate:modelValue\\"])), [ }, null, 8 /* PROPS */, [\\"onUpdate:modelValue\\"])), [
[_directive_bind, val, key],
[_vModelDynamic, model] [_vModelDynamic, model]
]) ])
} }
@ -152,14 +149,11 @@ exports[`compiler: transform v-model simple expression for input (dynamic type)
return function render(_ctx, _cache) { return function render(_ctx, _cache) {
with (_ctx) { with (_ctx) {
const { vModelDynamic: _vModelDynamic, resolveDirective: _resolveDirective, withDirectives: _withDirectives, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue const { vModelDynamic: _vModelDynamic, withDirectives: _withDirectives, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
const _directive_bind = _resolveDirective(\\"bind\\")
return _withDirectives((_openBlock(), _createElementBlock(\\"input\\", { return _withDirectives((_openBlock(), _createElementBlock(\\"input\\", {
\\"onUpdate:modelValue\\": $event => ((model) = $event) \\"onUpdate:modelValue\\": $event => ((model) = $event)
}, null, 8 /* PROPS */, [\\"onUpdate:modelValue\\"])), [ }, null, 8 /* PROPS */, [\\"onUpdate:modelValue\\"])), [
[_directive_bind, foo, \\"type\\"],
[_vModelDynamic, model] [_vModelDynamic, model]
]) ])
} }

View File

@ -377,4 +377,20 @@ describe('ssr: components', () => {
}) })
}) })
}) })
describe('custom directive', () => {
test('basic', () => {
expect(compile(`<foo v-xxx:x.y="z" />`).code).toMatchInlineSnapshot(`
"const { resolveComponent: _resolveComponent, resolveDirective: _resolveDirective, mergeProps: _mergeProps } = require(\\"vue\\")
const { ssrGetDirectiveProps: _ssrGetDirectiveProps, ssrRenderComponent: _ssrRenderComponent } = require(\\"vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent, _attrs) {
const _component_foo = _resolveComponent(\\"foo\\")
const _directive_xxx = _resolveDirective(\\"xxx\\")
_push(_ssrRenderComponent(_component_foo, _mergeProps(_attrs, _ssrGetDirectiveProps(_ctx, _directive_xxx, _ctx.z, \\"x\\", { y: true })), null, _parent))
}"
`)
})
})
}) })

View File

@ -288,5 +288,56 @@ describe('ssr: element', () => {
}></div>\`" }></div>\`"
`) `)
}) })
test('custom dir', () => {
expect(getCompiledString(`<div v-xxx:x.y="z" />`)).toMatchInlineSnapshot(`
"\`<div\${
_ssrRenderAttrs(_ssrGetDirectiveProps(_ctx, _directive_xxx, _ctx.z, \\"x\\", { y: true }))
}></div>\`"
`)
})
test('custom dir with normal attrs', () => {
expect(getCompiledString(`<div class="foo" v-xxx />`))
.toMatchInlineSnapshot(`
"\`<div\${
_ssrRenderAttrs(_mergeProps({ class: \\"foo\\" }, _ssrGetDirectiveProps(_ctx, _directive_xxx)))
}></div>\`"
`)
})
test('custom dir with v-bind', () => {
expect(getCompiledString(`<div :title="foo" :class="bar" v-xxx />`))
.toMatchInlineSnapshot(`
"\`<div\${
_ssrRenderAttrs(_mergeProps({
title: _ctx.foo,
class: _ctx.bar
}, _ssrGetDirectiveProps(_ctx, _directive_xxx)))
}></div>\`"
`)
})
test('custom dir with object v-bind', () => {
expect(getCompiledString(`<div v-bind="x" v-xxx />`))
.toMatchInlineSnapshot(`
"\`<div\${
_ssrRenderAttrs(_mergeProps(_ctx.x, _ssrGetDirectiveProps(_ctx, _directive_xxx)))
}></div>\`"
`)
})
test('custom dir with object v-bind + normal bindings', () => {
expect(
getCompiledString(`<div v-bind="x" class="foo" v-xxx title="bar" />`)
).toMatchInlineSnapshot(`
"\`<div\${
_ssrRenderAttrs(_mergeProps(_ctx.x, {
class: \\"foo\\",
title: \\"bar\\"
}, _ssrGetDirectiveProps(_ctx, _directive_xxx)))
}></div>\`"
`)
})
}) })
}) })

View File

@ -17,6 +17,7 @@ export const SSR_RENDER_DYNAMIC_MODEL = Symbol(`ssrRenderDynamicModel`)
export const SSR_GET_DYNAMIC_MODEL_PROPS = Symbol(`ssrGetDynamicModelProps`) export const SSR_GET_DYNAMIC_MODEL_PROPS = Symbol(`ssrGetDynamicModelProps`)
export const SSR_RENDER_TELEPORT = Symbol(`ssrRenderTeleport`) export const SSR_RENDER_TELEPORT = Symbol(`ssrRenderTeleport`)
export const SSR_RENDER_SUSPENSE = Symbol(`ssrRenderSuspense`) export const SSR_RENDER_SUSPENSE = Symbol(`ssrRenderSuspense`)
export const SSR_GET_DIRECTIVE_PROPS = Symbol(`ssrGetDirectiveProps`)
export const ssrHelpers = { export const ssrHelpers = {
[SSR_INTERPOLATE]: `ssrInterpolate`, [SSR_INTERPOLATE]: `ssrInterpolate`,
@ -35,7 +36,8 @@ export const ssrHelpers = {
[SSR_RENDER_DYNAMIC_MODEL]: `ssrRenderDynamicModel`, [SSR_RENDER_DYNAMIC_MODEL]: `ssrRenderDynamicModel`,
[SSR_GET_DYNAMIC_MODEL_PROPS]: `ssrGetDynamicModelProps`, [SSR_GET_DYNAMIC_MODEL_PROPS]: `ssrGetDynamicModelProps`,
[SSR_RENDER_TELEPORT]: `ssrRenderTeleport`, [SSR_RENDER_TELEPORT]: `ssrRenderTeleport`,
[SSR_RENDER_SUSPENSE]: `ssrRenderSuspense` [SSR_RENDER_SUSPENSE]: `ssrRenderSuspense`,
[SSR_GET_DIRECTIVE_PROPS]: `ssrGetDirectiveProps`
} }
// Note: these are helpers imported from @vue/server-renderer // Note: these are helpers imported from @vue/server-renderer

View File

@ -33,7 +33,8 @@ import {
TELEPORT, TELEPORT,
TRANSITION_GROUP, TRANSITION_GROUP,
CREATE_VNODE, CREATE_VNODE,
CallExpression CallExpression,
JSChildNode
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { SSR_RENDER_COMPONENT, SSR_RENDER_VNODE } from '../runtimeHelpers' import { SSR_RENDER_COMPONENT, SSR_RENDER_VNODE } from '../runtimeHelpers'
import { import {
@ -48,6 +49,7 @@ import {
} from './ssrTransformSuspense' } from './ssrTransformSuspense'
import { ssrProcessTransitionGroup } from './ssrTransformTransitionGroup' import { ssrProcessTransitionGroup } from './ssrTransformTransitionGroup'
import { isSymbol, isObject, isArray } from '@vue/shared' import { isSymbol, isObject, isArray } from '@vue/shared'
import { buildSSRProps } from './ssrTransformElement'
// We need to construct the slot functions in the 1st pass to ensure proper // We need to construct the slot functions in the 1st pass to ensure proper
// scope tracking, but the children of each slot cannot be processed until // scope tracking, but the children of each slot cannot be processed until
@ -110,12 +112,15 @@ export const ssrTransformComponent: NodeTransform = (node, context) => {
}) })
} }
const props = let propsExp: string | JSChildNode = `null`
node.props.length > 0 if (node.props.length) {
? // note we are not passing ssr: true here because for components, v-on // note we are not passing ssr: true here because for components, v-on
// handlers should still be passed // handlers should still be passed
buildProps(node, context).props || `null` const { props, directives } = buildProps(node, context)
: `null` if (props || directives.length) {
propsExp = buildSSRProps(props, directives, context)
}
}
const wipEntries: WIPSlotEntry[] = [] const wipEntries: WIPSlotEntry[] = []
wipMap.set(node, wipEntries) wipMap.set(node, wipEntries)
@ -151,7 +156,7 @@ export const ssrTransformComponent: NodeTransform = (node, context) => {
`_push`, `_push`,
createCallExpression(context.helper(CREATE_VNODE), [ createCallExpression(context.helper(CREATE_VNODE), [
component, component,
props, propsExp,
slots slots
]), ]),
`_parent` `_parent`
@ -160,7 +165,7 @@ export const ssrTransformComponent: NodeTransform = (node, context) => {
} else { } else {
node.ssrCodegenNode = createCallExpression( node.ssrCodegenNode = createCallExpression(
context.helper(SSR_RENDER_COMPONENT), context.helper(SSR_RENDER_COMPONENT),
[component, props, slots, `_parent`] [component, propsExp, slots, `_parent`]
) )
} }
} }

View File

@ -26,11 +26,15 @@ import {
createSequenceExpression, createSequenceExpression,
InterpolationNode, InterpolationNode,
isStaticExp, isStaticExp,
AttributeNode AttributeNode,
buildDirectiveArgs,
TransformContext,
PropsExpression
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { import {
escapeHtml, escapeHtml,
isBooleanAttr, isBooleanAttr,
isBuiltInDirective,
isSSRSafeAttrName, isSSRSafeAttrName,
NO, NO,
propsToAttrMap propsToAttrMap
@ -44,7 +48,8 @@ import {
SSR_RENDER_ATTRS, SSR_RENDER_ATTRS,
SSR_INTERPOLATE, SSR_INTERPOLATE,
SSR_GET_DYNAMIC_MODEL_PROPS, SSR_GET_DYNAMIC_MODEL_PROPS,
SSR_INCLUDE_BOOLEAN_ATTR SSR_INCLUDE_BOOLEAN_ATTR,
SSR_GET_DIRECTIVE_PROPS
} from '../runtimeHelpers' } from '../runtimeHelpers'
import { SSRTransformContext, processChildren } from '../ssrCodegenTransform' import { SSRTransformContext, processChildren } from '../ssrCodegenTransform'
@ -71,16 +76,26 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
const needTagForRuntime = const needTagForRuntime =
node.tag === 'textarea' || node.tag.indexOf('-') > 0 node.tag === 'textarea' || node.tag.indexOf('-') > 0
// v-bind="obj" or v-bind:[key] can potentially overwrite other static // v-bind="obj", v-bind:[key] and custom directives can potentially
// attrs and can affect final rendering result, so when they are present // overwrite other static attrs and can affect final rendering result,
// we need to bail out to full `renderAttrs` // so when they are present we need to bail out to full `renderAttrs`
const hasDynamicVBind = hasDynamicKeyVBind(node) const hasDynamicVBind = hasDynamicKeyVBind(node)
if (hasDynamicVBind) { const hasCustomDir = node.props.some(
const { props } = buildProps(node, context, node.props, true /* ssr */) p => p.type === NodeTypes.DIRECTIVE && !isBuiltInDirective(p.name)
if (props) { )
const needMergeProps = hasDynamicVBind || hasCustomDir
if (needMergeProps) {
const { props, directives } = buildProps(
node,
context,
node.props,
true /* ssr */
)
if (props || directives.length) {
const mergedProps = buildSSRProps(props, directives, context)
const propsExp = createCallExpression( const propsExp = createCallExpression(
context.helper(SSR_RENDER_ATTRS), context.helper(SSR_RENDER_ATTRS),
[props] [mergedProps]
) )
if (node.tag === 'textarea') { if (node.tag === 'textarea') {
@ -99,7 +114,7 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
propsExp.arguments = [ propsExp.arguments = [
createAssignmentExpression( createAssignmentExpression(
createSimpleExpression(tempId, false), createSimpleExpression(tempId, false),
props mergedProps
) )
] ]
rawChildrenMap.set( rawChildrenMap.set(
@ -128,7 +143,7 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
const tempExp = createSimpleExpression(tempId, false) const tempExp = createSimpleExpression(tempId, false)
propsExp.arguments = [ propsExp.arguments = [
createSequenceExpression([ createSequenceExpression([
createAssignmentExpression(tempExp, props), createAssignmentExpression(tempExp, mergedProps),
createCallExpression(context.helper(MERGE_PROPS), [ createCallExpression(context.helper(MERGE_PROPS), [
tempExp, tempExp,
createCallExpression( createCallExpression(
@ -176,10 +191,10 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
createCompilerError(ErrorCodes.X_V_SLOT_MISPLACED, prop.loc) createCompilerError(ErrorCodes.X_V_SLOT_MISPLACED, prop.loc)
) )
} else if (isTextareaWithValue(node, prop) && prop.exp) { } else if (isTextareaWithValue(node, prop) && prop.exp) {
if (!hasDynamicVBind) { if (!needMergeProps) {
node.children = [createInterpolation(prop.exp, prop.loc)] node.children = [createInterpolation(prop.exp, prop.loc)]
} }
} else if (!hasDynamicVBind) { } else if (!needMergeProps) {
// Directive transforms. // Directive transforms.
const directiveTransform = context.directiveTransforms[prop.name] const directiveTransform = context.directiveTransforms[prop.name]
if (directiveTransform) { if (directiveTransform) {
@ -277,7 +292,7 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
// special case: value on <textarea> // special case: value on <textarea>
if (node.tag === 'textarea' && prop.name === 'value' && prop.value) { if (node.tag === 'textarea' && prop.name === 'value' && prop.value) {
rawChildrenMap.set(node, escapeHtml(prop.value.content)) rawChildrenMap.set(node, escapeHtml(prop.value.content))
} else if (!hasDynamicVBind) { } else if (!needMergeProps) {
if (prop.name === 'key' || prop.name === 'ref') { if (prop.name === 'key' || prop.name === 'ref') {
continue continue
} }
@ -307,6 +322,37 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
} }
} }
export function buildSSRProps(
props: PropsExpression | undefined,
directives: DirectiveNode[],
context: TransformContext
): JSChildNode {
let mergePropsArgs: JSChildNode[] = []
if (props) {
if (props.type === NodeTypes.JS_CALL_EXPRESSION) {
// already a mergeProps call
mergePropsArgs = props.arguments as JSChildNode[]
} else {
mergePropsArgs.push(props)
}
}
if (directives.length) {
for (const dir of directives) {
context.directives.add(dir.name)
mergePropsArgs.push(
createCallExpression(context.helper(SSR_GET_DIRECTIVE_PROPS), [
`_ctx`,
...buildDirectiveArgs(dir, context).elements
] as JSChildNode[])
)
}
}
return mergePropsArgs.length > 1
? createCallExpression(context.helper(MERGE_PROPS), mergePropsArgs)
: mergePropsArgs[0]
}
function isTrueFalseValue(prop: DirectiveNode | AttributeNode) { function isTrueFalseValue(prop: DirectiveNode | AttributeNode) {
if (prop.type === NodeTypes.DIRECTIVE) { if (prop.type === NodeTypes.DIRECTIVE) {
return ( return (

View File

@ -12,7 +12,7 @@ return withDirectives(h(comp), [
*/ */
import { VNode } from './vnode' import { VNode } from './vnode'
import { isFunction, EMPTY_OBJ, makeMap } from '@vue/shared' import { isFunction, EMPTY_OBJ, isBuiltInDirective } from '@vue/shared'
import { warn } from './warning' import { warn } from './warning'
import { ComponentInternalInstance, Data } from './component' import { ComponentInternalInstance, Data } from './component'
import { currentRenderingInstance } from './componentRenderContext' import { currentRenderingInstance } from './componentRenderContext'
@ -63,10 +63,6 @@ export type Directive<T = any, V = any> =
export type DirectiveModifiers = Record<string, boolean> export type DirectiveModifiers = Record<string, boolean>
const isBuiltInDirective = /*#__PURE__*/ makeMap(
'bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo'
)
export function validateDirectiveName(name: string) { export function validateDirectiveName(name: string) {
if (isBuiltInDirective(name)) { if (isBuiltInDirective(name)) {
warn('Do not use built-in directive ids as custom directive id: ' + name) warn('Do not use built-in directive ids as custom directive id: ' + name)

View File

@ -10,8 +10,10 @@ import {
vShow, vShow,
vModelText, vModelText,
vModelRadio, vModelRadio,
vModelCheckbox vModelCheckbox,
resolveDirective
} from 'vue' } from 'vue'
import { ssrGetDirectiveProps, ssrRenderAttrs } from '../src'
describe('ssr: directives', () => { describe('ssr: directives', () => {
describe('template v-show', () => { describe('template v-show', () => {
@ -374,7 +376,7 @@ describe('ssr: directives', () => {
}) })
}) })
test('custom directive w/ getSSRProps', async () => { test('custom directive w/ getSSRProps (vdom)', async () => {
expect( expect(
await renderToString( await renderToString(
createApp({ createApp({
@ -394,4 +396,35 @@ describe('ssr: directives', () => {
) )
).toBe(`<div id="foo"></div>`) ).toBe(`<div id="foo"></div>`)
}) })
test('custom directive w/ getSSRProps (optimized)', async () => {
expect(
await renderToString(
createApp({
data() {
return {
x: 'foo'
}
},
directives: {
xxx: {
getSSRProps({ value, arg, modifiers }) {
return { id: [value, arg, modifiers.ok].join('-') }
}
}
},
ssrRender(_ctx, _push, _parent, _attrs) {
const _directive_xxx = resolveDirective('xxx')!
_push(
`<div${ssrRenderAttrs(
ssrGetDirectiveProps(_ctx, _directive_xxx, _ctx.x, 'arg', {
ok: true
})
)}></div>`
)
}
})
)
).toBe(`<div id="foo-arg-true"></div>`)
})
}) })

View File

@ -0,0 +1,26 @@
import { ComponentPublicInstance, Directive } from '@vue/runtime-core'
export function ssrGetDirectiveProps(
instance: ComponentPublicInstance,
dir: Directive,
value?: any,
arg?: string,
modifiers: Record<string, boolean> = {}
): Record<string, any> {
if (typeof dir !== 'function' && dir.getSSRProps) {
return (
dir.getSSRProps(
{
dir,
instance,
value,
oldValue: undefined,
arg,
modifiers
},
null as any
) || {}
)
}
return {}
}

View File

@ -30,6 +30,7 @@ export {
export { ssrInterpolate } from './helpers/ssrInterpolate' export { ssrInterpolate } from './helpers/ssrInterpolate'
export { ssrRenderList } from './helpers/ssrRenderList' export { ssrRenderList } from './helpers/ssrRenderList'
export { ssrRenderSuspense } from './helpers/ssrRenderSuspense' export { ssrRenderSuspense } from './helpers/ssrRenderSuspense'
export { ssrGetDirectiveProps } from './helpers/ssrGetDirectiveProps'
export { includeBooleanAttr as ssrIncludeBooleanAttr } from '@vue/shared' export { includeBooleanAttr as ssrIncludeBooleanAttr } from '@vue/shared'
// v-model helpers // v-model helpers

View File

@ -90,6 +90,10 @@ export const isReservedProp = /*#__PURE__*/ makeMap(
'onVnodeBeforeUnmount,onVnodeUnmounted' 'onVnodeBeforeUnmount,onVnodeUnmounted'
) )
export const isBuiltInDirective = /*#__PURE__*/ makeMap(
'bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo'
)
const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => { const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
const cache: Record<string, string> = Object.create(null) const cache: Record<string, string> = Object.create(null)
return ((str: string) => { return ((str: string) => {