diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index d3b24f2bdde..72460ddcafe 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -103,6 +103,7 @@ export { registerRuntimeCompiler } from './component' // For server-renderer export { createComponentInstance, setupComponent } from './component' +export { renderComponentRoot } from './componentRenderUtils' // Types ----------------------------------------------------------------------- diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 33c177d6f5c..c23ddd80106 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -4,7 +4,9 @@ import { isString, isObject, EMPTY_ARR, - extend + extend, + normalizeClass, + normalizeStyle } from '@vue/shared' import { ComponentInternalInstance, @@ -378,43 +380,6 @@ export function normalizeChildren(vnode: VNode, children: unknown) { vnode.shapeFlag |= type } -function normalizeStyle( - value: unknown -): Record | void { - if (isArray(value)) { - const res: Record = {} - for (let i = 0; i < value.length; i++) { - const normalized = normalizeStyle(value[i]) - if (normalized) { - for (const key in normalized) { - res[key] = normalized[key] - } - } - } - return res - } else if (isObject(value)) { - return value - } -} - -export function normalizeClass(value: unknown): string { - let res = '' - if (isString(value)) { - res = value - } else if (isArray(value)) { - for (let i = 0; i < value.length; i++) { - res += normalizeClass(value[i]) + ' ' - } - } else if (isObject(value)) { - for (const name in value) { - if (value[name]) { - res += name + ' ' - } - } - } - return res.trim() -} - const handlersRE = /^on|^vnode/ export function mergeProps(...args: (Data & VNodeProps)[]) { diff --git a/packages/server-renderer/__tests__/escape.spec.ts b/packages/server-renderer/__tests__/escape.spec.ts new file mode 100644 index 00000000000..6e4c6a27714 --- /dev/null +++ b/packages/server-renderer/__tests__/escape.spec.ts @@ -0,0 +1 @@ +test('ssr: escape HTML', () => {}) diff --git a/packages/server-renderer/__tests__/renderToString.spec.ts b/packages/server-renderer/__tests__/renderToString.spec.ts new file mode 100644 index 00000000000..4a7dc68ebbe --- /dev/null +++ b/packages/server-renderer/__tests__/renderToString.spec.ts @@ -0,0 +1,17 @@ +// import { renderToString, renderComponent } from '../src' + +describe('ssr: renderToString', () => { + test('basic', () => {}) + + test('nested components', () => {}) + + test('nested components with optimized slots', () => {}) + + test('mixing optimized / vnode components', () => {}) + + test('nested components with vnode slots', () => {}) + + test('async components', () => {}) + + test('parallel async components', () => {}) +}) diff --git a/packages/server-renderer/__tests__/renderVnode.spec.ts b/packages/server-renderer/__tests__/renderVnode.spec.ts new file mode 100644 index 00000000000..038b93390e4 --- /dev/null +++ b/packages/server-renderer/__tests__/renderVnode.spec.ts @@ -0,0 +1,29 @@ +describe('ssr: render raw vnodes', () => { + test('class', () => {}) + + test('styles', () => { + // only render numbers for properties that allow no unit numbers + }) + + describe('attrs', () => { + test('basic', () => {}) + + test('boolean attrs', () => {}) + + test('enumerated attrs', () => {}) + + test('skip falsy values', () => {}) + }) + + describe('domProps', () => { + test('innerHTML', () => {}) + + test('textContent', () => {}) + + test('textarea', () => {}) + + test('other renderable domProps', () => { + // also test camel to kebab case conversion for some props + }) + }) +}) diff --git a/packages/server-renderer/src/helpers.ts b/packages/server-renderer/src/escape.ts similarity index 86% rename from packages/server-renderer/src/helpers.ts rename to packages/server-renderer/src/escape.ts index 761dc10e765..6cfe940393a 100644 --- a/packages/server-renderer/src/helpers.ts +++ b/packages/server-renderer/src/escape.ts @@ -1,5 +1,3 @@ -import { toDisplayString } from '@vue/shared' - const escapeRE = /["'&<>]/ export function escape(string: unknown) { @@ -45,7 +43,3 @@ export function escape(string: unknown) { return lastIndex !== index ? html + str.substring(lastIndex, index) : html } - -export function interpolate(value: unknown) { - return escape(toDisplayString(value)) -} diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts index 318a0fa3495..8546d2471ad 100644 --- a/packages/server-renderer/src/index.ts +++ b/packages/server-renderer/src/index.ts @@ -1,104 +1,16 @@ -import { - App, - Component, - ComponentInternalInstance, - createComponentInstance, - setupComponent, - VNode, - createVNode -} from 'vue' -import { isString, isPromise, isArray } from '@vue/shared' +import { toDisplayString } from 'vue' -export * from './helpers' +export { renderToString, renderComponent } from './renderToString' -type SSRBuffer = SSRBufferItem[] -type SSRBufferItem = string | ResolvedSSRBuffer | Promise -type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[] +export { + renderVNode, + renderClass, + renderStyle, + renderProps +} from './renderVnode' -function createBuffer() { - let appendable = false - let hasAsync = false - const buffer: SSRBuffer = [] - return { - buffer, - hasAsync() { - return hasAsync - }, - 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 { - let ret = '' - for (let i = 0; i < buffer.length; i++) { - const item = buffer[i] - if (isString(item)) { - ret += item - } else { - ret += unrollBuffer(item) - } - } - return ret -} - -export async function renderToString(app: App): Promise { - const resolvedBuffer = (await renderComponent( - app._component, - app._props - )) as ResolvedSSRBuffer - return unrollBuffer(resolvedBuffer) -} - -export function renderComponent( - comp: Component, - props: Record | null = null, - children: VNode['children'] = null, - parentComponent: ComponentInternalInstance | null = null -): ResolvedSSRBuffer | Promise { - const vnode = createVNode(comp, props, children) - const instance = createComponentInstance(vnode, parentComponent) - const res = setupComponent(instance, null) - if (isPromise(res)) { - return res.then(() => innerRenderComponent(comp, instance)) - } else { - return innerRenderComponent(comp, instance) - } -} +export { escape } from './escape' -function innerRenderComponent( - comp: Component, - instance: ComponentInternalInstance -): ResolvedSSRBuffer | Promise { - const { buffer, push, hasAsync } = createBuffer() - if (typeof comp === 'function') { - // TODO FunctionalComponent - } else { - if (comp.ssrRender) { - // optimized - comp.ssrRender(push, instance.proxy) - } else if (comp.render) { - // TODO fallback to vdom serialization - } else { - // TODO warn component missing render function - } - } - // 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() - ? // TS can't figure out the typing due to recursive appearance of Promise - Promise.all(buffer as any) - : (buffer as ResolvedSSRBuffer) +export function interpolate(value: unknown) { + return escape(toDisplayString(value)) } diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts new file mode 100644 index 00000000000..b35ca8f207c --- /dev/null +++ b/packages/server-renderer/src/renderToString.ts @@ -0,0 +1,109 @@ +import { + App, + Component, + ComponentInternalInstance, + VNode, + createComponentInstance, + setupComponent, + createVNode, + renderComponentRoot +} from 'vue' +import { isString, isPromise, isArray, isFunction } from '@vue/shared' +import { renderVNode } from './renderVnode' + +export type SSRBuffer = SSRBufferItem[] +export type SSRBufferItem = string | ResolvedSSRBuffer | Promise +export type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[] + +function createBuffer() { + let appendable = false + let hasAsync = false + const buffer: SSRBuffer = [] + return { + buffer, + hasAsync() { + return hasAsync + }, + 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 { + let ret = '' + for (let i = 0; i < buffer.length; i++) { + const item = buffer[i] + if (isString(item)) { + ret += item + } else { + ret += unrollBuffer(item) + } + } + return ret +} + +export async function renderToString(app: App): Promise { + const resolvedBuffer = (await renderComponent( + app._component, + app._props + )) as ResolvedSSRBuffer + return unrollBuffer(resolvedBuffer) +} + +export function renderComponent( + comp: Component, + props: Record | null = null, + children: VNode['children'] = null, + parentComponent: ComponentInternalInstance | null = null +): ResolvedSSRBuffer | Promise { + const vnode = createVNode(comp, props, children) + const instance = createComponentInstance(vnode, parentComponent) + const res = setupComponent(instance, null) + if (isPromise(res)) { + return res.then(() => innerRenderComponent(comp, instance)) + } else { + return innerRenderComponent(comp, instance) + } +} + +function innerRenderComponent( + comp: Component, + instance: ComponentInternalInstance +): ResolvedSSRBuffer | Promise { + const { buffer, push, hasAsync } = createBuffer() + if (isFunction(comp)) { + renderVNode(push, renderComponentRoot(instance)) + } else { + if (comp.ssrRender) { + // optimized + comp.ssrRender(push, instance.proxy) + } else if (comp.render) { + renderVNode(push, renderComponentRoot(instance)) + } else { + // TODO on the fly template compilation support + throw new Error( + `Component ${ + comp.name ? `${comp.name} ` : `` + } is missing render function.` + ) + } + } + // 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() + ? // TS can't figure out the typing due to recursive appearance of Promise + Promise.all(buffer as any) + : (buffer as ResolvedSSRBuffer) +} diff --git a/packages/server-renderer/src/renderVnode.ts b/packages/server-renderer/src/renderVnode.ts new file mode 100644 index 00000000000..09f32df27ac --- /dev/null +++ b/packages/server-renderer/src/renderVnode.ts @@ -0,0 +1,13 @@ +import { VNode } from 'vue' +import { SSRBufferItem } from './renderToString' + +export function renderVNode( + push: (item: SSRBufferItem) => void, + vnode: VNode +) {} + +export function renderProps() {} + +export function renderClass() {} + +export function renderStyle() {} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 89f21923e86..55b5d3ea7da 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export * from './globalsWhitelist' export * from './codeframe' export * from './domTagConfig' export * from './mockWarn' +export * from './normalizeProp' export const EMPTY_OBJ: { readonly [key: string]: any } = __DEV__ ? Object.freeze({}) diff --git a/packages/shared/src/normalizeProp.ts b/packages/shared/src/normalizeProp.ts new file mode 100644 index 00000000000..8829710c1e5 --- /dev/null +++ b/packages/shared/src/normalizeProp.ts @@ -0,0 +1,38 @@ +import { isArray, isString, isObject } from './' + +export function normalizeStyle( + value: unknown +): Record | void { + if (isArray(value)) { + const res: Record = {} + for (let i = 0; i < value.length; i++) { + const normalized = normalizeStyle(value[i]) + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key] + } + } + } + return res + } else if (isObject(value)) { + return value + } +} + +export function normalizeClass(value: unknown): string { + let res = '' + if (isString(value)) { + res = value + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + res += normalizeClass(value[i]) + ' ' + } + } else if (isObject(value)) { + for (const name in value) { + if (value[name]) { + res += name + ' ' + } + } + } + return res.trim() +}