7a72de779ca5dfb8b758b06bb88609e071c5378f4b46384e1614630b367aaa5e4b9484eff54556225b0f2e2d7b47a0bfdde635322791e38e3adf0a6c71f7f7 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. /* @flow */
  2. const path = require('path')
  3. const serialize = require('serialize-javascript')
  4. import { isJS, isCSS } from '../util'
  5. import TemplateStream from './template-stream'
  6. import { parseTemplate } from './parse-template'
  7. import { createMapper } from './create-async-file-mapper'
  8. import type { ParsedTemplate } from './parse-template'
  9. import type { AsyncFileMapper } from './create-async-file-mapper'
  10. type TemplateRendererOptions = {
  11. template?: string | (content: string, context: any) => string;
  12. inject?: boolean;
  13. clientManifest?: ClientManifest;
  14. shouldPreload?: (file: string, type: string) => boolean;
  15. shouldPrefetch?: (file: string, type: string) => boolean;
  16. serializer?: Function;
  17. };
  18. export type ClientManifest = {
  19. publicPath: string;
  20. all: Array<string>;
  21. initial: Array<string>;
  22. async: Array<string>;
  23. modules: {
  24. [id: string]: Array<number>;
  25. },
  26. hasNoCssVersion?: {
  27. [file: string]: boolean;
  28. }
  29. };
  30. type Resource = {
  31. file: string;
  32. extension: string;
  33. fileWithoutQuery: string;
  34. asType: string;
  35. };
  36. export default class TemplateRenderer {
  37. options: TemplateRendererOptions;
  38. inject: boolean;
  39. parsedTemplate: ParsedTemplate | Function | null;
  40. publicPath: string;
  41. clientManifest: ClientManifest;
  42. preloadFiles: Array<Resource>;
  43. prefetchFiles: Array<Resource>;
  44. mapFiles: AsyncFileMapper;
  45. serialize: Function;
  46. constructor (options: TemplateRendererOptions) {
  47. this.options = options
  48. this.inject = options.inject !== false
  49. // if no template option is provided, the renderer is created
  50. // as a utility object for rendering assets like preload links and scripts.
  51. const { template } = options
  52. this.parsedTemplate = template
  53. ? typeof template === 'string'
  54. ? parseTemplate(template)
  55. : template
  56. : null
  57. // function used to serialize initial state JSON
  58. this.serialize = options.serializer || (state => {
  59. return serialize(state, { isJSON: true })
  60. })
  61. // extra functionality with client manifest
  62. if (options.clientManifest) {
  63. const clientManifest = this.clientManifest = options.clientManifest
  64. // ensure publicPath ends with /
  65. this.publicPath = clientManifest.publicPath === ''
  66. ? ''
  67. : clientManifest.publicPath.replace(/([^\/])$/, '$1/')
  68. // preload/prefetch directives
  69. this.preloadFiles = (clientManifest.initial || []).map(normalizeFile)
  70. this.prefetchFiles = (clientManifest.async || []).map(normalizeFile)
  71. // initial async chunk mapping
  72. this.mapFiles = createMapper(clientManifest)
  73. }
  74. }
  75. bindRenderFns (context: Object) {
  76. const renderer: any = this
  77. ;['ResourceHints', 'State', 'Scripts', 'Styles'].forEach(type => {
  78. context[`render${type}`] = renderer[`render${type}`].bind(renderer, context)
  79. })
  80. // also expose getPreloadFiles, useful for HTTP/2 push
  81. context.getPreloadFiles = renderer.getPreloadFiles.bind(renderer, context)
  82. }
  83. // render synchronously given rendered app content and render context
  84. render (content: string, context: ?Object): string | Promise<string> {
  85. const template = this.parsedTemplate
  86. if (!template) {
  87. throw new Error('render cannot be called without a template.')
  88. }
  89. context = context || {}
  90. if (typeof template === 'function') {
  91. return template(content, context)
  92. }
  93. if (this.inject) {
  94. return (
  95. template.head(context) +
  96. (context.head || '') +
  97. this.renderResourceHints(context) +
  98. this.renderStyles(context) +
  99. template.neck(context) +
  100. content +
  101. this.renderState(context) +
  102. this.renderScripts(context) +
  103. template.tail(context)
  104. )
  105. } else {
  106. return (
  107. template.head(context) +
  108. template.neck(context) +
  109. content +
  110. template.tail(context)
  111. )
  112. }
  113. }
  114. renderStyles (context: Object): string {
  115. const initial = this.preloadFiles || []
  116. const async = this.getUsedAsyncFiles(context) || []
  117. const cssFiles = initial.concat(async).filter(({ file }) => isCSS(file))
  118. return (
  119. // render links for css files
  120. (cssFiles.length
  121. ? cssFiles.map(({ file }) => `<link rel="stylesheet" href="${this.publicPath}${file}">`).join('')
  122. : '') +
  123. // context.styles is a getter exposed by vue-style-loader which contains
  124. // the inline component styles collected during SSR
  125. (context.styles || '')
  126. )
  127. }
  128. renderResourceHints (context: Object): string {
  129. return this.renderPreloadLinks(context) + this.renderPrefetchLinks(context)
  130. }
  131. getPreloadFiles (context: Object): Array<Resource> {
  132. const usedAsyncFiles = this.getUsedAsyncFiles(context)
  133. if (this.preloadFiles || usedAsyncFiles) {
  134. return (this.preloadFiles || []).concat(usedAsyncFiles || [])
  135. } else {
  136. return []
  137. }
  138. }
  139. renderPreloadLinks (context: Object): string {
  140. const files = this.getPreloadFiles(context)
  141. const shouldPreload = this.options.shouldPreload
  142. if (files.length) {
  143. return files.map(({ file, extension, fileWithoutQuery, asType }) => {
  144. let extra = ''
  145. // by default, we only preload scripts or css
  146. if (!shouldPreload && asType !== 'script' && asType !== 'style') {
  147. return ''
  148. }
  149. // user wants to explicitly control what to preload
  150. if (shouldPreload && !shouldPreload(fileWithoutQuery, asType)) {
  151. return ''
  152. }
  153. if (asType === 'font') {
  154. extra = ` type="font/${extension}" crossorigin`
  155. }
  156. return `<link rel="preload" href="${
  157. this.publicPath}${file
  158. }"${
  159. asType !== '' ? ` as="${asType}"` : ''
  160. }${
  161. extra
  162. }>`
  163. }).join('')
  164. } else {
  165. return ''
  166. }
  167. }
  168. renderPrefetchLinks (context: Object): string {
  169. const shouldPrefetch = this.options.shouldPrefetch
  170. if (this.prefetchFiles) {
  171. const usedAsyncFiles = this.getUsedAsyncFiles(context)
  172. const alreadyRendered = file => {
  173. return usedAsyncFiles && usedAsyncFiles.some(f => f.file === file)
  174. }
  175. return this.prefetchFiles.map(({ file, fileWithoutQuery, asType }) => {
  176. if (shouldPrefetch && !shouldPrefetch(fileWithoutQuery, asType)) {
  177. return ''
  178. }
  179. if (alreadyRendered(file)) {
  180. return ''
  181. }
  182. return `<link rel="prefetch" href="${this.publicPath}${file}">`
  183. }).join('')
  184. } else {
  185. return ''
  186. }
  187. }
  188. renderState (context: Object, options?: Object): string {
  189. const {
  190. contextKey = 'state',
  191. windowKey = '__INITIAL_STATE__'
  192. } = options || {}
  193. const state = this.serialize(context[contextKey])
  194. const autoRemove = process.env.NODE_ENV === 'production'
  195. ? ';(function(){var s;(s=document.currentScript||document.scripts[document.scripts.length-1]).parentNode.removeChild(s);}());'
  196. : ''
  197. const nonceAttr = context.nonce ? ` nonce="${context.nonce}"` : ''
  198. return context[contextKey]
  199. ? `<script${nonceAttr}>window.${windowKey}=${state}${autoRemove}</script>`
  200. : ''
  201. }
  202. renderScripts (context: Object): string {
  203. if (this.clientManifest) {
  204. const initial = this.preloadFiles.filter(({ file }) => isJS(file))
  205. const async = (this.getUsedAsyncFiles(context) || []).filter(({ file }) => isJS(file))
  206. const needed = [initial[0]].concat(async, initial.slice(1))
  207. return needed.map(({ file }) => {
  208. return `<script src="${this.publicPath}${file}" defer></script>`
  209. }).join('')
  210. } else {
  211. return ''
  212. }
  213. }
  214. getUsedAsyncFiles (context: Object): ?Array<Resource> {
  215. if (!context._mappedFiles && context._registeredComponents && this.mapFiles) {
  216. const registered = Array.from(context._registeredComponents)
  217. context._mappedFiles = this.mapFiles(registered).map(normalizeFile)
  218. }
  219. return context._mappedFiles
  220. }
  221. // create a transform stream
  222. createStream (context: ?Object): TemplateStream {
  223. if (!this.parsedTemplate) {
  224. throw new Error('createStream cannot be called without a template.')
  225. }
  226. return new TemplateStream(this, this.parsedTemplate, context || {})
  227. }
  228. }
  229. function normalizeFile (file: string): Resource {
  230. const withoutQuery = file.replace(/\?.*/, '')
  231. const extension = path.extname(withoutQuery).slice(1)
  232. return {
  233. file,
  234. extension,
  235. fileWithoutQuery: withoutQuery,
  236. asType: getPreloadType(extension)
  237. }
  238. }
  239. function getPreloadType (ext: string): string {
  240. if (ext === 'js') {
  241. return 'script'
  242. } else if (ext === 'css') {
  243. return 'style'
  244. } else if (/jpe?g|png|svg|gif|webp|ico/.test(ext)) {
  245. return 'image'
  246. } else if (/woff2?|ttf|otf|eot/.test(ext)) {
  247. return 'font'
  248. } else {
  249. // not exhausting all possibilities here, but above covers common cases
  250. return ''
  251. }
  252. }