feat(ssr): ssrPrefetch option + context.rendered hook (#9017)

This commit is contained in:
Guillaume Chau 2018-12-20 21:26:12 +01:00 committed by Evan You
parent f036cce163
commit d7a533d6f8
10 changed files with 349 additions and 9 deletions

View File

@ -44,6 +44,7 @@ declare type ComponentOptions = {
beforeDestroy?: Function;
destroyed?: Function;
errorCaptured?: () => boolean | void;
ssrPrefetch?: Function;
// assets
directives?: { [key: string]: Object };

View File

@ -79,6 +79,9 @@ export function createRenderer ({
}, cb)
try {
render(component, write, context, err => {
if (context && context.rendered) {
context.rendered(context)
}
if (template) {
result = templateRenderer.renderSync(result, context)
}
@ -106,6 +109,12 @@ export function createRenderer ({
render(component, write, context, done)
})
if (!template) {
if (context && context.rendered) {
const rendered = context.rendered
renderStream.once('beforeEnd', () => {
rendered(context)
})
}
return renderStream
} else {
const templateStream = templateRenderer.createStream(context)
@ -113,6 +122,12 @@ export function createRenderer ({
templateStream.emit('error', err)
})
renderStream.pipe(templateStream)
if (context && context.rendered) {
const rendered = context.rendered
renderStream.once('beforeEnd', () => {
rendered(context)
})
}
return templateStream
}
}

View File

@ -42,6 +42,7 @@ export default class RenderStream extends stream.Readable {
})
this.end = () => {
this.emit('beforeEnd')
// the rendering is finished; we should push out the last of the buffer.
this.done = true
this.push(this.buffer)

View File

@ -19,6 +19,7 @@ let warned = Object.create(null)
const warnOnce = msg => {
if (!warned[msg]) {
warned[msg] = true
// eslint-disable-next-line no-console
console.warn(`\n\u001b[31m${msg}\u001b[39m\n`)
}
}
@ -49,6 +50,27 @@ const normalizeRender = vm => {
}
}
function waitForSsrPrefetch (vm, resolve, reject) {
let handlers = vm.$options.ssrPrefetch
if (isDef(handlers)) {
if (!Array.isArray(handlers)) handlers = [handlers]
try {
const promises = []
for (let i = 0, j = handlers.length; i < j; i++) {
const result = handlers[i].call(vm, vm)
if (result && typeof result.then === 'function') {
promises.push(result)
}
}
Promise.all(promises).then(resolve).catch(reject)
return
} catch (e) {
reject(e)
}
}
resolve()
}
function renderNode (node, isRoot, context) {
if (node.isString) {
renderStringNode(node, context)
@ -166,13 +188,20 @@ function renderComponentInner (node, isRoot, context) {
context.activeInstance
)
normalizeRender(child)
const childNode = child._render()
childNode.parent = node
context.renderStates.push({
type: 'Component',
prevActive
})
renderNode(childNode, isRoot, context)
const resolve = () => {
const childNode = child._render()
childNode.parent = node
context.renderStates.push({
type: 'Component',
prevActive
})
renderNode(childNode, isRoot, context)
}
const reject = context.done
waitForSsrPrefetch(child, resolve, reject)
}
function renderAsyncComponent (node, isRoot, context) {
@ -394,6 +423,10 @@ export function createRenderFunction (
})
installSSRHelpers(component)
normalizeRender(component)
renderNode(component._render(), true, context)
const resolve = () => {
renderNode(component._render(), true, context)
}
waitForSsrPrefetch(component, resolve, done)
}
}

View File

@ -17,5 +17,6 @@ export const LIFECYCLE_HOOKS = [
'destroyed',
'activated',
'deactivated',
'errorCaptured'
'errorCaptured',
'ssrPrefetch'
]

View File

@ -102,4 +102,26 @@ describe('SSR: renderToStream', () => {
stream1.read(1)
stream2.read(1)
})
it('should call context.rendered', done => {
let a = 0
const stream = renderToStream(new Vue({
template: `
<div>Hello</div>
`
}), {
rendered: () => {
a = 42
}
})
let res = ''
stream.on('data', chunk => {
res += chunk
})
stream.on('end', () => {
expect(res).toContain('<div data-server-rendered="true">Hello</div>')
expect(a).toBe(42)
done()
})
})
})

View File

@ -1311,6 +1311,194 @@ describe('SSR: renderToString', () => {
done()
})
})
it('should support ssrPrefetch option', done => {
renderVmWithOptions({
template: `
<div>{{ count }}</div>
`,
data: {
count: 0
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.count = 42
resolve()
}, 1)
})
}
}, result => {
expect(result).toContain('<div data-server-rendered="true">42</div>')
done()
})
})
it('should support ssrPrefetch option (nested)', done => {
renderVmWithOptions({
template: `
<div>
<span>{{ count }}</span>
<nested-prefetch></nested-prefetch>
</div>
`,
data: {
count: 0
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.count = 42
resolve()
}, 1)
})
},
components: {
nestedPrefetch: {
template: `
<div>{{ message }}</div>
`,
data () {
return {
message: ''
}
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.message = 'vue.js'
resolve()
}, 1)
})
}
}
}
}, result => {
expect(result).toContain('<div data-server-rendered="true"><span>42</span> <div>vue.js</div></div>')
done()
})
})
it('should support ssrPrefetch option (nested async)', done => {
renderVmWithOptions({
template: `
<div>
<span>{{ count }}</span>
<nested-prefetch></nested-prefetch>
</div>
`,
data: {
count: 0
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.count = 42
resolve()
}, 1)
})
},
components: {
nestedPrefetch (resolve) {
resolve({
template: `
<div>{{ message }}</div>
`,
data () {
return {
message: ''
}
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.message = 'vue.js'
resolve()
}, 1)
})
}
})
}
}
}, result => {
expect(result).toContain('<div data-server-rendered="true"><span>42</span> <div>vue.js</div></div>')
done()
})
})
it('should merge ssrPrefetch option', done => {
const mixin = {
data: {
message: ''
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.message = 'vue.js'
resolve()
}, 1)
})
}
}
renderVmWithOptions({
mixins: [mixin],
template: `
<div>
<span>{{ count }}</span>
<div>{{ message }}</div>
</div>
`,
data: {
count: 0
},
ssrPrefetch () {
return new Promise((resolve) => {
setTimeout(() => {
this.count = 42
resolve()
}, 1)
})
}
}, result => {
expect(result).toContain('<div data-server-rendered="true"><span>42</span> <div>vue.js</div></div>')
done()
})
})
it(`should skip ssrPrefetch option that doesn't return a promise`, done => {
renderVmWithOptions({
template: `
<div>{{ count }}</div>
`,
data: {
count: 0
},
ssrPrefetch () {
setTimeout(() => {
this.count = 42
}, 1)
}
}, result => {
expect(result).toContain('<div data-server-rendered="true">0</div>')
done()
})
})
it('should call context.rendered', done => {
let a = 0
renderToString(new Vue({
template: '<div>Hello</div>'
}), {
rendered: () => {
a = 42
}
}, (err, res) => {
expect(err).toBeNull()
expect(res).toContain('<div data-server-rendered="true">Hello</div>')
expect(a).toBe(42)
done()
})
})
})
function renderVmWithOptions (options, cb) {

View File

@ -99,6 +99,41 @@ describe('SSR: template option', () => {
})
})
it('renderToString with interpolation and context.rendered', done => {
const renderer = createRenderer({
template: interpolateTemplate
})
const context = {
title: '<script>hacks</script>',
snippet: '<div>foo</div>',
head: '<meta name="viewport" content="width=device-width">',
styles: '<style>h1 { color: red }</style>',
state: { a: 0 },
rendered: context => {
context.state.a = 1
}
}
renderer.renderToString(new Vue({
template: '<div>hi</div>'
}), context, (err, res) => {
expect(err).toBeNull()
expect(res).toContain(
`<html><head>` +
// double mustache should be escaped
`<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
`${context.head}${context.styles}</head><body>` +
`<div data-server-rendered="true">hi</div>` +
`<script>window.__INITIAL_STATE__={"a":1}</script>` +
// triple should be raw
`<div>foo</div>` +
`</body></html>`
)
done()
})
})
it('renderToStream', done => {
const renderer = createRenderer({
template: defaultTemplate
@ -166,6 +201,46 @@ describe('SSR: template option', () => {
})
})
it('renderToStream with interpolation and context.rendered', done => {
const renderer = createRenderer({
template: interpolateTemplate
})
const context = {
title: '<script>hacks</script>',
snippet: '<div>foo</div>',
head: '<meta name="viewport" content="width=device-width">',
styles: '<style>h1 { color: red }</style>',
state: { a: 0 },
rendered: context => {
context.state.a = 1
}
}
const stream = renderer.renderToStream(new Vue({
template: '<div>hi</div>'
}), context)
let res = ''
stream.on('data', chunk => {
res += chunk
})
stream.on('end', () => {
expect(res).toContain(
`<html><head>` +
// double mustache should be escaped
`<title>&lt;script&gt;hacks&lt;/script&gt;</title>` +
`${context.head}${context.styles}</head><body>` +
`<div data-server-rendered="true">hi</div>` +
`<script>window.__INITIAL_STATE__={"a":1}</script>` +
// triple should be raw
`<div>foo</div>` +
`</body></html>`
)
done()
})
})
it('bundleRenderer + renderToString', done => {
createBundleRenderer('app.js', {
asBundle: true,

1
types/options.d.ts vendored
View File

@ -96,6 +96,7 @@ export interface ComponentOptions<
activated?(): void;
deactivated?(): void;
errorCaptured?(err: Error, vm: Vue, info: string): boolean | void;
ssrPrefetch?(this: V): Promise<void>;
directives?: { [key: string]: DirectiveFunction | DirectiveOptions };
components?: { [key: string]: Component<any, any, any, any> | AsyncComponent<any, any, any, any> };

View File

@ -241,6 +241,9 @@ Vue.component('component', {
info.toUpperCase()
return true
},
ssrPrefetch () {
return Promise.resolve()
},
directives: {
a: {