wip(srr): slot outlet

This commit is contained in:
Evan You 2020-02-05 21:04:40 -05:00
parent 7a63103a11
commit 9b3b6962df
14 changed files with 263 additions and 120 deletions

View File

@ -147,11 +147,13 @@ export interface ComponentNode extends BaseElementNode {
| ComponentCodegenNode | ComponentCodegenNode
| CacheExpression // when cached by v-once | CacheExpression // when cached by v-once
| undefined | undefined
ssrCodegenNode?: CallExpression
} }
export interface SlotOutletNode extends BaseElementNode { export interface SlotOutletNode extends BaseElementNode {
tagType: ElementTypes.SLOT tagType: ElementTypes.SLOT
codegenNode: SlotOutletCodegenNode | undefined | CacheExpression // when cached by v-once codegenNode: SlotOutletCodegenNode | undefined | CacheExpression // when cached by v-once
ssrCodegenNode?: CallExpression
} }
export interface TemplateNode extends BaseElementNode { export interface TemplateNode extends BaseElementNode {

View File

@ -35,14 +35,15 @@ export { transformBind } from './transforms/vBind'
// exported for compiler-ssr // exported for compiler-ssr
export { MERGE_PROPS } from './runtimeHelpers' export { MERGE_PROPS } from './runtimeHelpers'
export { processIfBranches } from './transforms/vIf' export { processIf } from './transforms/vIf'
export { processForNode, createForLoopParams } from './transforms/vFor' export { processFor, createForLoopParams } from './transforms/vFor'
export { export {
transformExpression, transformExpression,
processExpression processExpression
} from './transforms/transformExpression' } from './transforms/transformExpression'
export { trackVForSlotScopes, trackSlotScopes } from './transforms/vSlot' export { trackVForSlotScopes, trackSlotScopes } from './transforms/vSlot'
export { buildProps } from './transforms/transformElement' export { buildProps } from './transforms/transformElement'
export { processSlotOutlet } from './transforms/transformSlotOutlet'
// utility, but need to rewrite typing to avoid dts relying on @vue/shared // utility, but need to rewrite typing to avoid dts relying on @vue/shared
import { generateCodeFrame as _genCodeFrame } from '@vue/shared' import { generateCodeFrame as _genCodeFrame } from '@vue/shared'

View File

@ -1,78 +1,32 @@
import { NodeTransform } from '../transform' import { NodeTransform, TransformContext } from '../transform'
import { import {
NodeTypes, NodeTypes,
CallExpression, CallExpression,
createCallExpression, createCallExpression,
ExpressionNode ExpressionNode,
SlotOutletNode
} from '../ast' } from '../ast'
import { isSlotOutlet } from '../utils' import { isSlotOutlet, findProp } from '../utils'
import { buildProps } from './transformElement' import { buildProps, PropsExpression } from './transformElement'
import { createCompilerError, ErrorCodes } from '../errors' import { createCompilerError, ErrorCodes } from '../errors'
import { RENDER_SLOT } from '../runtimeHelpers' import { RENDER_SLOT } from '../runtimeHelpers'
export const transformSlotOutlet: NodeTransform = (node, context) => { export const transformSlotOutlet: NodeTransform = (node, context) => {
if (isSlotOutlet(node)) { if (isSlotOutlet(node)) {
const { props, children, loc } = node const { children, loc } = node
const $slots = context.prefixIdentifiers ? `_ctx.$slots` : `$slots` const { slotName, slotProps } = processSlotOutlet(node, context)
let slotName: string | ExpressionNode = `"default"`
// check for <slot name="xxx" OR :name="xxx" /> const slotArgs: CallExpression['arguments'] = [
let nameIndex: number = -1 context.prefixIdentifiers ? `_ctx.$slots` : `$slots`,
for (let i = 0; i < props.length; i++) { slotName
const prop = props[i] ]
if (prop.type === NodeTypes.ATTRIBUTE) {
if (prop.name === `name` && prop.value) {
// static name="xxx"
slotName = JSON.stringify(prop.value.content)
nameIndex = i
break
}
} else if (prop.name === `bind`) {
const { arg, exp } = prop
if (
arg &&
exp &&
arg.type === NodeTypes.SIMPLE_EXPRESSION &&
arg.isStatic &&
arg.content === `name`
) {
// dynamic :name="xxx"
slotName = exp
nameIndex = i
break
}
}
}
const slotArgs: CallExpression['arguments'] = [$slots, slotName] if (slotProps) {
const propsWithoutName = slotArgs.push(slotProps)
nameIndex > -1
? props.slice(0, nameIndex).concat(props.slice(nameIndex + 1))
: props
let hasProps = propsWithoutName.length > 0
if (hasProps) {
const { props: propsExpression, directives } = buildProps(
node,
context,
propsWithoutName
)
if (directives.length) {
context.onError(
createCompilerError(
ErrorCodes.X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,
directives[0].loc
)
)
}
if (propsExpression) {
slotArgs.push(propsExpression)
} else {
hasProps = false
}
} }
if (children.length) { if (children.length) {
if (!hasProps) { if (!slotProps) {
slotArgs.push(`{}`) slotArgs.push(`{}`)
} }
slotArgs.push(children) slotArgs.push(children)
@ -85,3 +39,49 @@ export const transformSlotOutlet: NodeTransform = (node, context) => {
) )
} }
} }
interface SlotOutletProcessResult {
slotName: string | ExpressionNode
slotProps: PropsExpression | undefined
}
export function processSlotOutlet(
node: SlotOutletNode,
context: TransformContext
): SlotOutletProcessResult {
let slotName: string | ExpressionNode = `"default"`
let slotProps: PropsExpression | undefined = undefined
// check for <slot name="xxx" OR :name="xxx" />
const name = findProp(node, 'name')
if (name) {
if (name.type === NodeTypes.ATTRIBUTE && name.value) {
// static name
slotName = JSON.stringify(name.value.content)
} else if (name.type === NodeTypes.DIRECTIVE && name.exp) {
// dynamic name
slotName = name.exp
}
}
const propsWithoutName = name
? node.props.filter(p => p !== name)
: node.props
if (propsWithoutName.length > 0) {
const { props, directives } = buildProps(node, context, propsWithoutName)
slotProps = props
if (directives.length) {
context.onError(
createCompilerError(
ErrorCodes.X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,
directives[0].loc
)
)
}
}
return {
slotName,
slotProps
}
}

View File

@ -46,7 +46,7 @@ export const transformFor = createStructuralDirectiveTransform(
'for', 'for',
(node, dir, context) => { (node, dir, context) => {
const { helper } = context const { helper } = context
return processForNode(node, dir, context, forNode => { return processFor(node, dir, context, forNode => {
// create the loop render function expression now, and add the // create the loop render function expression now, and add the
// iterator on exit after all children have been traversed // iterator on exit after all children have been traversed
const renderExp = createCallExpression(helper(RENDER_LIST), [ const renderExp = createCallExpression(helper(RENDER_LIST), [
@ -138,7 +138,7 @@ export const transformFor = createStructuralDirectiveTransform(
) )
// target-agnostic transform used for both Client and SSR // target-agnostic transform used for both Client and SSR
export function processForNode( export function processFor(
node: ElementNode, node: ElementNode,
dir: DirectiveNode, dir: DirectiveNode,
context: TransformContext, context: TransformContext,

View File

@ -41,7 +41,7 @@ import { injectProp } from '../utils'
export const transformIf = createStructuralDirectiveTransform( export const transformIf = createStructuralDirectiveTransform(
/^(if|else|else-if)$/, /^(if|else|else-if)$/,
(node, dir, context) => { (node, dir, context) => {
return processIfBranches(node, dir, context, (ifNode, branch, isRoot) => { return processIf(node, dir, context, (ifNode, branch, isRoot) => {
// Exit callback. Complete the codegenNode when all children have been // Exit callback. Complete the codegenNode when all children have been
// transformed. // transformed.
return () => { return () => {
@ -72,7 +72,7 @@ export const transformIf = createStructuralDirectiveTransform(
) )
// target-agnostic transform used for both Client and SSR // target-agnostic transform used for both Client and SSR
export function processIfBranches( export function processIf(
node: ElementNode, node: ElementNode,
dir: DirectiveNode, dir: DirectiveNode,
context: TransformContext, context: TransformContext,

View File

@ -0,0 +1,60 @@
import { compile } from '../src'
describe('ssr: <slot>', () => {
test('basic', () => {
expect(compile(`<slot/>`).code).toMatchInlineSnapshot(`
"const { _renderSlot } = require(\\"@vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent) {
_renderSlot(_ctx.$slots, \\"default\\", {}, null, _push, _parent)
}"
`)
})
test('with name', () => {
expect(compile(`<slot name="foo" />`).code).toMatchInlineSnapshot(`
"const { _renderSlot } = require(\\"@vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent) {
_renderSlot(_ctx.$slots, \\"foo\\", {}, null, _push, _parent)
}"
`)
})
test('with dynamic name', () => {
expect(compile(`<slot :name="bar.baz" />`).code).toMatchInlineSnapshot(`
"const { _renderSlot } = require(\\"@vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent) {
_renderSlot(_ctx.$slots, _ctx.bar.baz, {}, null, _push, _parent)
}"
`)
})
test('with props', () => {
expect(compile(`<slot name="foo" :p="1" bar="2" />`).code)
.toMatchInlineSnapshot(`
"const { _renderSlot } = require(\\"@vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent) {
_renderSlot(_ctx.$slots, \\"foo\\", {
p: 1,
bar: \\"2\\"
}, null, _push, _parent)
}"
`)
})
test('with fallback', () => {
expect(compile(`<slot>some {{ fallback }} content</slot>`).code)
.toMatchInlineSnapshot(`
"const { _renderSlot, _interpolate } = require(\\"@vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent) {
_renderSlot(_ctx.$slots, \\"default\\", {}, () => {
_push(\`some \${_interpolate(_ctx.fallback)} content\`)
}, _push, _parent)
}"
`)
})
})

View File

@ -15,8 +15,9 @@ import {
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { isString, escapeHtml, NO } from '@vue/shared' import { isString, escapeHtml, NO } from '@vue/shared'
import { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers' import { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers'
import { processIf } from './transforms/ssrVIf' import { ssrProcessIf } from './transforms/ssrVIf'
import { processFor } from './transforms/ssrVFor' import { ssrProcessFor } from './transforms/ssrVFor'
import { ssrProcessSlotOutlet } from './transforms/ssrTransformSlotOutlet'
// Because SSR codegen output is completely different from client-side output // Because SSR codegen output is completely different from client-side output
// (e.g. multiple elements can be concatenated into a single template literal // (e.g. multiple elements can be concatenated into a single template literal
@ -119,7 +120,7 @@ export function processChildren(
} else if (child.tagType === ElementTypes.COMPONENT) { } else if (child.tagType === ElementTypes.COMPONENT) {
// TODO // TODO
} else if (child.tagType === ElementTypes.SLOT) { } else if (child.tagType === ElementTypes.SLOT) {
// TODO ssrProcessSlotOutlet(child, context)
} }
} else if (child.type === NodeTypes.TEXT) { } else if (child.type === NodeTypes.TEXT) {
context.pushStringPart(escapeHtml(child.content)) context.pushStringPart(escapeHtml(child.content))
@ -128,9 +129,9 @@ export function processChildren(
createCallExpression(context.helper(SSR_INTERPOLATE), [child.content]) createCallExpression(context.helper(SSR_INTERPOLATE), [child.content])
) )
} else if (child.type === NodeTypes.IF) { } else if (child.type === NodeTypes.IF) {
processIf(child, context) ssrProcessIf(child, context)
} else if (child.type === NodeTypes.FOR) { } else if (child.type === NodeTypes.FOR) {
processFor(child, context) ssrProcessFor(child, context)
} }
} }
} }

View File

@ -1,3 +1,49 @@
import { NodeTransform } from '@vue/compiler-dom' import {
NodeTransform,
isSlotOutlet,
processSlotOutlet,
createCallExpression,
SlotOutletNode,
createFunctionExpression,
createBlockStatement
} from '@vue/compiler-dom'
import { SSR_RENDER_SLOT } from '../runtimeHelpers'
import {
SSRTransformContext,
createChildContext,
processChildren
} from '../ssrCodegenTransform'
export const ssrTransformSlotOutlet: NodeTransform = () => {} export const ssrTransformSlotOutlet: NodeTransform = (node, context) => {
if (isSlotOutlet(node)) {
const { slotName, slotProps } = processSlotOutlet(node, context)
node.ssrCodegenNode = createCallExpression(
context.helper(SSR_RENDER_SLOT),
[
`_ctx.$slots`,
slotName,
slotProps || `{}`,
`null`, // fallback content placeholder.
`_push`,
`_parent`
]
)
}
}
export function ssrProcessSlotOutlet(
node: SlotOutletNode,
context: SSRTransformContext
) {
const renderCall = node.ssrCodegenNode!
// has fallback content
if (node.children.length) {
const childContext = createChildContext(context)
processChildren(node.children, childContext)
const fallbackRenderFn = createFunctionExpression([])
fallbackRenderFn.body = createBlockStatement(childContext.body)
// _renderSlot(slots, name, props, fallback, ...)
renderCall.arguments[3] = fallbackRenderFn
}
context.pushStatement(node.ssrCodegenNode!)
}

View File

@ -1,7 +1,7 @@
import { import {
createStructuralDirectiveTransform, createStructuralDirectiveTransform,
ForNode, ForNode,
processForNode, processFor,
createCallExpression, createCallExpression,
createFunctionExpression, createFunctionExpression,
createForLoopParams, createForLoopParams,
@ -18,12 +18,12 @@ import { SSR_RENDER_LIST } from '../runtimeHelpers'
// Plugin for the first transform pass, which simply constructs the AST node // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformFor = createStructuralDirectiveTransform( export const ssrTransformFor = createStructuralDirectiveTransform(
'for', 'for',
processForNode processFor
) )
// This is called during the 2nd transform pass to construct the SSR-sepcific // This is called during the 2nd transform pass to construct the SSR-sepcific
// codegen nodes. // codegen nodes.
export function processFor(node: ForNode, context: SSRTransformContext) { export function ssrProcessFor(node: ForNode, context: SSRTransformContext) {
const childContext = createChildContext(context) const childContext = createChildContext(context)
const needFragmentWrapper = const needFragmentWrapper =
node.children.length !== 1 || node.children[0].type !== NodeTypes.ELEMENT node.children.length !== 1 || node.children[0].type !== NodeTypes.ELEMENT

View File

@ -1,6 +1,6 @@
import { import {
createStructuralDirectiveTransform, createStructuralDirectiveTransform,
processIfBranches, processIf,
IfNode, IfNode,
createIfStatement, createIfStatement,
createBlockStatement, createBlockStatement,
@ -18,12 +18,12 @@ import {
// Plugin for the first transform pass, which simply constructs the AST node // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformIf = createStructuralDirectiveTransform( export const ssrTransformIf = createStructuralDirectiveTransform(
/^(if|else|else-if)$/, /^(if|else|else-if)$/,
processIfBranches processIf
) )
// This is called during the 2nd transform pass to construct the SSR-sepcific // This is called during the 2nd transform pass to construct the SSR-sepcific
// codegen nodes. // codegen nodes.
export function processIf(node: IfNode, context: SSRTransformContext) { export function ssrProcessIf(node: IfNode, context: SSRTransformContext) {
const [rootBranch] = node.branches const [rootBranch] = node.branches
const ifStatement = createIfStatement( const ifStatement = createIfStatement(
rootBranch.condition!, rootBranch.condition!,

View File

@ -7,11 +7,8 @@ import {
ComponentOptions ComponentOptions
} from 'vue' } from 'vue'
import { escapeHtml } from '@vue/shared' import { escapeHtml } from '@vue/shared'
import { import { renderToString, renderComponent } from '../src/renderToString'
renderToString, import { renderSlot } from '../src/helpers/renderSlot'
renderComponent,
renderSlot
} from '../src/renderToString'
describe('ssr: renderToString', () => { describe('ssr: renderToString', () => {
test('should apply app context', async () => { test('should apply app context', async () => {
@ -135,7 +132,16 @@ describe('ssr: renderToString', () => {
props: ['msg'], props: ['msg'],
ssrRender(ctx: any, push: any, parent: any) { ssrRender(ctx: any, push: any, parent: any) {
push(`<div class="child">`) push(`<div class="child">`)
renderSlot(ctx.$slots.default, { msg: 'from slot' }, push, parent) renderSlot(
ctx.$slots,
'default',
{ msg: 'from slot' },
() => {
push(`fallback`)
},
push,
parent
)
push(`</div>`) push(`</div>`)
} }
} }
@ -169,6 +175,19 @@ describe('ssr: renderToString', () => {
`<!----><span>from slot</span><!---->` + `<!----><span>from slot</span><!---->` +
`</div></div>` `</div></div>`
) )
// test fallback
expect(
await renderToString(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(renderComponent(Child, { msg: 'hello' }, null, parent))
push(`</div>`)
}
})
)
).toBe(`<div>parent<div class="child"><!---->fallback<!----></div></div>`)
}) })
test('nested components with vnode slots', async () => { test('nested components with vnode slots', async () => {
@ -176,7 +195,14 @@ describe('ssr: renderToString', () => {
props: ['msg'], props: ['msg'],
ssrRender(ctx: any, push: any, parent: any) { ssrRender(ctx: any, push: any, parent: any) {
push(`<div class="child">`) push(`<div class="child">`)
renderSlot(ctx.$slots.default, { msg: 'from slot' }, push, parent) renderSlot(
ctx.$slots,
'default',
{ msg: 'from slot' },
null,
push,
parent
)
push(`</div>`) push(`</div>`)
} }
} }

View File

@ -0,0 +1,35 @@
import { Props, PushFn, renderVNodeChildren } from '../renderToString'
import { ComponentInternalInstance, Slot, Slots } from 'vue'
export type SSRSlots = Record<string, SSRSlot>
export type SSRSlot = (
props: Props,
push: PushFn,
parentComponent: ComponentInternalInstance | null
) => void
export function renderSlot(
slots: Slots | SSRSlots,
slotName: string,
slotProps: Props,
fallbackRenderFn: (() => void) | null,
push: PushFn,
parentComponent: ComponentInternalInstance | null = null
) {
const slotFn = slots[slotName]
// template-compiled slots are always rendered as fragments
push(`<!---->`)
if (slotFn) {
if (slotFn.length > 1) {
// only ssr-optimized slot fns accept more than 1 arguments
slotFn(slotProps, push, parentComponent)
} else {
// normal slot
renderVNodeChildren(push, (slotFn as Slot)(slotProps), parentComponent)
}
} else if (fallbackRenderFn) {
fallbackRenderFn()
}
push(`<!---->`)
}

View File

@ -2,10 +2,8 @@
export { renderToString } from './renderToString' export { renderToString } from './renderToString'
// internal runtime helpers // internal runtime helpers
export { export { renderComponent as _renderComponent } from './renderToString'
renderComponent as _renderComponent, export { renderSlot as _renderSlot } from './helpers/renderSlot'
renderSlot as _renderSlot
} from './renderToString'
export { export {
renderClass as _renderClass, renderClass as _renderClass,
renderStyle as _renderStyle, renderStyle as _renderStyle,

View File

@ -11,7 +11,6 @@ import {
Portal, Portal,
ShapeFlags, ShapeFlags,
ssrUtils, ssrUtils,
Slot,
Slots Slots
} from 'vue' } from 'vue'
import { import {
@ -23,6 +22,7 @@ import {
escapeHtml escapeHtml
} from '@vue/shared' } from '@vue/shared'
import { renderAttrs } from './helpers/renderAttrs' import { renderAttrs } from './helpers/renderAttrs'
import { SSRSlots } from './helpers/renderSlot'
const { const {
isVNode, isVNode,
@ -41,8 +41,8 @@ const {
type SSRBuffer = SSRBufferItem[] type SSRBuffer = SSRBufferItem[]
type SSRBufferItem = string | ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> type SSRBufferItem = string | ResolvedSSRBuffer | Promise<ResolvedSSRBuffer>
type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[] type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[]
type PushFn = (item: SSRBufferItem) => void export type PushFn = (item: SSRBufferItem) => void
type Props = Record<string, unknown> export type Props = Record<string, unknown>
function createBuffer() { function createBuffer() {
let appendable = false let appendable = false
@ -191,7 +191,7 @@ function renderVNode(
} }
} }
function renderVNodeChildren( export function renderVNodeChildren(
push: PushFn, push: PushFn,
children: VNodeArrayChildren, children: VNodeArrayChildren,
parentComponent: ComponentInternalInstance | null = null parentComponent: ComponentInternalInstance | null = null
@ -255,29 +255,3 @@ function renderElement(
push(`</${tag}>`) push(`</${tag}>`)
} }
} }
export type SSRSlots = Record<string, SSRSlot>
export type SSRSlot = (
props: Props,
push: PushFn,
parentComponent: ComponentInternalInstance | null
) => void
export function renderSlot(
slotFn: Slot | SSRSlot,
slotProps: Props,
push: PushFn,
parentComponent: ComponentInternalInstance | null = null
) {
// template-compiled slots are always rendered as fragments
push(`<!---->`)
if (slotFn.length > 1) {
// only ssr-optimized slot fns accept more than 1 arguments
slotFn(slotProps, push, parentComponent)
} else {
// normal slot
renderVNodeChildren(push, (slotFn as Slot)(slotProps), parentComponent)
}
push(`<!---->`)
}