diff --git a/.eslintignore b/.eslintignore index d0509d1dd..f5e8d8571 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ node_modules template packages/test +temp diff --git a/packages/@vue/cli-service/lib/config/base.js b/packages/@vue/cli-service/lib/config/base.js index 03645040a..25c1e35b9 100644 --- a/packages/@vue/cli-service/lib/config/base.js +++ b/packages/@vue/cli-service/lib/config/base.js @@ -104,6 +104,22 @@ module.exports = (api, options) => { template: api.resolve('public/index.html') }]) + // inject preload/prefetch to HTML + const PreloadPlugin = require('../util/PreloadPlugin') + webpackConfig + .plugin('preload') + .use(PreloadPlugin, [{ + rel: 'preload', + include: 'initial' + }]) + + webpackConfig + .plugin('prefetch') + .use(PreloadPlugin, [{ + rel: 'prefetch', + include: 'asyncChunks' + }]) + webpackConfig .plugin('define') .use(require('webpack/lib/DefinePlugin'), [resolveClientEnv()]) diff --git a/packages/@vue/cli-service/lib/config/prod.js b/packages/@vue/cli-service/lib/config/prod.js index 0a5706864..5afa3909a 100644 --- a/packages/@vue/cli-service/lib/config/prod.js +++ b/packages/@vue/cli-service/lib/config/prod.js @@ -82,6 +82,21 @@ module.exports = (api, options) => { minChunks: Infinity }]) + // inline the manifest chunk into HTML + webpackConfig + .plugin('inline-manifest') + .use(require('../util/InlineSourcePlugin'), [{ + include: /manifest\..*\.js$/ + }]) + + // since manifest is inlined, don't preload it anymore + webpackConfig + .plugin('preload') + .tap(([options]) => { + options.fileBlacklist = [/\.map$/, /manifest\..*\.js$/] + return [options] + }) + // This instance extracts shared chunks from code splitted chunks and bundles them // in a separate chunk, similar to the vendor chunk // see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk diff --git a/packages/@vue/cli-service/lib/util/InlineSourcePlugin.js b/packages/@vue/cli-service/lib/util/InlineSourcePlugin.js new file mode 100644 index 000000000..648edf1d5 --- /dev/null +++ b/packages/@vue/cli-service/lib/util/InlineSourcePlugin.js @@ -0,0 +1,117 @@ +/* + The MIT License (MIT) + Copyright (c) 2016 Jan Nicklas + https://github.com/DustinJackson/html-webpack-inline-source-plugin/blob/master/LICENSE + + Modified by Yuxi Evan You +*/ + +const path = require('path') +const slash = require('slash') +const sourceMapUrl = require('source-map-url') +const escapeRegex = require('escape-string-regexp') + +module.exports = class InlineSourcePlugin { + constructor (options = {}) { + this.options = options + } + + apply (compiler) { + // Hook into the html-webpack-plugin processing + compiler.plugin('compilation', compilation => { + compilation.plugin('html-webpack-plugin-before-html-generation', (htmlPluginData, callback) => { + callback(null, htmlPluginData) + }) + compilation.plugin('html-webpack-plugin-alter-asset-tags', (htmlPluginData, callback) => { + if (!this.options.include) { + return callback(null, htmlPluginData) + } + const regex = this.options.include + const result = this.processTags(compilation, regex, htmlPluginData) + callback(null, result) + }) + }) + } + + processTags (compilation, regex, pluginData) { + const processTag = tag => this.processTag(compilation, regex, tag) + return { + head: pluginData.head.map(processTag), + body: pluginData.body.map(processTag), + plugin: pluginData.plugin, + chunks: pluginData.chunks, + outputName: pluginData.outputName + } + } + + processTag (compilation, regex, tag) { + let assetUrl + + // inline js + if (tag.tagName === 'script' && regex.test(tag.attributes.src)) { + assetUrl = tag.attributes.src + tag = { + tagName: 'script', + closeTag: true, + attributes: { + type: 'text/javascript' + } + } + + // inline css + } else if (tag.tagName === 'link' && regex.test(tag.attributes.href)) { + assetUrl = tag.attributes.href + tag = { + tagName: 'style', + closeTag: true, + attributes: { + type: 'text/css' + } + } + } + + if (assetUrl) { + // Strip public URL prefix from asset URL to get Webpack asset name + const publicUrlPrefix = compilation.outputOptions.publicPath || '' + const assetName = path.posix.relative(publicUrlPrefix, assetUrl) + const asset = compilation.assets[assetName] + // do not emit inlined assets + delete compilation.assets[assetName] + const updatedSource = this.resolveSourceMaps(compilation, assetName, asset) + tag.innerHTML = (tag.tagName === 'script') ? updatedSource.replace(/(<)(\/script>)/g, '\\x3C$2') : updatedSource + } + + return tag + } + + resolveSourceMaps (compilation, assetName, asset) { + let source = asset.source() + const out = compilation.outputOptions + // Get asset file absolute path + const assetPath = path.join(out.path, assetName) + // Extract original sourcemap URL from source string + if (typeof source !== 'string') { + source = source.toString() + } + const mapUrlOriginal = sourceMapUrl.getFrom(source) + // Return unmodified source if map is unspecified, URL-encoded, or already relative to site root + if (!mapUrlOriginal || mapUrlOriginal.indexOf('data:') === 0 || mapUrlOriginal.indexOf('/') === 0) { + return source + } + // Figure out sourcemap file path *relative to the asset file path* + const assetDir = path.dirname(assetPath) + const mapPath = path.join(assetDir, mapUrlOriginal) + const mapPathRelative = path.relative(out.path, mapPath) + // Starting with Node 6, `path` module throws on `undefined` + const publicPath = out.publicPath || '' + // Prepend Webpack public URL path to source map relative path + // Calling `slash` converts Windows backslashes to forward slashes + const mapUrlCorrected = slash(path.join(publicPath, mapPathRelative)) + // Regex: exact original sourcemap URL, possibly '*/' (for CSS), then EOF, ignoring whitespace + const regex = new RegExp(escapeRegex(mapUrlOriginal) + '(\\s*(?:\\*/)?\\s*$)') + // Replace sourcemap URL and (if necessary) preserve closing '*/' and whitespace + return source.replace(regex, (match, group) => { + return mapUrlCorrected + group + }) + } +} diff --git a/packages/@vue/cli-service/lib/util/PreloadPlugin.js b/packages/@vue/cli-service/lib/util/PreloadPlugin.js new file mode 100644 index 000000000..fbf0d0da8 --- /dev/null +++ b/packages/@vue/cli-service/lib/util/PreloadPlugin.js @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modified by Yuxi Evan You + */ + +const flatten = arr => arr.reduce((prev, curr) => prev.concat(curr), []) +const getValues = obj => Object.keys(obj).map(key => obj[key]) + +const doesChunkBelongToHTML = (chunk, roots, visitedChunks) => { + // Prevent circular recursion. + // See https://github.com/GoogleChromeLabs/preload-webpack-plugin/issues/49 + if (visitedChunks[chunk.renderedHash]) { + return false + } + visitedChunks[chunk.renderedHash] = true + + for (const root of roots) { + if (root.hash === chunk.renderedHash) { + return true + } + } + + for (const parent of chunk.parents) { + if (doesChunkBelongToHTML(parent, roots, visitedChunks)) { + return true + } + } + + return false +} + +const defaultOptions = { + rel: 'preload', + include: 'asyncChunks', + fileBlacklist: [/\.map/] +} + +module.exports = class PreloadPlugin { + constructor (options) { + this.options = Object.assign({}, defaultOptions, options) + } + + apply (compiler) { + const options = this.options + compiler.plugin('compilation', compilation => { + compilation.plugin('html-webpack-plugin-before-html-processing', (htmlPluginData, cb) => { + let filesToInclude = '' + let extractedChunks = [] + // 'asyncChunks' are chunks intended for lazy/async loading usually generated as + // part of code-splitting with import() or require.ensure(). By default, asyncChunks + // get wired up using link rel=preload when using this plugin. This behaviour can be + // configured to preload all types of chunks or just prefetch chunks as needed. + if (options.include === undefined || options.include === 'asyncChunks') { + try { + extractedChunks = compilation.chunks.filter(chunk => !chunk.isInitial()) + } catch (e) { + extractedChunks = compilation.chunks + } + } else if (options.include === 'initial') { + try { + extractedChunks = compilation.chunks.filter(chunk => chunk.isInitial()) + } catch (e) { + extractedChunks = compilation.chunks + } + } else if (options.include === 'all') { + // Async chunks, vendor chunks, normal chunks. + extractedChunks = compilation.chunks + } else if (Array.isArray(options.include)) { + // Keep only user specified chunks + extractedChunks = compilation + .chunks + .filter((chunk) => { + const chunkName = chunk.name + // Works only for named chunks + if (!chunkName) { + return false + } + return options.include.indexOf(chunkName) > -1 + }) + } + + const publicPath = compilation.outputOptions.publicPath || '' + + // Only handle the chunk import by the htmlWebpackPlugin + extractedChunks = extractedChunks.filter(chunk => doesChunkBelongToHTML( + chunk, getValues(htmlPluginData.assets.chunks), {})) + + flatten(extractedChunks.map(chunk => chunk.files)).filter(entry => { + return this.options.fileBlacklist.every(regex => regex.test(entry) === false) + }).forEach(entry => { + entry = `${publicPath}${entry}` + if (options.rel === 'preload') { + // If `as` value is not provided in option, dynamically determine the correct + // value depends on suffix of filename. Otherwise use the given `as` value. + let asValue + if (!options.as) { + if (entry.match(/\.css$/)) asValue = 'style' + else if (entry.match(/\.woff2$/)) asValue = 'font' + else asValue = 'script' + } else if (typeof options.as === 'function') { + asValue = options.as(entry) + } else { + asValue = options.as + } + const crossOrigin = asValue === 'font' ? 'crossorigin="crossorigin" ' : '' + filesToInclude += `\n` + } else { + // If preload isn't specified, the only other valid entry is prefetch here + // You could specify preconnect but as we're dealing with direct paths to resources + // instead of origins that would make less sense. + filesToInclude += `\n` + } + }) + if (htmlPluginData.html.indexOf('') !== -1) { + // If a valid closing is found, update it to include preload/prefetch tags + htmlPluginData.html = htmlPluginData.html.replace('', filesToInclude + '') + } else { + // Otherwise assume at least a is present and update it to include a new + htmlPluginData.html = htmlPluginData.html.replace('', '' + filesToInclude + '') + } + cb(null, htmlPluginData) + }) + }) + } +} diff --git a/packages/@vue/cli-service/package.json b/packages/@vue/cli-service/package.json index 886bbaa5b..67f037c66 100644 --- a/packages/@vue/cli-service/package.json +++ b/packages/@vue/cli-service/package.json @@ -29,6 +29,7 @@ "copy-webpack-plugin": "^4.3.1", "cross-spawn": "^5.1.0", "css-loader": "^0.28.7", + "escape-string-regexp": "^1.0.5", "extract-text-webpack-plugin": "^3.0.2", "file-loader": "^1.1.6", "friendly-errors-webpack-plugin": "^1.6.1", @@ -45,6 +46,8 @@ "read-pkg": "^3.0.0", "rimraf": "^2.6.2", "semver": "^5.4.1", + "slash": "^1.0.0", + "source-map-url": "^0.4.0", "string.prototype.padend": "^3.0.0", "uglifyjs-webpack-plugin": "^1.1.4", "url-loader": "^0.6.2",