Просмотр исходного кода

refactor: pull in vue-ssr-html-stream and refactor into generic template renderer

Evan You 9 лет назад
Родитель
Сommit
fae6b87786

+ 3 - 14
flow/modules.js

@@ -30,19 +30,8 @@ declare module 'de-indent' {
   }
 }
 
-declare module 'vue-ssr-html-stream' {
-  declare interface parsedTemplate {
-    head: string;
-    neck: string;
-    tail: string;
-  }
-  declare interface HTMLStreamOptions {
-    template: string | parsedTemplate;
-    context?: ?Object;
-  }
-  declare class exports extends stream$Transform {
-    constructor(options: HTMLStreamOptions): void;
-    static parseTemplate(template: string): parsedTemplate;
-    static renderTemplate(template: parsedTemplate, content: string, context?: ?Object): string;
+declare module 'serialize-javascript' {
+  declare var exports: {
+    (input: string, options: { isJSON: boolean }): string
   }
 }

+ 1 - 1
packages/vue-server-renderer/package.json

@@ -22,7 +22,7 @@
     "de-indent": "^1.0.2",
     "resolve": "^1.2.0",
     "source-map": "0.5.6",
-    "vue-ssr-html-stream": "^2.1.0"
+    "serialize-javascript": "^1.3.0"
   },
   "homepage": "https://github.com/vuejs/vue/tree/dev/packages/vue-server-renderer#readme"
 }

+ 1 - 1
src/entries/web-server-renderer.js

@@ -3,7 +3,7 @@
 process.env.VUE_ENV = 'server'
 
 import { createRenderer as _createRenderer } from 'server/create-renderer'
-import { createBundleRendererCreator } from 'server/create-bundle-renderer'
+import { createBundleRendererCreator } from 'server/bundle-renderer/create-bundle-renderer'
 import { isUnaryTag, canBeLeftOpenTag } from 'web/compiler/util'
 import modules from 'web/server/modules/index'
 import baseDirectives from 'web/server/directives/index'

+ 1 - 1
src/server/create-bundle-renderer.js → src/server/bundle-renderer/create-bundle-renderer.js

@@ -1,7 +1,7 @@
 /* @flow */
 
 import { createBundleRunner } from './create-bundle-runner'
-import type { Renderer, RenderOptions } from './create-renderer'
+import type { Renderer, RenderOptions } from '../create-renderer'
 import { createSourceMapConsumers, rewriteErrorTrace } from './source-map-support'
 
 const fs = require('fs')

+ 7 - 6
src/server/create-bundle-runner.js → src/server/bundle-renderer/create-bundle-runner.js

@@ -38,9 +38,9 @@ function compileModule (files, basedir) {
     return script
   }
 
-  function evaluateModule (filename, context, evaluatedModules) {
-    if (evaluatedModules[filename]) {
-      return evaluatedModules[filename]
+  function evaluateModule (filename, context, evaluatedFiles) {
+    if (evaluatedFiles[filename]) {
+      return evaluatedFiles[filename]
     }
 
     const script = getCompiledScript(filename)
@@ -49,7 +49,7 @@ function compileModule (files, basedir) {
     const r = file => {
       file = path.join('.', file)
       if (files[file]) {
-        return evaluateModule(file, context, evaluatedModules)
+        return evaluateModule(file, context, evaluatedFiles)
       } else if (basedir) {
         return require(
           resolvedModules[file] ||
@@ -64,7 +64,7 @@ function compileModule (files, basedir) {
     const res = Object.prototype.hasOwnProperty.call(m.exports, 'default')
       ? m.exports.default
       : m.exports
-    evaluatedModules[filename] = res
+    evaluatedFiles[filename] = res
     return res
   }
   return evaluateModule
@@ -74,7 +74,8 @@ export function createBundleRunner (entry, files, basedir) {
   const evaluate = compileModule(files, basedir)
   return (_context = {}) => new Promise((resolve, reject) => {
     const context = createContext(_context)
-    const res = evaluate(entry, context, {})
+    const evaluatedFiles = _context._evaluatedFiles = {}
+    const res = evaluate(entry, context, evaluatedFiles)
     resolve(typeof res === 'function' ? res(_context) : res)
   })
 }

+ 0 - 0
src/server/source-map-support.js → src/server/bundle-renderer/source-map-support.js


+ 18 - 14
src/server/create-renderer.js

@@ -1,8 +1,7 @@
 /* @flow */
 
-const HTMLStream = require('vue-ssr-html-stream')
-
 import RenderStream from './render-stream'
+import TemplateRenderer from './template-renderer/index'
 import { createWriteFunction } from './write'
 import { createRenderFunction } from './render'
 
@@ -24,6 +23,10 @@ export type RenderOptions = {
   cache?: RenderCache;
   template?: string;
   basedir?: string;
+  manifest?: {
+    server: Object;
+    client: Object;
+  }
 };
 
 export function createRenderer ({
@@ -31,10 +34,14 @@ export function createRenderer ({
   directives = {},
   isUnaryTag = (() => false),
   template,
-  cache
+  cache,
+  manifest
 }: RenderOptions = {}): Renderer {
   const render = createRenderFunction(modules, directives, isUnaryTag, cache)
-  const parsedTemplate = template && HTMLStream.parseTemplate(template)
+  const templateRenderer = template && new TemplateRenderer({
+    template,
+    manifest
+  })
 
   return {
     renderToString (
@@ -48,8 +55,8 @@ export function createRenderer ({
       }, done)
       try {
         render(component, write, () => {
-          if (parsedTemplate) {
-            result = HTMLStream.renderTemplate(parsedTemplate, result, context)
+          if (templateRenderer) {
+            result = templateRenderer.renderSync(result, context)
           }
           done(null, result)
         })
@@ -65,18 +72,15 @@ export function createRenderer ({
       const renderStream = new RenderStream((write, done) => {
         render(component, write, done)
       })
-      if (!parsedTemplate) {
+      if (!templateRenderer) {
         return renderStream
       } else {
-        const htmlStream = new HTMLStream({
-          template: parsedTemplate,
-          context
-        })
+        const templateStream = templateRenderer.createStream(context)
         renderStream.on('error', err => {
-          htmlStream.emit('error', err)
+          templateStream.emit('error', err)
         })
-        renderStream.pipe(htmlStream)
-        return htmlStream
+        renderStream.pipe(templateStream)
+        return templateStream
       }
     }
   }

+ 75 - 0
src/server/template-renderer/create-async-file-mapper.js

@@ -0,0 +1,75 @@
+/* @flow */
+
+/**
+ * Creates a mapper that maps files used during a server-side render
+ * to async chunk files in the client-side build, so that we can inline them
+ * directly in the rendered HTML to avoid waterfall requests.
+ */
+
+export function createMapper (serverStats: Object, clientStats: Object) {
+  const fileMap = createFileMap(serverStats, clientStats)
+  return function mapFiles (files: Array<string>): Array<string> {
+    const res = new Set()
+    for (let i = 0; i < files.length; i++) {
+      const mapped = fileMap.get(files[i])
+      if (mapped) {
+        for (let j = 0; j < mapped.length; j++) {
+          res.add(mapped[j])
+        }
+      }
+    }
+    return Array.from(res)
+  }
+}
+
+function createFileMap (serverStats, clientStats) {
+  const fileMap = new Map()
+  serverStats.assets
+    .filter(asset => /\.js$/.test(asset.name))
+    .forEach(asset => {
+      const mapped = mapFile(asset.name, serverStats, clientStats)
+      fileMap.set(asset.name, mapped)
+    })
+  return fileMap
+}
+
+function mapFile (file, serverStats, clientStats) {
+  // 1. server file -> server chunk ids
+  const serverChunkIds = new Set()
+  const asset = serverStats.assets.find(asset => asset.name === file)
+  if (!asset) return []
+  asset.chunks.forEach(id => {
+    const chunk = serverStats.chunks.find(c => c.id === id)
+    if (!chunk.initial) { // only map async chunks
+      serverChunkIds.add(id)
+    }
+  })
+
+  // 2. server chunk ids -> module identifiers
+  const moduleIdentifiers = []
+  serverStats.modules.forEach(module => {
+    if (module.chunks.some(id => serverChunkIds.has(id))) {
+      moduleIdentifiers.push(module.identifier)
+    }
+  })
+
+  // 3. module identifiers -> client chunk ids
+  const clientChunkIds = new Set()
+  moduleIdentifiers.forEach(identifier => {
+    const clientModule = clientStats.modules.find(m => m.identifier === identifier)
+    if (clientModule && clientModule.chunks.length === 1) { // ignore modules duplicated in multiple chunks
+      clientChunkIds.add(clientModule.chunks[0])
+    }
+  })
+
+  // 4. client chunks -> client files
+  const clientFiles = new Set()
+  Array.from(clientChunkIds).forEach(id => {
+    const chunk = clientStats.chunks.find(chunk => chunk.id === id)
+    if (!chunk.initial) {
+      chunk.files.forEach(file => clientFiles.add(file))
+    }
+  })
+
+  return Array.from(clientFiles)
+}

+ 178 - 0
src/server/template-renderer/index.js

@@ -0,0 +1,178 @@
+/* @flow */
+
+import TemplateStream from './template-stream'
+import { createMapper } from './create-async-file-mapper'
+
+const serialize = require('serialize-javascript')
+
+type TemplateRendererOptions = {
+  template: string;
+  manifest?: {
+    server: Object;
+    client: Object;
+  }
+};
+
+export type ParsedTemplate = {
+  head: string;
+  neck: string;
+  waist: string;
+  tail: string;
+};
+
+export default class TemplateRenderer {
+  template: ParsedTemplate;
+  publicPath: string;
+  preloadLinks: ?string;
+  asyncFiles: ?Array<string>;
+  mapFiles: ?(files: Array<string>) => Array<string>;
+
+  constructor (options: TemplateRendererOptions) {
+    this.template = parseTemplate(options.template)
+
+    // extra functionality with manifests
+    if (options.manifest) {
+      const serverManifest = options.manifest.server
+      const clientManifest = options.manifest.client
+      if (!serverManifest || !clientManifest) {
+        throw new Error(
+          'The manifest option must provide both server and client manifests.'
+        )
+      }
+
+      this.publicPath = clientManifest.publicPath.replace(/\/$/, '')
+
+      // preload/prefetch drectives
+      const clientInitialFiles = []
+      const clientAsyncFiles = []
+      clientManifest.chunks.forEach(chunk => {
+        chunk.files.forEach(file => {
+          if (chunk.initial) {
+            clientInitialFiles.push(file)
+          } else {
+            clientAsyncFiles.push(file)
+          }
+        })
+      })
+
+      this.preloadLinks = this.renderPreloadLinks(clientInitialFiles)
+      this.asyncFiles = clientAsyncFiles
+
+      // initial async chunk mapping
+      this.mapFiles = createMapper(serverManifest, clientManifest)
+    }
+  }
+
+  // render synchronously given rendered app content and render context
+  renderSync (content: string, context: ?Object) {
+    const template = this.template
+    context = context || {}
+    return (
+      template.head +
+      (context.head || '') +
+      (this.preloadLinks || '') +
+      this.renderPrefetchLinks(context) +
+      (context.styles || '') +
+      template.neck +
+      content +
+      this.renderState(context) +
+      template.waist +
+      this.renderAsyncChunks(context) +
+      template.tail
+    )
+  }
+
+  renderPreloadLinks (files: Array<string>): string {
+    return files.map(file => {
+      return `<link rel="preload" href="${
+        this.publicPath}/${file
+      }" as="${
+        /\.css$/.test(file) ? 'style' : 'script'
+      }">`
+    }).join('')
+  }
+
+  renderPrefetchLinks (context: Object): string {
+    const renderedFiles = this.getRenderedFilesFromContext(context)
+    if (this.asyncFiles) {
+      return this.asyncFiles.map(file => {
+        if (!renderedFiles || renderedFiles.indexOf(file) < 0) {
+          return `<link rel="prefetch" href="${this.publicPath}/${file}" as="script">`
+        } else {
+          return ''
+        }
+      }).join('')
+    } else {
+      return ''
+    }
+  }
+
+  renderState (context: Object): string {
+    return context.state
+      ? `<script>window.__INITIAL_STATE__=${
+          serialize(context.state, { isJSON: true })
+        }</script>`
+      : ''
+  }
+
+  renderAsyncChunks (context: Object): string {
+    const renderedFiles = this.getRenderedFilesFromContext(context)
+    if (renderedFiles) {
+      return renderedFiles.map(file => {
+        return `<script src="${this.publicPath}/${file}"></script>`
+      }).join('')
+    } else {
+      return ''
+    }
+  }
+
+  getRenderedFilesFromContext (context: Object) {
+    if (context._evaluatedFiles && this.mapFiles) {
+      return this.mapFiles(Object.keys(context._evaluatedFiles))
+    }
+  }
+
+  // create a transform stream
+  createStream (context: ?Object) {
+    return new TemplateStream(this, context || {})
+  }
+}
+
+function parseTemplate (
+  template: string,
+  contentPlaceholder?: string = '<!--vue-ssr-outlet-->'
+): ParsedTemplate {
+  if (typeof template === 'object') {
+    return template
+  }
+
+  let i = template.indexOf('</head>')
+  const j = template.indexOf(contentPlaceholder)
+
+  if (j < 0) {
+    throw new Error(`Content placeholder not found in template.`)
+  }
+
+  if (i < 0) {
+    i = template.indexOf('<body>')
+    if (i < 0) {
+      i = j
+    }
+  }
+
+  let waist = ''
+  let tail = template.slice(j + contentPlaceholder.length)
+  let k = tail.indexOf('</script>')
+  if (k > 0) {
+    k += '</script>'.length
+    waist = tail.slice(0, k)
+    tail = tail.slice(k)
+  }
+
+  return {
+    head: template.slice(0, i),
+    neck: template.slice(i, j),
+    waist,
+    tail
+  }
+}

+ 77 - 0
src/server/template-renderer/template-stream.js

@@ -0,0 +1,77 @@
+/* @flow */
+
+const Transform = require('stream').Transform
+import type TemplateRenderer, { ParsedTemplate } from './index'
+
+export default class TemplateStream extends Transform {
+  started: boolean;
+  renderer: TemplateRenderer;
+  template: ParsedTemplate;
+  context: Object;
+
+  constructor (renderer: TemplateRenderer, context: Object) {
+    super()
+    this.started = false
+    this.renderer = renderer
+    this.template = renderer.template
+    this.context = context || {}
+  }
+
+  _transform (data: Buffer | string, encoding: string, done: Function) {
+    if (!this.started) {
+      this.emit('beforeStart')
+      this.start()
+    }
+    this.push(data)
+    done()
+  }
+
+  start () {
+    this.started = true
+    this.push(this.template.head)
+
+    // inline server-rendered head meta information
+    if (this.context.head) {
+      this.push(this.context.head)
+    }
+
+    // inline preload directives for initial chunks
+    if (this.renderer.preloadLinks) {
+      this.push(this.renderer.preloadLinks)
+    }
+
+    // inline prefetch directives for async chunks not used during render
+    const prefetchLinks = this.renderer.renderPrefetchLinks(this.context)
+    if (prefetchLinks) {
+      this.push(prefetchLinks)
+    }
+
+    // inline server-rendered CSS collected by vue-style-loader
+    if (this.context.styles) {
+      this.push(this.context.styles)
+    }
+
+    this.push(this.template.neck)
+  }
+
+  _flush (done: Function) {
+    this.emit('beforeEnd')
+
+    // inline initial store state
+    const state = this.renderer.renderState(this.context)
+    if (state) {
+      this.push(state)
+    }
+
+    this.push(this.template.waist)
+
+    // embed async chunks used in initial render
+    const asyncChunks = this.renderer.renderAsyncChunks(this.context)
+    if (asyncChunks) {
+      this.push(asyncChunks)
+    }
+
+    this.push(this.template.tail)
+    done()
+  }
+}