From 6bc0e0a31a173cfd4cef82230862f269e4d94c94 Mon Sep 17 00:00:00 2001 From: Stanislav Lashmanov Date: Fri, 26 Jun 2020 18:09:47 +0300 Subject: [PATCH] feat(ssr): renderToStream (#1197) --- .../runtime-core/__tests__/hydration.spec.ts | 3 +- .../__tests__/renderToStream.spec.ts | 605 ++++++++++++++++++ .../__tests__/renderToString.spec.ts | 27 +- .../__tests__/ssrTeleport.spec.ts | 3 +- .../server-renderer/src/helpers/ssrCompile.ts | 46 ++ .../src/helpers/ssrRenderComponent.ts | 15 + .../src/helpers/ssrRenderSlot.ts | 3 +- .../src/helpers/ssrRenderSuspense.ts | 2 +- .../src/helpers/ssrRenderTeleport.ts | 7 +- packages/server-renderer/src/index.ts | 8 +- packages/server-renderer/src/render.ts | 286 +++++++++ .../server-renderer/src/renderToStream.ts | 59 ++ .../server-renderer/src/renderToString.ts | 379 +---------- 13 files changed, 1054 insertions(+), 389 deletions(-) create mode 100644 packages/server-renderer/__tests__/renderToStream.spec.ts create mode 100644 packages/server-renderer/src/helpers/ssrCompile.ts create mode 100644 packages/server-renderer/src/helpers/ssrRenderComponent.ts create mode 100644 packages/server-renderer/src/render.ts create mode 100644 packages/server-renderer/src/renderToStream.ts diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index e54063fd..007ceec9 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -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') diff --git a/packages/server-renderer/__tests__/renderToStream.spec.ts b/packages/server-renderer/__tests__/renderToStream.spec.ts new file mode 100644 index 00000000..8d8e4540 --- /dev/null +++ b/packages/server-renderer/__tests__/renderToStream.spec.ts @@ -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(`
foo
`) + }) + + describe('components', () => { + test('vnode components', async () => { + expect( + await renderToStream( + createApp({ + data() { + return { msg: 'hello' } + }, + render(this: any) { + return h('div', this.msg) + } + }) + ) + ).toBe(`
hello
`) + }) + + test('option components returning render from setup', async () => { + expect( + await renderToStream( + createApp({ + setup() { + const msg = ref('hello') + return () => h('div', msg.value) + } + }) + ) + ).toBe(`
hello
`) + }) + + test('setup components returning render from setup', async () => { + expect( + await renderToStream( + createApp( + defineComponent((props: {}) => { + const msg = ref('hello') + return () => h('div', msg.value) + }) + ) + ) + ).toBe(`
hello
`) + }) + + test('optimized components', async () => { + expect( + await renderToStream( + createApp({ + data() { + return { msg: 'hello' } + }, + ssrRender(ctx, push) { + push(`
${ctx.msg}
`) + } + }) + ) + ).toBe(`
hello
`) + }) + + describe('template components', () => { + test('render', async () => { + expect( + await renderToStream( + createApp({ + data() { + return { msg: 'hello' } + }, + template: `
{{ msg }}
` + }) + ) + ).toBe(`
hello
`) + }) + + 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(`
parent
hello
`) + }) + + test('nested optimized components', async () => { + const Child = { + props: ['msg'], + ssrRender(ctx: any, push: any) { + push(`
${ctx.msg}
`) + } + } + + expect( + await renderToStream( + createApp({ + ssrRender(_ctx, push, parent) { + push(`
parent`) + push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent)) + push(`
`) + } + }) + ) + ).toBe(`
parent
hello
`) + }) + + test('nested template components', async () => { + const Child = { + props: ['msg'], + template: `
{{ msg }}
` + } + const app = createApp({ + template: `
parent
` + }) + app.component('Child', Child) + + expect(await renderToStream(app)).toBe( + `
parent
hello
` + ) + }) + + test('mixing optimized / vnode / template components', async () => { + const OptimizedChild = { + props: ['msg'], + ssrRender(ctx: any, push: any) { + push(`
${ctx.msg}
`) + } + } + + const VNodeChild = { + props: ['msg'], + render(this: any) { + return h('div', this.msg) + } + } + + const TemplateChild = { + props: ['msg'], + template: `
{{ msg }}
` + } + + expect( + await renderToStream( + createApp({ + ssrRender(_ctx, push, parent) { + push(`
parent`) + push( + ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent) + ) + push( + ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent) + ) + push( + ssrRenderComponent( + TemplateChild, + { msg: 'template' }, + null, + parent + ) + ) + push(`
`) + } + }) + ) + ).toBe( + `
parent
opt
vnode
template
` + ) + }) + + test('nested components with optimized slots', async () => { + const Child = { + props: ['msg'], + ssrRender(ctx: any, push: any, parent: any) { + push(`
`) + ssrRenderSlot( + ctx.$slots, + 'default', + { msg: 'from slot' }, + () => { + push(`fallback`) + }, + push, + parent + ) + push(`
`) + } + } + + expect( + await renderToStream( + createApp({ + ssrRender(_ctx, push, parent) { + push(`
parent`) + push( + ssrRenderComponent( + Child, + { msg: 'hello' }, + { + // optimized slot using string push + default: ({ msg }: any, push: any, p: any) => { + push(`${msg}`) + }, + // important to avoid slots being normalized + _: 1 as any + }, + parent + ) + ) + push(`
`) + } + }) + ) + ).toBe( + `
parent
` + + `from slot` + + `
` + ) + + // test fallback + expect( + await renderToStream( + createApp({ + ssrRender(_ctx, push, parent) { + push(`
parent`) + push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent)) + push(`
`) + } + }) + ) + ).toBe( + `
parent
fallback
` + ) + }) + + test('nested components with vnode slots', async () => { + const Child = { + props: ['msg'], + ssrRender(ctx: any, push: any, parent: any) { + push(`
`) + ssrRenderSlot( + ctx.$slots, + 'default', + { msg: 'from slot' }, + null, + push, + parent + ) + push(`
`) + } + } + + expect( + await renderToStream( + createApp({ + ssrRender(_ctx, push, parent) { + push(`
parent`) + push( + ssrRenderComponent( + Child, + { msg: 'hello' }, + { + // bailed slots returning raw vnodes + default: ({ msg }: any) => { + return h('span', msg) + } + }, + parent + ) + ) + push(`
`) + } + }) + ) + ).toBe( + `
parent
` + + `from slot` + + `
` + ) + }) + + test('nested components with template slots', async () => { + const Child = { + props: ['msg'], + template: `
` + } + + const app = createApp({ + components: { Child }, + template: `
parent{{ msg }}
` + }) + + expect(await renderToStream(app)).toBe( + `
parent
` + + `from slot` + + `
` + ) + }) + + 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: `
parent{{ msg }}
` + }) + app.component('Child', Child) + + expect(await renderToStream(app)).toBe( + `
parent
` + + // no comment anchors because slot is used directly as element children + `from slot` + + `
` + ) + }) + + 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(`
${ctx.msg}
`) + } + } + + expect( + await renderToStream( + createApp({ + ssrRender(_ctx, push, parent) { + push(`
parent`) + push(ssrRenderComponent(Child, null, null, parent)) + push(`
`) + } + }) + ) + ).toBe(`
parent
hello
`) + }) + + test('parallel async components', async () => { + const OptimizedChild = { + props: ['msg'], + async setup(props: any) { + return { + localMsg: props.msg + '!' + } + }, + ssrRender(ctx: any, push: any) { + push(`
${ctx.localMsg}
`) + } + } + + 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(`
parent`) + push( + ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent) + ) + push( + ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent) + ) + push(`
`) + } + }) + ) + ).toBe(`
parent
opt!
vnode!
`) + }) + }) + + describe('vnode element', () => { + test('props', async () => { + expect( + await renderToStream( + h('div', { id: 'foo&', class: ['bar', 'baz'] }, 'hello') + ) + ).toBe(`
hello
`) + }) + + test('text children', async () => { + expect(await renderToStream(h('div', 'hello'))).toBe(`
hello
`) + }) + + test('array children', async () => { + expect( + await renderToStream( + h('div', [ + 'foo', + h('span', 'bar'), + [h('span', 'baz')], + createCommentVNode('qux') + ]) + ) + ).toBe( + `
foobarbaz
` + ) + }) + + test('void elements', async () => { + expect(await renderToStream(h('input'))).toBe(``) + }) + + test('innerHTML', async () => { + expect( + await renderToStream( + h( + 'div', + { + innerHTML: `hello` + }, + 'ignored' + ) + ) + ).toBe(`
hello
`) + }) + + test('textContent', async () => { + expect( + await renderToStream( + h( + 'div', + { + textContent: `hello` + }, + 'ignored' + ) + ) + ).toBe(`
${escapeHtml(`hello`)}
`) + }) + + test('textarea value', async () => { + expect( + await renderToStream( + h( + 'textarea', + { + value: `hello` + }, + 'ignored' + ) + ) + ).toBe(``) + }) + }) + + describe('raw vnode types', () => { + test('Text', async () => { + expect(await renderToStream(createTextVNode('hello
'))).toBe( + `hello <div>` + ) + }) + + test('Comment', async () => { + // https://www.w3.org/TR/html52/syntax.html#comments + expect( + await renderToStream( + h('div', [ + createCommentVNode('>foo'), + createCommentVNode('->foo'), + createCommentVNode(''), + createCommentVNode('--!>foo
`) + }) + + test('Static', async () => { + const content = `
helloworld
` + 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(`
`) + }) + + 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( + `
slot
` + ) + }) + }) +}) diff --git a/packages/server-renderer/__tests__/renderToString.spec.ts b/packages/server-renderer/__tests__/renderToString.spec.ts index b034312c..f628d991 100644 --- a/packages/server-renderer/__tests__/renderToString.spec.ts +++ b/packages/server-renderer/__tests__/renderToString.spec.ts @@ -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(`
parent`) - push(renderComponent(Child, { msg: 'hello' }, null, parent)) + push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent)) push(`
`) } }) @@ -194,11 +195,13 @@ describe('ssr: renderToString', () => { ssrRender(_ctx, push, parent) { push(`
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(`
parent`) push( - renderComponent( + ssrRenderComponent( Child, { msg: 'hello' }, { @@ -269,7 +272,7 @@ describe('ssr: renderToString', () => { createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) - push(renderComponent(Child, { msg: 'hello' }, null, parent)) + push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent)) push(`
`) } }) @@ -302,7 +305,7 @@ describe('ssr: renderToString', () => { ssrRender(_ctx, push, parent) { push(`
parent`) push( - renderComponent( + ssrRenderComponent( Child, { msg: 'hello' }, { @@ -388,7 +391,7 @@ describe('ssr: renderToString', () => { createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) - push(renderComponent(Child, null, null, parent)) + push(ssrRenderComponent(Child, null, null, parent)) push(`
`) } }) @@ -427,9 +430,11 @@ describe('ssr: renderToString', () => { ssrRender(_ctx, push, parent) { push(`
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(`
`) } }) diff --git a/packages/server-renderer/__tests__/ssrTeleport.spec.ts b/packages/server-renderer/__tests__/ssrTeleport.spec.ts index c990e600..1dd4aa65 100644 --- a/packages/server-renderer/__tests__/ssrTeleport.spec.ts +++ b/packages/server-renderer/__tests__/ssrTeleport.spec.ts @@ -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', () => { diff --git a/packages/server-renderer/src/helpers/ssrCompile.ts b/packages/server-renderer/src/helpers/ssrCompile.ts new file mode 100644 index 00000000..4f9d5062 --- /dev/null +++ b/packages/server-renderer/src/helpers/ssrCompile.ts @@ -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 = 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)) +} diff --git a/packages/server-renderer/src/helpers/ssrRenderComponent.ts b/packages/server-renderer/src/helpers/ssrRenderComponent.ts new file mode 100644 index 00000000..000f2b48 --- /dev/null +++ b/packages/server-renderer/src/helpers/ssrRenderComponent.ts @@ -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 { + return renderComponentVNode( + createVNode(comp, props, children), + parentComponent + ) +} diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts index 64d32182..8c96322a 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts @@ -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 - export type SSRSlot = ( props: Props, push: PushFn, diff --git a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts index 97586988..3d6df47f 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts @@ -1,4 +1,4 @@ -import { PushFn } from '../renderToString' +import { PushFn } from '../render' export async function ssrRenderSuspense( push: PushFn, diff --git a/packages/server-renderer/src/helpers/ssrRenderTeleport.ts b/packages/server-renderer/src/helpers/ssrRenderTeleport.ts index 66772265..77331b7b 100644 --- a/packages/server-renderer/src/helpers/ssrRenderTeleport.ts +++ b/packages/server-renderer/src/helpers/ssrRenderTeleport.ts @@ -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, diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts index 372fa08e..723f964e 100644 --- a/packages/server-renderer/src/index.ts +++ b/packages/server-renderer/src/index.ts @@ -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 diff --git a/packages/server-renderer/src/render.ts b/packages/server-renderer/src/render.ts new file mode 100644 index 00000000..15f05223 --- /dev/null +++ b/packages/server-renderer/src/render.ts @@ -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 +export type PushFn = (item: SSRBufferItem) => void +export type Props = Record + +export type SSRContext = { + [key: string]: any + teleports?: Record + __teleportBuffers?: Record +} + +// 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 { + 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 { + 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 ? `` : `` + ) + 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(``) + } +} + +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 + ) +} diff --git a/packages/server-renderer/src/renderToStream.ts b/packages/server-renderer/src/renderToStream.ts new file mode 100644 index 00000000..63b38952 --- /dev/null +++ b/packages/server-renderer/src/renderToStream.ts @@ -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 { + 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 +} diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index 8720929b..68051da8 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -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 -export type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[] - -export type PushFn = (item: SSRBufferItem) => void - -export type Props = Record - -export type SSRContext = { - [key: string]: any - teleports?: Record - __teleportBuffers?: Record -} - -export function createBuffer() { - let appendable = false - let hasAsync = false - const buffer: SSRBuffer = [] - return { - getBuffer(): ResolvedSSRBuffer | Promise { - // 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 { 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 { - return renderComponentVNode( - createVNode(comp, props, children), - parentComponent - ) -} - -function renderComponentVNode( - vnode: VNode, - parentComponent: ComponentInternalInstance | null = null -): ResolvedSSRBuffer | Promise { - 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 { - 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 = 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 ? `` : `` - ) - 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(``) - } -} - -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) } } }