feat(ssr): render portals (#714)
This commit is contained in:
parent
aa09f01a1e
commit
e495fa4a18
@ -5,11 +5,16 @@ import {
|
|||||||
withScopeId,
|
withScopeId,
|
||||||
resolveComponent,
|
resolveComponent,
|
||||||
ComponentOptions,
|
ComponentOptions,
|
||||||
|
Portal,
|
||||||
ref,
|
ref,
|
||||||
defineComponent
|
defineComponent
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { escapeHtml, mockWarn } from '@vue/shared'
|
import { escapeHtml, mockWarn } from '@vue/shared'
|
||||||
import { renderToString, renderComponent } from '../src/renderToString'
|
import {
|
||||||
|
renderToString,
|
||||||
|
renderComponent,
|
||||||
|
SSRContext
|
||||||
|
} from '../src/renderToString'
|
||||||
import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot'
|
import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot'
|
||||||
|
|
||||||
mockWarn()
|
mockWarn()
|
||||||
@ -508,6 +513,21 @@ describe('ssr: renderToString', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('portal', async () => {
|
||||||
|
const ctx: SSRContext = {}
|
||||||
|
await renderToString(
|
||||||
|
h(
|
||||||
|
Portal,
|
||||||
|
{
|
||||||
|
target: `#target`
|
||||||
|
},
|
||||||
|
h('span', 'hello')
|
||||||
|
),
|
||||||
|
ctx
|
||||||
|
)
|
||||||
|
expect(ctx.portals!['#target']).toBe('<span>hello</span>')
|
||||||
|
})
|
||||||
|
|
||||||
describe('scopeId', () => {
|
describe('scopeId', () => {
|
||||||
// note: here we are only testing scopeId handling for vdom serialization.
|
// note: here we are only testing scopeId handling for vdom serialization.
|
||||||
// compiled srr render functions will include scopeId directly in strings.
|
// compiled srr render functions will include scopeId directly in strings.
|
||||||
|
@ -16,7 +16,7 @@ export function ssrRenderSlot(
|
|||||||
slotProps: Props,
|
slotProps: Props,
|
||||||
fallbackRenderFn: (() => void) | null,
|
fallbackRenderFn: (() => void) | null,
|
||||||
push: PushFn,
|
push: PushFn,
|
||||||
parentComponent: ComponentInternalInstance | null = null
|
parentComponent: ComponentInternalInstance
|
||||||
) {
|
) {
|
||||||
const slotFn = slots[slotName]
|
const slotFn = slots[slotName]
|
||||||
// template-compiled slots are always rendered as fragments
|
// template-compiled slots are always rendered as fragments
|
||||||
|
@ -11,7 +11,8 @@ import {
|
|||||||
Portal,
|
Portal,
|
||||||
ssrUtils,
|
ssrUtils,
|
||||||
Slots,
|
Slots,
|
||||||
warn
|
warn,
|
||||||
|
createApp
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
ShapeFlags,
|
ShapeFlags,
|
||||||
@ -47,9 +48,22 @@ 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)[]
|
||||||
|
|
||||||
export type PushFn = (item: SSRBufferItem) => void
|
export type PushFn = (item: SSRBufferItem) => void
|
||||||
|
|
||||||
export type Props = Record<string, unknown>
|
export type Props = Record<string, unknown>
|
||||||
|
|
||||||
|
const ssrContextKey = Symbol()
|
||||||
|
|
||||||
|
export type SSRContext = {
|
||||||
|
[key: string]: any
|
||||||
|
portals?: Record<string, string>
|
||||||
|
__portalBuffers?: Record<
|
||||||
|
string,
|
||||||
|
ResolvedSSRBuffer | Promise<ResolvedSSRBuffer>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
function createBuffer() {
|
function createBuffer() {
|
||||||
let appendable = false
|
let appendable = false
|
||||||
let hasAsync = false
|
let hasAsync = false
|
||||||
@ -88,17 +102,33 @@ function unrollBuffer(buffer: ResolvedSSRBuffer): string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderToString(input: App | VNode): Promise<string> {
|
export async function renderToString(
|
||||||
|
input: App | VNode,
|
||||||
|
context: SSRContext = {}
|
||||||
|
): Promise<string> {
|
||||||
let buffer: ResolvedSSRBuffer
|
let buffer: ResolvedSSRBuffer
|
||||||
if (isVNode(input)) {
|
if (isVNode(input)) {
|
||||||
// raw vnode, wrap with component
|
// raw vnode, wrap with app (for context)
|
||||||
buffer = await renderComponent({ render: () => input })
|
return renderToString(createApp({ render: () => input }), context)
|
||||||
} else {
|
} else {
|
||||||
// rendering an app
|
// rendering an app
|
||||||
const vnode = createVNode(input._component, input._props)
|
const vnode = createVNode(input._component, input._props)
|
||||||
vnode.appContext = input._context
|
vnode.appContext = input._context
|
||||||
|
// provide the ssr context to the tree
|
||||||
|
input.provide(ssrContextKey, context)
|
||||||
buffer = await renderComponentVNode(vnode)
|
buffer = await renderComponentVNode(vnode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolve portals
|
||||||
|
if (context.__portalBuffers) {
|
||||||
|
context.portals = context.portals || {}
|
||||||
|
for (const key in context.__portalBuffers) {
|
||||||
|
// note: it's OK to await sequentially here because the Promises were
|
||||||
|
// created eagerly in parallel.
|
||||||
|
context.portals[key] = unrollBuffer(await context.__portalBuffers[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return unrollBuffer(buffer)
|
return unrollBuffer(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +162,7 @@ function renderComponentVNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SSRRenderFunction = (
|
type SSRRenderFunction = (
|
||||||
ctx: any,
|
context: any,
|
||||||
push: (item: any) => void,
|
push: (item: any) => void,
|
||||||
parentInstance: ComponentInternalInstance
|
parentInstance: ComponentInternalInstance
|
||||||
) => void
|
) => void
|
||||||
@ -206,7 +236,7 @@ function renderComponentSubTree(
|
|||||||
function renderVNode(
|
function renderVNode(
|
||||||
push: PushFn,
|
push: PushFn,
|
||||||
vnode: VNode,
|
vnode: VNode,
|
||||||
parentComponent: ComponentInternalInstance | null = null
|
parentComponent: ComponentInternalInstance
|
||||||
) {
|
) {
|
||||||
const { type, shapeFlag, children } = vnode
|
const { type, shapeFlag, children } = vnode
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -222,7 +252,7 @@ function renderVNode(
|
|||||||
push(`<!---->`)
|
push(`<!---->`)
|
||||||
break
|
break
|
||||||
case Portal:
|
case Portal:
|
||||||
// TODO
|
renderPortal(vnode, parentComponent)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
if (shapeFlag & ShapeFlags.ELEMENT) {
|
if (shapeFlag & ShapeFlags.ELEMENT) {
|
||||||
@ -244,7 +274,7 @@ function renderVNode(
|
|||||||
export function renderVNodeChildren(
|
export function renderVNodeChildren(
|
||||||
push: PushFn,
|
push: PushFn,
|
||||||
children: VNodeArrayChildren,
|
children: VNodeArrayChildren,
|
||||||
parentComponent: ComponentInternalInstance | null = null
|
parentComponent: ComponentInternalInstance
|
||||||
) {
|
) {
|
||||||
for (let i = 0; i < children.length; i++) {
|
for (let i = 0; i < children.length; i++) {
|
||||||
renderVNode(push, normalizeVNode(children[i]), parentComponent)
|
renderVNode(push, normalizeVNode(children[i]), parentComponent)
|
||||||
@ -254,7 +284,7 @@ export function renderVNodeChildren(
|
|||||||
function renderElement(
|
function renderElement(
|
||||||
push: PushFn,
|
push: PushFn,
|
||||||
vnode: VNode,
|
vnode: VNode,
|
||||||
parentComponent: ComponentInternalInstance | null = null
|
parentComponent: ComponentInternalInstance
|
||||||
) {
|
) {
|
||||||
const tag = vnode.type as string
|
const tag = vnode.type as string
|
||||||
const { props, children, shapeFlag, scopeId } = vnode
|
const { props, children, shapeFlag, scopeId } = vnode
|
||||||
@ -305,3 +335,35 @@ function renderElement(
|
|||||||
push(`</${tag}>`)
|
push(`</${tag}>`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPortal(
|
||||||
|
vnode: VNode,
|
||||||
|
parentComponent: ComponentInternalInstance
|
||||||
|
) {
|
||||||
|
const target = vnode.props && vnode.props.target
|
||||||
|
if (!target) {
|
||||||
|
console.warn(`[@vue/server-renderer] Portal is missing target prop.`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!isString(target)) {
|
||||||
|
console.warn(
|
||||||
|
`[@vue/server-renderer] Portal target must be a query selector string.`
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const { buffer, push, hasAsync } = createBuffer()
|
||||||
|
renderVNodeChildren(
|
||||||
|
push,
|
||||||
|
vnode.children as VNodeArrayChildren,
|
||||||
|
parentComponent
|
||||||
|
)
|
||||||
|
const context = parentComponent.appContext.provides[
|
||||||
|
ssrContextKey as any
|
||||||
|
] as SSRContext
|
||||||
|
const portalBuffers =
|
||||||
|
context.__portalBuffers || (context.__portalBuffers = {})
|
||||||
|
portalBuffers[target] = hasAsync()
|
||||||
|
? Promise.all(buffer)
|
||||||
|
: (buffer as ResolvedSSRBuffer)
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user