feat(ssr): renderToStream (#1197)

This commit is contained in:
Stanislav Lashmanov 2020-06-26 18:09:47 +03:00 committed by GitHub
parent e0d19a6953
commit 6bc0e0a31a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1054 additions and 389 deletions

View File

@ -11,9 +11,8 @@ import {
defineAsyncComponent,
defineComponent
} from '@vue/runtime-dom'
import { renderToString } from '@vue/server-renderer'
import { renderToString, SSRContext } from '@vue/server-renderer'
import { mockWarn } from '@vue/shared'
import { SSRContext } from 'packages/server-renderer/src/renderToString'
function mountWithHydration(html: string, render: () => any) {
const container = document.createElement('div')

View File

@ -0,0 +1,605 @@
import {
createApp,
h,
createCommentVNode,
withScopeId,
resolveComponent,
ComponentOptions,
ref,
defineComponent,
createTextVNode,
createStaticVNode
} from 'vue'
import { escapeHtml, mockWarn } from '@vue/shared'
import { renderToStream as _renderToStream } from '../src/renderToStream'
import { Readable } from 'stream'
import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot'
import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent'
mockWarn()
const promisifyStream = (stream: Readable) => {
return new Promise((resolve, reject) => {
let result = ''
stream.on('data', data => {
result += data
})
stream.on('error', () => {
reject(result)
})
stream.on('end', () => {
resolve(result)
})
})
}
const renderToStream = (app: any, context?: any) =>
promisifyStream(_renderToStream(app, context))
describe('ssr: renderToStream', () => {
test('should apply app context', async () => {
const app = createApp({
render() {
const Foo = resolveComponent('foo') as ComponentOptions
return h(Foo)
}
})
app.component('foo', {
render: () => h('div', 'foo')
})
const html = await renderToStream(app)
expect(html).toBe(`<div>foo</div>`)
})
describe('components', () => {
test('vnode components', async () => {
expect(
await renderToStream(
createApp({
data() {
return { msg: 'hello' }
},
render(this: any) {
return h('div', this.msg)
}
})
)
).toBe(`<div>hello</div>`)
})
test('option components returning render from setup', async () => {
expect(
await renderToStream(
createApp({
setup() {
const msg = ref('hello')
return () => h('div', msg.value)
}
})
)
).toBe(`<div>hello</div>`)
})
test('setup components returning render from setup', async () => {
expect(
await renderToStream(
createApp(
defineComponent((props: {}) => {
const msg = ref('hello')
return () => h('div', msg.value)
})
)
)
).toBe(`<div>hello</div>`)
})
test('optimized components', async () => {
expect(
await renderToStream(
createApp({
data() {
return { msg: 'hello' }
},
ssrRender(ctx, push) {
push(`<div>${ctx.msg}</div>`)
}
})
)
).toBe(`<div>hello</div>`)
})
describe('template components', () => {
test('render', async () => {
expect(
await renderToStream(
createApp({
data() {
return { msg: 'hello' }
},
template: `<div>{{ msg }}</div>`
})
)
).toBe(`<div>hello</div>`)
})
test('handle compiler errors', async () => {
await renderToStream(createApp({ template: `<` }))
expect(
'Template compilation error: Unexpected EOF in tag.\n' +
'1 | <\n' +
' | ^'
).toHaveBeenWarned()
})
})
test('nested vnode components', async () => {
const Child = {
props: ['msg'],
render(this: any) {
return h('div', this.msg)
}
}
expect(
await renderToStream(
createApp({
render() {
return h('div', ['parent', h(Child, { msg: 'hello' })])
}
})
)
).toBe(`<div>parent<div>hello</div></div>`)
})
test('nested optimized components', async () => {
const Child = {
props: ['msg'],
ssrRender(ctx: any, push: any) {
push(`<div>${ctx.msg}</div>`)
}
}
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
push(`</div>`)
}
})
)
).toBe(`<div>parent<div>hello</div></div>`)
})
test('nested template components', async () => {
const Child = {
props: ['msg'],
template: `<div>{{ msg }}</div>`
}
const app = createApp({
template: `<div>parent<Child msg="hello" /></div>`
})
app.component('Child', Child)
expect(await renderToStream(app)).toBe(
`<div>parent<div>hello</div></div>`
)
})
test('mixing optimized / vnode / template components', async () => {
const OptimizedChild = {
props: ['msg'],
ssrRender(ctx: any, push: any) {
push(`<div>${ctx.msg}</div>`)
}
}
const VNodeChild = {
props: ['msg'],
render(this: any) {
return h('div', this.msg)
}
}
const TemplateChild = {
props: ['msg'],
template: `<div>{{ msg }}</div>`
}
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
)
push(
ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
)
push(
ssrRenderComponent(
TemplateChild,
{ msg: 'template' },
null,
parent
)
)
push(`</div>`)
}
})
)
).toBe(
`<div>parent<div>opt</div><div>vnode</div><div>template</div></div>`
)
})
test('nested components with optimized slots', async () => {
const Child = {
props: ['msg'],
ssrRender(ctx: any, push: any, parent: any) {
push(`<div class="child">`)
ssrRenderSlot(
ctx.$slots,
'default',
{ msg: 'from slot' },
() => {
push(`fallback`)
},
push,
parent
)
push(`</div>`)
}
}
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
ssrRenderComponent(
Child,
{ msg: 'hello' },
{
// optimized slot using string push
default: ({ msg }: any, push: any, p: any) => {
push(`<span>${msg}</span>`)
},
// important to avoid slots being normalized
_: 1 as any
},
parent
)
)
push(`</div>`)
}
})
)
).toBe(
`<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` +
`</div></div>`
)
// test fallback
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
push(`</div>`)
}
})
)
).toBe(
`<div>parent<div class="child"><!--[-->fallback<!--]--></div></div>`
)
})
test('nested components with vnode slots', async () => {
const Child = {
props: ['msg'],
ssrRender(ctx: any, push: any, parent: any) {
push(`<div class="child">`)
ssrRenderSlot(
ctx.$slots,
'default',
{ msg: 'from slot' },
null,
push,
parent
)
push(`</div>`)
}
}
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
ssrRenderComponent(
Child,
{ msg: 'hello' },
{
// bailed slots returning raw vnodes
default: ({ msg }: any) => {
return h('span', msg)
}
},
parent
)
)
push(`</div>`)
}
})
)
).toBe(
`<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` +
`</div></div>`
)
})
test('nested components with template slots', async () => {
const Child = {
props: ['msg'],
template: `<div class="child"><slot msg="from slot"></slot></div>`
}
const app = createApp({
components: { Child },
template: `<div>parent<Child v-slot="{ msg }"><span>{{ msg }}</span></Child></div>`
})
expect(await renderToStream(app)).toBe(
`<div>parent<div class="child">` +
`<!--[--><span>from slot</span><!--]-->` +
`</div></div>`
)
})
test('nested render fn components with template slots', async () => {
const Child = {
props: ['msg'],
render(this: any) {
return h(
'div',
{
class: 'child'
},
this.$slots.default({ msg: 'from slot' })
)
}
}
const app = createApp({
template: `<div>parent<Child v-slot="{ msg }"><span>{{ msg }}</span></Child></div>`
})
app.component('Child', Child)
expect(await renderToStream(app)).toBe(
`<div>parent<div class="child">` +
// no comment anchors because slot is used directly as element children
`<span>from slot</span>` +
`</div></div>`
)
})
test('async components', async () => {
const Child = {
// should wait for resolved render context from setup()
async setup() {
return {
msg: 'hello'
}
},
ssrRender(ctx: any, push: any) {
push(`<div>${ctx.msg}</div>`)
}
}
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(ssrRenderComponent(Child, null, null, parent))
push(`</div>`)
}
})
)
).toBe(`<div>parent<div>hello</div></div>`)
})
test('parallel async components', async () => {
const OptimizedChild = {
props: ['msg'],
async setup(props: any) {
return {
localMsg: props.msg + '!'
}
},
ssrRender(ctx: any, push: any) {
push(`<div>${ctx.localMsg}</div>`)
}
}
const VNodeChild = {
props: ['msg'],
async setup(props: any) {
return {
localMsg: props.msg + '!'
}
},
render(this: any) {
return h('div', this.localMsg)
}
}
expect(
await renderToStream(
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
)
push(
ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
)
push(`</div>`)
}
})
)
).toBe(`<div>parent<div>opt!</div><div>vnode!</div></div>`)
})
})
describe('vnode element', () => {
test('props', async () => {
expect(
await renderToStream(
h('div', { id: 'foo&', class: ['bar', 'baz'] }, 'hello')
)
).toBe(`<div id="foo&amp;" class="bar baz">hello</div>`)
})
test('text children', async () => {
expect(await renderToStream(h('div', 'hello'))).toBe(`<div>hello</div>`)
})
test('array children', async () => {
expect(
await renderToStream(
h('div', [
'foo',
h('span', 'bar'),
[h('span', 'baz')],
createCommentVNode('qux')
])
)
).toBe(
`<div>foo<span>bar</span><!--[--><span>baz</span><!--]--><!--qux--></div>`
)
})
test('void elements', async () => {
expect(await renderToStream(h('input'))).toBe(`<input>`)
})
test('innerHTML', async () => {
expect(
await renderToStream(
h(
'div',
{
innerHTML: `<span>hello</span>`
},
'ignored'
)
)
).toBe(`<div><span>hello</span></div>`)
})
test('textContent', async () => {
expect(
await renderToStream(
h(
'div',
{
textContent: `<span>hello</span>`
},
'ignored'
)
)
).toBe(`<div>${escapeHtml(`<span>hello</span>`)}</div>`)
})
test('textarea value', async () => {
expect(
await renderToStream(
h(
'textarea',
{
value: `<span>hello</span>`
},
'ignored'
)
)
).toBe(`<textarea>${escapeHtml(`<span>hello</span>`)}</textarea>`)
})
})
describe('raw vnode types', () => {
test('Text', async () => {
expect(await renderToStream(createTextVNode('hello <div>'))).toBe(
`hello &lt;div&gt;`
)
})
test('Comment', async () => {
// https://www.w3.org/TR/html52/syntax.html#comments
expect(
await renderToStream(
h('div', [
createCommentVNode('>foo'),
createCommentVNode('->foo'),
createCommentVNode('<!--foo-->'),
createCommentVNode('--!>foo<!-')
])
)
).toBe(`<div><!--foo--><!--foo--><!--foo--><!--foo--></div>`)
})
test('Static', async () => {
const content = `<div id="ok">hello<span>world</span></div>`
expect(await renderToStream(createStaticVNode(content, 1))).toBe(content)
})
})
describe('scopeId', () => {
// note: here we are only testing scopeId handling for vdom serialization.
// compiled srr render functions will include scopeId directly in strings.
const withId = withScopeId('data-v-test')
const withChildId = withScopeId('data-v-child')
test('basic', async () => {
expect(
await renderToStream(
withId(() => {
return h('div')
})()
)
).toBe(`<div data-v-test></div>`)
})
test('with slots', async () => {
const Child = {
__scopeId: 'data-v-child',
render: withChildId(function(this: any) {
return h('div', this.$slots.default())
})
}
const Parent = {
__scopeId: 'data-v-test',
render: withId(() => {
return h(Child, null, {
default: withId(() => h('span', 'slot'))
})
})
}
expect(await renderToStream(h(Parent))).toBe(
`<div data-v-test data-v-child><span data-v-test data-v-child-s>slot</span></div>`
)
})
})
})

View File

@ -11,8 +11,9 @@ import {
createStaticVNode
} from 'vue'
import { escapeHtml, mockWarn } from '@vue/shared'
import { renderToString, renderComponent } from '../src/renderToString'
import { renderToString } from '../src/renderToString'
import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot'
import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent'
mockWarn()
@ -145,7 +146,7 @@ describe('ssr: renderToString', () => {
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(renderComponent(Child, { msg: 'hello' }, null, parent))
push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
push(`</div>`)
}
})
@ -194,11 +195,13 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
renderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
)
push(renderComponent(VNodeChild, { msg: 'vnode' }, null, parent))
push(
renderComponent(
ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
)
push(
ssrRenderComponent(
TemplateChild,
{ msg: 'template' },
null,
@ -239,7 +242,7 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
renderComponent(
ssrRenderComponent(
Child,
{ msg: 'hello' },
{
@ -269,7 +272,7 @@ describe('ssr: renderToString', () => {
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(renderComponent(Child, { msg: 'hello' }, null, parent))
push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
push(`</div>`)
}
})
@ -302,7 +305,7 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
renderComponent(
ssrRenderComponent(
Child,
{ msg: 'hello' },
{
@ -388,7 +391,7 @@ describe('ssr: renderToString', () => {
createApp({
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(renderComponent(Child, null, null, parent))
push(ssrRenderComponent(Child, null, null, parent))
push(`</div>`)
}
})
@ -427,9 +430,11 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`<div>parent`)
push(
renderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
)
push(
ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
)
push(renderComponent(VNodeChild, { msg: 'vnode' }, null, parent))
push(`</div>`)
}
})

View File

@ -1,5 +1,6 @@
import { createApp, h, Teleport } from 'vue'
import { renderToString, SSRContext } from '../src/renderToString'
import { renderToString } from '../src/renderToString'
import { SSRContext } from '../src/render'
import { ssrRenderTeleport } from '../src/helpers/ssrRenderTeleport'
describe('ssrRenderTeleport', () => {

View File

@ -0,0 +1,46 @@
import { ComponentInternalInstance, warn } from 'vue'
import { compile } from '@vue/compiler-ssr'
import { generateCodeFrame, NO } from '@vue/shared'
import { CompilerError } from '@vue/compiler-core'
import { PushFn } from '../render'
type SSRRenderFunction = (
context: any,
push: PushFn,
parentInstance: ComponentInternalInstance
) => void
const compileCache: Record<string, SSRRenderFunction> = Object.create(null)
export function ssrCompile(
template: string,
instance: ComponentInternalInstance
): SSRRenderFunction {
const cached = compileCache[template]
if (cached) {
return cached
}
const { code } = compile(template, {
isCustomElement: instance.appContext.config.isCustomElement || NO,
isNativeTag: instance.appContext.config.isNativeTag || NO,
onError(err: CompilerError) {
if (__DEV__) {
const message = `[@vue/server-renderer] Template compilation error: ${
err.message
}`
const codeFrame =
err.loc &&
generateCodeFrame(
template as string,
err.loc.start.offset,
err.loc.end.offset
)
warn(codeFrame ? `${message}\n${codeFrame}` : message)
} else {
throw err
}
}
})
return (compileCache[template] = Function('require', code)(require))
}

View File

@ -0,0 +1,15 @@
import { Component, ComponentInternalInstance, createVNode, Slots } from 'vue'
import { Props, renderComponentVNode, SSRBuffer } from '../render'
import { SSRSlots } from './ssrRenderSlot'
export function ssrRenderComponent(
comp: Component,
props: Props | null = null,
children: Slots | SSRSlots | null = null,
parentComponent: ComponentInternalInstance | null = null
): SSRBuffer | Promise<SSRBuffer> {
return renderComponentVNode(
createVNode(comp, props, children),
parentComponent
)
}

View File

@ -1,8 +1,7 @@
import { Props, PushFn, renderVNodeChildren } from '../renderToString'
import { ComponentInternalInstance, Slot, Slots } from 'vue'
import { Props, PushFn, renderVNodeChildren } from '../render'
export type SSRSlots = Record<string, SSRSlot>
export type SSRSlot = (
props: Props,
push: PushFn,

View File

@ -1,4 +1,4 @@
import { PushFn } from '../renderToString'
import { PushFn } from '../render'
export async function ssrRenderSuspense(
push: PushFn,

View File

@ -1,10 +1,5 @@
import { ComponentInternalInstance, ssrContextKey } from 'vue'
import {
SSRContext,
createBuffer,
PushFn,
SSRBufferItem
} from '../renderToString'
import { createBuffer, PushFn, SSRBufferItem, SSRContext } from '../render'
export function ssrRenderTeleport(
parentPush: PushFn,

View File

@ -1,9 +1,12 @@
// public
export { renderToString, SSRContext } from './renderToString'
export { SSRContext } from './render'
export { renderToString } from './renderToString'
export { renderToStream } from './renderToStream'
// internal runtime helpers
export { renderComponent as ssrRenderComponent } from './renderToString'
export { ssrRenderComponent } from './helpers/ssrRenderComponent'
export { ssrRenderSlot } from './helpers/ssrRenderSlot'
export { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
export {
ssrRenderClass,
ssrRenderStyle,
@ -13,7 +16,6 @@ export {
} from './helpers/ssrRenderAttrs'
export { ssrInterpolate } from './helpers/ssrInterpolate'
export { ssrRenderList } from './helpers/ssrRenderList'
export { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
export { ssrRenderSuspense } from './helpers/ssrRenderSuspense'
// v-model helpers

View File

@ -0,0 +1,286 @@
import {
Comment,
Component,
ComponentInternalInstance,
DirectiveBinding,
Fragment,
mergeProps,
ssrUtils,
Static,
Text,
VNode,
VNodeArrayChildren,
VNodeProps,
warn
} from 'vue'
import {
escapeHtml,
escapeHtmlComment,
isFunction,
isPromise,
isString,
isVoidTag,
ShapeFlags
} from '@vue/shared'
import { ssrRenderAttrs } from './helpers/ssrRenderAttrs'
import { ssrCompile } from './helpers/ssrCompile'
import { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
const {
createComponentInstance,
setCurrentRenderingInstance,
setupComponent,
renderComponentRoot,
normalizeVNode,
normalizeSuspenseChildren
} = ssrUtils
export type SSRBuffer = SSRBufferItem[]
export type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
export type PushFn = (item: SSRBufferItem) => void
export type Props = Record<string, unknown>
export type SSRContext = {
[key: string]: any
teleports?: Record<string, string>
__teleportBuffers?: Record<string, SSRBuffer>
}
// Each component has a buffer array.
// A buffer array can contain one of the following:
// - plain string
// - A resolved buffer (recursive arrays of strings that can be unrolled
// synchronously)
// - An async buffer (a Promise that resolves to a resolved buffer)
export function createBuffer() {
let appendable = false
const buffer: SSRBuffer = []
return {
getBuffer(): SSRBuffer {
// Return static buffer and await on items during unroll stage
return buffer
},
push(item: SSRBufferItem) {
const isStringItem = isString(item)
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
} else {
buffer.push(item)
}
appendable = isStringItem
}
}
}
export function renderComponentVNode(
vnode: VNode,
parentComponent: ComponentInternalInstance | null = null
): SSRBuffer | Promise<SSRBuffer> {
const instance = createComponentInstance(vnode, parentComponent, null)
const res = setupComponent(instance, true /* isSSR */)
if (isPromise(res)) {
return res
.catch(err => {
warn(`[@vue/server-renderer]: Uncaught error in async setup:\n`, err)
})
.then(() => renderComponentSubTree(instance))
} else {
return renderComponentSubTree(instance)
}
}
function renderComponentSubTree(
instance: ComponentInternalInstance
): SSRBuffer | Promise<SSRBuffer> {
const comp = instance.type as Component
const { getBuffer, push } = createBuffer()
if (isFunction(comp)) {
renderVNode(push, renderComponentRoot(instance), instance)
} else {
if (!instance.render && !comp.ssrRender && isString(comp.template)) {
comp.ssrRender = ssrCompile(comp.template, instance)
}
if (comp.ssrRender) {
// optimized
// set current rendering instance for asset resolution
setCurrentRenderingInstance(instance)
comp.ssrRender(instance.proxy, push, instance)
setCurrentRenderingInstance(null)
} else if (instance.render) {
renderVNode(push, renderComponentRoot(instance), instance)
} else {
warn(
`Component ${
comp.name ? `${comp.name} ` : ``
} is missing template or render function.`
)
push(`<!---->`)
}
}
return getBuffer()
}
function renderVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
const { type, shapeFlag, children } = vnode
switch (type) {
case Text:
push(escapeHtml(children as string))
break
case Comment:
push(
children ? `<!--${escapeHtmlComment(children as string)}-->` : `<!---->`
)
break
case Static:
push(children as string)
break
case Fragment:
push(`<!--[-->`) // open
renderVNodeChildren(push, children as VNodeArrayChildren, parentComponent)
push(`<!--]-->`) // close
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
renderElementVNode(push, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
push(renderComponentVNode(vnode, parentComponent))
} else if (shapeFlag & ShapeFlags.TELEPORT) {
renderTeleportVNode(push, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
renderVNode(
push,
normalizeSuspenseChildren(vnode).content,
parentComponent
)
} else {
warn(
'[@vue/server-renderer] Invalid VNode type:',
type,
`(${typeof type})`
)
}
}
}
export function renderVNodeChildren(
push: PushFn,
children: VNodeArrayChildren,
parentComponent: ComponentInternalInstance
) {
for (let i = 0; i < children.length; i++) {
renderVNode(push, normalizeVNode(children[i]), parentComponent)
}
}
function renderElementVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
const tag = vnode.type as string
let { props, children, shapeFlag, scopeId, dirs } = vnode
let openTag = `<${tag}`
if (dirs) {
props = applySSRDirectives(vnode, props, dirs)
}
if (props) {
openTag += ssrRenderAttrs(props, tag)
}
if (scopeId) {
openTag += ` ${scopeId}`
const treeOwnerId = parentComponent && parentComponent.type.__scopeId
// vnode's own scopeId and the current rendering component's scopeId is
// different - this is a slot content node.
if (treeOwnerId && treeOwnerId !== scopeId) {
openTag += ` ${treeOwnerId}-s`
}
}
push(openTag + `>`)
if (!isVoidTag(tag)) {
let hasChildrenOverride = false
if (props) {
if (props.innerHTML) {
hasChildrenOverride = true
push(props.innerHTML)
} else if (props.textContent) {
hasChildrenOverride = true
push(escapeHtml(props.textContent))
} else if (tag === 'textarea' && props.value) {
hasChildrenOverride = true
push(escapeHtml(props.value))
}
}
if (!hasChildrenOverride) {
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
push(escapeHtml(children as string))
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
renderVNodeChildren(
push,
children as VNodeArrayChildren,
parentComponent
)
}
}
push(`</${tag}>`)
}
}
function applySSRDirectives(
vnode: VNode,
rawProps: VNodeProps | null,
dirs: DirectiveBinding[]
): VNodeProps {
const toMerge: VNodeProps[] = []
for (let i = 0; i < dirs.length; i++) {
const binding = dirs[i]
const {
dir: { getSSRProps }
} = binding
if (getSSRProps) {
const props = getSSRProps(binding, vnode)
if (props) toMerge.push(props)
}
}
return mergeProps(rawProps || {}, ...toMerge)
}
function renderTeleportVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
const target = vnode.props && vnode.props.to
const disabled = vnode.props && vnode.props.disabled
if (!target) {
warn(`[@vue/server-renderer] Teleport is missing target prop.`)
return []
}
if (!isString(target)) {
warn(
`[@vue/server-renderer] Teleport target must be a query selector string.`
)
return []
}
ssrRenderTeleport(
push,
push => {
renderVNodeChildren(
push,
vnode.children as VNodeArrayChildren,
parentComponent
)
},
target,
disabled || disabled === '',
parentComponent
)
}

View File

@ -0,0 +1,59 @@
import {
App,
VNode,
createVNode,
ssrUtils,
createApp,
ssrContextKey
} from 'vue'
import { isString, isPromise } from '@vue/shared'
import { renderComponentVNode, SSRBuffer, SSRContext } from './render'
import { Readable } from 'stream'
const { isVNode } = ssrUtils
async function unrollBuffer(
buffer: SSRBuffer,
stream: Readable
): Promise<void> {
for (let i = 0; i < buffer.length; i++) {
let item = buffer[i]
if (isPromise(item)) {
item = await item
}
if (isString(item)) {
stream.push(item)
} else {
await unrollBuffer(item, stream)
}
}
}
export function renderToStream(
input: App | VNode,
context: SSRContext = {}
): Readable {
if (isVNode(input)) {
// raw vnode, wrap with app (for context)
return renderToStream(createApp({ render: () => input }), context)
}
// rendering an app
const vnode = createVNode(input._component, input._props)
vnode.appContext = input._context
// provide the ssr context to the tree
input.provide(ssrContextKey, context)
const stream = new Readable()
Promise.resolve(renderComponentVNode(vnode))
.then(buffer => unrollBuffer(buffer, stream))
.then(() => {
stream.push(null)
})
.catch(error => {
stream.destroy(error)
})
return stream
}

View File

@ -1,109 +1,27 @@
import {
App,
Component,
ComponentInternalInstance,
VNode,
VNodeArrayChildren,
createVNode,
Text,
Comment,
Static,
Fragment,
ssrUtils,
Slots,
createApp,
createVNode,
ssrContextKey,
warn,
DirectiveBinding,
VNodeProps,
mergeProps
ssrUtils,
VNode
} from 'vue'
import {
ShapeFlags,
isString,
isPromise,
isArray,
isFunction,
isVoidTag,
escapeHtml,
NO,
generateCodeFrame,
escapeHtmlComment
} from '@vue/shared'
import { compile } from '@vue/compiler-ssr'
import { ssrRenderAttrs } from './helpers/ssrRenderAttrs'
import { SSRSlots } from './helpers/ssrRenderSlot'
import { CompilerError } from '@vue/compiler-dom'
import { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
import { isPromise, isString } from '@vue/shared'
import { SSRContext, renderComponentVNode, SSRBuffer } from './render'
const {
isVNode,
createComponentInstance,
setCurrentRenderingInstance,
setupComponent,
renderComponentRoot,
normalizeVNode,
normalizeSuspenseChildren
} = ssrUtils
const { isVNode } = ssrUtils
// Each component has a buffer array.
// A buffer array can contain one of the following:
// - plain string
// - A resolved buffer (recursive arrays of strings that can be unrolled
// synchronously)
// - An async buffer (a Promise that resolves to a resolved buffer)
export type SSRBuffer = SSRBufferItem[]
export type SSRBufferItem =
| string
| ResolvedSSRBuffer
| Promise<ResolvedSSRBuffer>
export type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[]
export type PushFn = (item: SSRBufferItem) => void
export type Props = Record<string, unknown>
export type SSRContext = {
[key: string]: any
teleports?: Record<string, string>
__teleportBuffers?: Record<string, SSRBuffer>
}
export function createBuffer() {
let appendable = false
let hasAsync = false
const buffer: SSRBuffer = []
return {
getBuffer(): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
// If the current component's buffer contains any Promise from async children,
// then it must return a Promise too. Otherwise this is a component that
// contains only sync children so we can avoid the async book-keeping overhead.
return hasAsync ? Promise.all(buffer) : (buffer as ResolvedSSRBuffer)
},
push(item: SSRBufferItem) {
const isStringItem = isString(item)
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
} else {
buffer.push(item)
}
appendable = isStringItem
if (!isStringItem && !isArray(item)) {
// promise
hasAsync = true
}
}
}
}
function unrollBuffer(buffer: ResolvedSSRBuffer): string {
async function unrollBuffer(buffer: SSRBuffer): Promise<string> {
let ret = ''
for (let i = 0; i < buffer.length; i++) {
const item = buffer[i]
let item = buffer[i]
if (isPromise(item)) {
item = await item
}
if (isString(item)) {
ret += item
} else {
ret += unrollBuffer(item)
ret += await unrollBuffer(item as SSRBuffer)
}
}
return ret
@ -127,272 +45,7 @@ export async function renderToString(
await resolveTeleports(context)
return unrollBuffer(buffer)
}
export function renderComponent(
comp: Component,
props: Props | null = null,
children: Slots | SSRSlots | null = null,
parentComponent: ComponentInternalInstance | null = null
): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
return renderComponentVNode(
createVNode(comp, props, children),
parentComponent
)
}
function renderComponentVNode(
vnode: VNode,
parentComponent: ComponentInternalInstance | null = null
): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
const instance = createComponentInstance(vnode, parentComponent, null)
const res = setupComponent(instance, true /* isSSR */)
if (isPromise(res)) {
return res
.catch(err => {
warn(`[@vue/server-renderer]: Uncaught error in async setup:\n`, err)
})
.then(() => renderComponentSubTree(instance))
} else {
return renderComponentSubTree(instance)
}
}
function renderComponentSubTree(
instance: ComponentInternalInstance
): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
const comp = instance.type as Component
const { getBuffer, push } = createBuffer()
if (isFunction(comp)) {
renderVNode(push, renderComponentRoot(instance), instance)
} else {
if (!instance.render && !comp.ssrRender && isString(comp.template)) {
comp.ssrRender = ssrCompile(comp.template, instance)
}
if (comp.ssrRender) {
// optimized
// set current rendering instance for asset resolution
setCurrentRenderingInstance(instance)
comp.ssrRender(instance.proxy, push, instance)
setCurrentRenderingInstance(null)
} else if (instance.render) {
renderVNode(push, renderComponentRoot(instance), instance)
} else {
warn(
`Component ${
comp.name ? `${comp.name} ` : ``
} is missing template or render function.`
)
push(`<!---->`)
}
}
return getBuffer()
}
type SSRRenderFunction = (
context: any,
push: (item: any) => void,
parentInstance: ComponentInternalInstance
) => void
const compileCache: Record<string, SSRRenderFunction> = Object.create(null)
function ssrCompile(
template: string,
instance: ComponentInternalInstance
): SSRRenderFunction {
const cached = compileCache[template]
if (cached) {
return cached
}
const { code } = compile(template, {
isCustomElement: instance.appContext.config.isCustomElement || NO,
isNativeTag: instance.appContext.config.isNativeTag || NO,
onError(err: CompilerError) {
if (__DEV__) {
const message = `[@vue/server-renderer] Template compilation error: ${
err.message
}`
const codeFrame =
err.loc &&
generateCodeFrame(
template as string,
err.loc.start.offset,
err.loc.end.offset
)
warn(codeFrame ? `${message}\n${codeFrame}` : message)
} else {
throw err
}
}
})
return (compileCache[template] = Function('require', code)(require))
}
function renderVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
const { type, shapeFlag, children } = vnode
switch (type) {
case Text:
push(escapeHtml(children as string))
break
case Comment:
push(
children ? `<!--${escapeHtmlComment(children as string)}-->` : `<!---->`
)
break
case Static:
push(children as string)
break
case Fragment:
push(`<!--[-->`) // open
renderVNodeChildren(push, children as VNodeArrayChildren, parentComponent)
push(`<!--]-->`) // close
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
renderElementVNode(push, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
push(renderComponentVNode(vnode, parentComponent))
} else if (shapeFlag & ShapeFlags.TELEPORT) {
renderTeleportVNode(push, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
renderVNode(
push,
normalizeSuspenseChildren(vnode).content,
parentComponent
)
} else {
warn(
'[@vue/server-renderer] Invalid VNode type:',
type,
`(${typeof type})`
)
}
}
}
export function renderVNodeChildren(
push: PushFn,
children: VNodeArrayChildren,
parentComponent: ComponentInternalInstance
) {
for (let i = 0; i < children.length; i++) {
renderVNode(push, normalizeVNode(children[i]), parentComponent)
}
}
function renderElementVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
const tag = vnode.type as string
let { props, children, shapeFlag, scopeId, dirs } = vnode
let openTag = `<${tag}`
if (dirs) {
props = applySSRDirectives(vnode, props, dirs)
}
if (props) {
openTag += ssrRenderAttrs(props, tag)
}
if (scopeId) {
openTag += ` ${scopeId}`
const treeOwnerId = parentComponent && parentComponent.type.__scopeId
// vnode's own scopeId and the current rendering component's scopeId is
// different - this is a slot content node.
if (treeOwnerId && treeOwnerId !== scopeId) {
openTag += ` ${treeOwnerId}-s`
}
}
push(openTag + `>`)
if (!isVoidTag(tag)) {
let hasChildrenOverride = false
if (props) {
if (props.innerHTML) {
hasChildrenOverride = true
push(props.innerHTML)
} else if (props.textContent) {
hasChildrenOverride = true
push(escapeHtml(props.textContent))
} else if (tag === 'textarea' && props.value) {
hasChildrenOverride = true
push(escapeHtml(props.value))
}
}
if (!hasChildrenOverride) {
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
push(escapeHtml(children as string))
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
renderVNodeChildren(
push,
children as VNodeArrayChildren,
parentComponent
)
}
}
push(`</${tag}>`)
}
}
function applySSRDirectives(
vnode: VNode,
rawProps: VNodeProps | null,
dirs: DirectiveBinding[]
): VNodeProps {
const toMerge: VNodeProps[] = []
for (let i = 0; i < dirs.length; i++) {
const binding = dirs[i]
const {
dir: { getSSRProps }
} = binding
if (getSSRProps) {
const props = getSSRProps(binding, vnode)
if (props) toMerge.push(props)
}
}
return mergeProps(rawProps || {}, ...toMerge)
}
function renderTeleportVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
const target = vnode.props && vnode.props.to
const disabled = vnode.props && vnode.props.disabled
if (!target) {
warn(`[@vue/server-renderer] Teleport is missing target prop.`)
return []
}
if (!isString(target)) {
warn(
`[@vue/server-renderer] Teleport target must be a query selector string.`
)
return []
}
ssrRenderTeleport(
push,
push => {
renderVNodeChildren(
push,
vnode.children as VNodeArrayChildren,
parentComponent
)
},
target,
disabled || disabled === '',
parentComponent
)
return unrollBuffer(buffer as SSRBuffer)
}
async function resolveTeleports(context: SSRContext) {
@ -401,9 +54,9 @@ async function resolveTeleports(context: SSRContext) {
for (const key in context.__teleportBuffers) {
// note: it's OK to await sequentially here because the Promises were
// created eagerly in parallel.
context.teleports[key] = unrollBuffer(
await Promise.all(context.__teleportBuffers[key])
)
context.teleports[key] = await unrollBuffer((await Promise.all(
context.__teleportBuffers[key]
)) as SSRBuffer)
}
}
}