feat: modern mode

This commit is contained in:
Evan You
2018-06-12 23:51:19 -04:00
parent e15fa20bb6
commit 204d8f07de
9 changed files with 348 additions and 211 deletions
+9 -2
View File
@@ -50,7 +50,9 @@ module.exports = (context, options = {}) => {
const targets = process.env.VUE_CLI_BABEL_TARGET_NODE
? { node: 'current' }
: rawTargets
: process.env.VUE_CLI_MODERN_BUILD
? { esmodules: true }
: rawTargets
// included-by-default polyfills. These are common polyfills that 3rd party
// dependencies may rely on (e.g. Vuex relies on Promise), but since with
@@ -58,7 +60,12 @@ module.exports = (context, options = {}) => {
// be force-included.
let polyfills
const buildTarget = process.env.VUE_CLI_TARGET || 'app'
if (buildTarget === 'app' && useBuiltIns === 'usage' && !process.env.VUE_CLI_BABEL_TARGET_NODE) {
if (
buildTarget === 'app' &&
useBuiltIns === 'usage' &&
!process.env.VUE_CLI_BABEL_TARGET_NODE &&
!process.env.VUE_CLI_MODERN_BUILD
) {
polyfills = getPolyfills(targets, userPolyfills || defaultPolyfills, {
ignoreBrowserslistConfig,
configPath
+2 -1
View File
@@ -29,7 +29,8 @@ module.exports = (api, options) => {
.options(api.genCacheConfig('babel-loader', {
'@babel/core': require('@babel/core/package.json').version,
'@vue/babel-preset-app': require('@vue/babel-preset-app').version,
'babel-loader': require('babel-loader/package.json').version
'babel-loader': require('babel-loader/package.json').version,
modern: !!process.env.VUE_CLI_MODERN_BUILD
}, 'babel.config.js'))
.end()
@@ -21,7 +21,7 @@ module.exports = (api, options) => {
'--no-clean': `do not remove the dist directory before building the project`,
'--watch': `watch for changes`
}
}, async function build (args) {
}, async (args) => {
for (const key in defaults) {
if (args[key] == null) {
args[key] = defaults[key]
@@ -32,161 +32,168 @@ module.exports = (api, options) => {
args.entry = args.entry || 'src/App.vue'
}
const fs = require('fs-extra')
const path = require('path')
const chalk = require('chalk')
const webpack = require('webpack')
const formatStats = require('./formatStats')
const {
log,
done,
info,
logWithSpinner,
stopSpinner
} = require('@vue/cli-shared-utils')
if (options.modernMode && args.target === 'app') {
delete process.env.VUE_CLI_MODERN_BUILD
await build(Object.assign({}, args, {
modern: false
}), api, options)
log()
const mode = api.service.mode
if (args.target === 'app') {
logWithSpinner(`Building for ${mode}...`)
process.env.VUE_CLI_MODERN_BUILD = true
await build(Object.assign({}, args, {
modern: true,
clean: false
}), api, options)
} else {
const buildMode = buildModes[args.target]
if (buildMode) {
logWithSpinner(`Building for ${mode} as ${buildMode}...`)
} else {
throw new Error(`Unknown build target: ${args.target}`)
}
return build(args, api, options)
}
})
}
const targetDir = api.resolve(args.dest || options.outputDir)
async function build (args, api, options) {
const fs = require('fs-extra')
const path = require('path')
const chalk = require('chalk')
const webpack = require('webpack')
const formatStats = require('./formatStats')
const {
log,
done,
info,
logWithSpinner,
stopSpinner
} = require('@vue/cli-shared-utils')
// respect inline build destination in copy plugin
if (args.dest) {
api.chainWebpack(config => {
if (config.plugins.has('copy')) {
config.plugin('copy').tap(args => {
args[0][0].to = targetDir
return args
})
}
})
}
// resolve raw webpack config
process.env.VUE_CLI_BUILD_TARGET = args.target
let webpackConfig
if (args.target === 'lib') {
webpackConfig = require('./resolveLibConfig')(api, args, options)
} else if (
args.target === 'wc' ||
args.target === 'wc-async'
) {
webpackConfig = require('./resolveWcConfig')(api, args, options)
log()
const mode = api.service.mode
if (args.target === 'app') {
const bundleTag = options.modernMode
? args.modern
? `modern bundle `
: `legacy bundle `
: ``
logWithSpinner(`Building ${bundleTag}for ${mode}...`)
} else {
const buildMode = buildModes[args.target]
if (buildMode) {
logWithSpinner(`Building for ${mode} as ${buildMode}...`)
} else {
webpackConfig = api.resolveWebpackConfig()
if (args.entry && !options.pages) {
webpackConfig.entry = { app: api.resolve(args.entry) }
throw new Error(`Unknown build target: ${args.target}`)
}
}
const targetDir = api.resolve(args.dest || options.outputDir)
// resolve raw webpack config
process.env.VUE_CLI_BUILD_TARGET = args.target
let webpackConfig
if (args.target === 'lib') {
webpackConfig = require('./resolveLibConfig')(api, args, options)
} else if (
args.target === 'wc' ||
args.target === 'wc-async'
) {
webpackConfig = require('./resolveWcConfig')(api, args, options)
} else {
webpackConfig = require('./resolveAppConfig')(api, args, options)
}
// apply inline dest path after user configureWebpack hooks
// so it takes higher priority
if (args.dest) {
const applyDest = config => {
config.output.path = targetDir
}
if (Array.isArray(webpackConfig)) {
webpackConfig.forEach(applyDest)
} else {
applyDest(webpackConfig)
}
}
// grab the actual output path and check for common mis-configuration
const actualTargetDir = (
Array.isArray(webpackConfig)
? webpackConfig[0]
: webpackConfig
).output.path
if (args.watch) {
webpackConfig.watch = true
}
if (!args.dest && actualTargetDir !== api.resolve(options.outputDir)) {
// user directly modifies output.path in configureWebpack or chainWebpack.
// this is not supported because there's no way for us to give copy
// plugin the correct value this way.
console.error(chalk.red(
`\n\nConfiguration Error: ` +
`Avoid modifying webpack output.path directly. ` +
`Use the "outputDir" option instead.\n`
))
process.exit(1)
}
if (actualTargetDir === api.service.context) {
console.error(chalk.red(
`\n\nConfiguration Error: ` +
`Do not set output directory to project root.\n`
))
process.exit(1)
}
if (args.clean) {
await fs.remove(targetDir)
}
// Expose advanced stats
if (args.dashboard) {
const DashboardPlugin = require('../../webpack/DashboardPlugin')
;(webpackConfig.plugins = webpackConfig.plugins || []).push(new DashboardPlugin({
type: 'build'
}))
}
return new Promise((resolve, reject) => {
webpack(webpackConfig, (err, stats) => {
stopSpinner(false)
if (err) {
return reject(err)
}
}
// apply inline dest path after user configureWebpack hooks
// so it takes higher priority
if (args.dest) {
const applyDest = config => {
config.output.path = targetDir
if (stats.hasErrors()) {
return reject(`Build failed with errors.`)
}
if (Array.isArray(webpackConfig)) {
webpackConfig.forEach(applyDest)
} else {
applyDest(webpackConfig)
}
}
// grab the actual output path and check for common mis-configuration
const actualTargetDir = (
Array.isArray(webpackConfig)
? webpackConfig[0]
: webpackConfig
).output.path
if (args.watch) {
webpackConfig.watch = true
}
if (!args.dest && actualTargetDir !== api.resolve(options.outputDir)) {
// user directly modifies output.path in configureWebpack or chainWebpack.
// this is not supported because there's no way for us to give copy
// plugin the correct value this way.
console.error(chalk.red(
`\n\nConfiguration Error: ` +
`Avoid modifying webpack output.path directly. ` +
`Use the "outputDir" option instead.\n`
))
process.exit(1)
}
if (actualTargetDir === api.service.context) {
console.error(chalk.red(
`\n\nConfiguration Error: ` +
`Do not set output directory to project root.\n`
))
process.exit(1)
}
if (args.clean) {
await fs.remove(targetDir)
}
// Expose advanced stats
if (args.dashboard) {
const DashboardPlugin = require('../../webpack/DashboardPlugin')
;(webpackConfig.plugins = webpackConfig.plugins || []).push(new DashboardPlugin({
type: 'build'
}))
}
return new Promise((resolve, reject) => {
webpack(webpackConfig, (err, stats) => {
stopSpinner(false)
if (err) {
return reject(err)
}
if (stats.hasErrors()) {
return reject(`Build failed with errors.`)
}
if (!args.silent) {
const targetDirShort = path.relative(
api.service.context,
targetDir
)
log(formatStats(stats, targetDirShort, api))
if (args.target === 'app') {
if (!args.watch) {
done(`Build complete. The ${chalk.cyan(targetDirShort)} directory is ready to be deployed.\n`)
} else {
done(`Build complete. Watching for changes...`)
}
if (
options.baseUrl === '/' &&
// only log the tips if this is the first build
!fs.existsSync(api.resolve('node_modules/.cache'))
) {
info(`The app is built assuming that it will be deployed at the root of a domain.`)
info(`If you intend to deploy it under a subpath, update the ${chalk.green('baseUrl')} option`)
info(`in your project config (${chalk.cyan(`vue.config.js`)} or ${chalk.green('"vue"')} field in ${chalk.cyan(`package.json`)}).\n`)
}
if (!args.silent) {
const targetDirShort = path.relative(
api.service.context,
targetDir
)
log(formatStats(stats, targetDirShort, api))
if (args.target === 'app' && !(options.modernMode && !args.modern)) {
if (!args.watch) {
done(`Build complete. The ${chalk.cyan(targetDirShort)} directory is ready to be deployed.\n`)
} else {
done(`Build complete. Watching for changes...`)
}
if (
options.baseUrl === '/' &&
// only log the tips if this is the first build
!fs.existsSync(api.resolve('node_modules/.cache'))
) {
info(`The app is built assuming that it will be deployed at the root of a domain.`)
info(`If you intend to deploy it under a subpath, update the ${chalk.green('baseUrl')} option`)
info(`in your project config (${chalk.cyan(`vue.config.js`)} or ${chalk.green('"vue"')} field in ${chalk.cyan(`package.json`)}).\n`)
}
}
}
// test-only signal
if (process.env.VUE_CLI_TEST) {
console.log('Build complete.')
}
// test-only signal
if (process.env.VUE_CLI_TEST) {
console.log('Build complete.')
}
resolve()
})
resolve()
})
})
}
@@ -0,0 +1,37 @@
module.exports = (api, args, options) => {
const config = api.resolveChainableWebpackConfig()
const targetDir = api.resolve(args.dest || options.outputDir)
// respect inline build destination in copy plugin
if (args.dest && config.plugins.has('copy')) {
config.plugin('copy').tap(args => {
args[0][0].to = targetDir
return args
})
}
if (options.modernMode) {
const ModernModePlugin = require('../../webpack/ModernModePlugin')
const isModernBuild = !!process.env.VUE_CLI_MODERN_BUILD
if (!isModernBuild) {
// Inject plugin to extract build stats and write to disk
config
.plugin('modern-mode-legacy')
.use(ModernModePlugin, [targetDir, false])
} else {
// Inject plugin to read non-modern build stats and inject HTML
config
.plugin('modern-mode-modern')
.use(ModernModePlugin, [targetDir, true])
}
}
const rawConfig = config.toConfig()
// respect inline entry
if (args.entry && !options.pages) {
rawConfig.entry = { app: api.resolve(args.entry) }
}
return rawConfig
}
+71 -64
View File
@@ -10,6 +10,29 @@ module.exports = (api, options) => {
}
const isProd = process.env.NODE_ENV === 'production'
const isLegacyBundle = options.modernMode && !process.env.VUE_CLI_MODERN_BUILD
// code splitting
if (isProd) {
webpackConfig
.optimization.splitChunks({
cacheGroups: {
vendors: {
name: `chunk-vendors`,
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
},
common: {
name: `chunk-common`,
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
}
})
}
// HTML plugin
const resolveClientEnv = require('../util/resolveClientEnv')
@@ -38,7 +61,9 @@ module.exports = (api, options) => {
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
removeAttributeQuotes: true,
collapseBooleanAttributes: true,
removeScriptTypeAttributes: true
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
},
@@ -64,21 +89,23 @@ module.exports = (api, options) => {
.plugin('html')
.use(HTMLPlugin, [htmlOptions])
// inject preload/prefetch to HTML
webpackConfig
.plugin('preload')
.use(PreloadPlugin, [{
rel: 'preload',
include: 'initial',
fileBlacklist: [/\.map$/, /hot-update\.js$/]
}])
if (!isLegacyBundle) {
// inject preload/prefetch to HTML
webpackConfig
.plugin('preload')
.use(PreloadPlugin, [{
rel: 'preload',
include: 'initial',
fileBlacklist: [/\.map$/, /hot-update\.js$/]
}])
webpackConfig
.plugin('prefetch')
.use(PreloadPlugin, [{
rel: 'prefetch',
include: 'asyncChunks'
}])
webpackConfig
.plugin('prefetch')
.use(PreloadPlugin, [{
rel: 'prefetch',
include: 'asyncChunks'
}])
}
} else {
// multi-page setup
webpackConfig.entryPoints.clear()
@@ -107,37 +134,39 @@ module.exports = (api, options) => {
.use(HTMLPlugin, [pageHtmlOptions])
})
pages.forEach(name => {
const {
filename = `${name}.html`
} = normalizePageConfig(multiPageConfig[name])
webpackConfig
.plugin(`preload-${name}`)
.use(PreloadPlugin, [{
rel: 'preload',
includeHtmlNames: [filename],
include: {
type: 'initial',
entries: [name]
},
fileBlacklist: [/\.map$/, /hot-update\.js$/]
}])
if (!isLegacyBundle) {
pages.forEach(name => {
const {
filename = `${name}.html`
} = normalizePageConfig(multiPageConfig[name])
webpackConfig
.plugin(`preload-${name}`)
.use(PreloadPlugin, [{
rel: 'preload',
includeHtmlNames: [filename],
include: {
type: 'initial',
entries: [name]
},
fileBlacklist: [/\.map$/, /hot-update\.js$/]
}])
webpackConfig
.plugin(`prefetch-${name}`)
.use(PreloadPlugin, [{
rel: 'prefetch',
includeHtmlNames: [filename],
include: {
type: 'asyncChunks',
entries: [name]
}
}])
})
webpackConfig
.plugin(`prefetch-${name}`)
.use(PreloadPlugin, [{
rel: 'prefetch',
includeHtmlNames: [filename],
include: {
type: 'asyncChunks',
entries: [name]
}
}])
})
}
}
// copy static assets in public/
if (fs.existsSync(api.resolve('public'))) {
if (!isLegacyBundle && fs.existsSync(api.resolve('public'))) {
webpackConfig
.plugin('copy')
.use(require('copy-webpack-plugin'), [[{
@@ -146,27 +175,5 @@ module.exports = (api, options) => {
ignore: ['index.html', '.DS_Store']
}]])
}
// code splitting
if (isProd) {
webpackConfig
.optimization.splitChunks({
cacheGroups: {
vendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
}
})
}
})
}
+2 -1
View File
@@ -1,5 +1,6 @@
module.exports = (api, options) => {
api.chainWebpack(webpackConfig => {
const isLegacyBundle = options.modernMode && !process.env.VUE_CLI_MODERN_BUILD
const resolveLocal = require('../util/resolveLocal')
const getAssetPath = require('../util/getAssetPath')
const inlineLimit = 10000
@@ -12,7 +13,7 @@ module.exports = (api, options) => {
.end()
.output
.path(api.resolve(options.outputDir))
.filename('[name].js')
.filename(isLegacyBundle ? '[name]-legacy.js' : '[name].js')
.publicPath(options.baseUrl)
webpackConfig.resolve
+2 -1
View File
@@ -1,10 +1,11 @@
module.exports = (api, options) => {
api.chainWebpack(webpackConfig => {
if (process.env.NODE_ENV === 'production') {
const isLegacyBundle = options.modernMode && !process.env.VUE_CLI_MODERN_BUILD
const getAssetPath = require('../util/getAssetPath')
const filename = getAssetPath(
options,
`js/[name].[chunkhash:8].js`,
`js/[name]${isLegacyBundle ? `-legacy` : ``}.[chunkhash:8].js`,
true /* placeAtRootIfRelative */
)
+4
View File
@@ -4,6 +4,7 @@ const schema = createSchema(joi => joi.object({
baseUrl: joi.string().allow(''),
outputDir: joi.string(),
assetsDir: joi.string(),
modernMode: joi.boolean(),
runtimeCompiler: joi.boolean(),
transpileDependencies: joi.array(),
productionSourceMap: joi.boolean(),
@@ -54,6 +55,9 @@ exports.defaults = () => ({
// where to put static assets (js/css/img/font/...)
assetsDir: '',
// ship minimally-transpiled ES2015 along with a legacy bundle
modernMode: false,
// boolean, use full build?
runtimeCompiler: false,
@@ -0,0 +1,72 @@
const fs = require('fs-extra')
const path = require('path')
// https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
const Safari10NoModuleFix = `!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();`
class ModernModePlugin {
constructor (targetDir, isModern) {
this.targetDir = targetDir
this.isModern = isModern
}
apply (compiler) {
if (!this.isModern) {
this.applyLegacy(compiler)
} else {
this.applyModern(compiler)
}
}
applyLegacy (compiler) {
const ID = `vue-cli-legacy-bundle`
compiler.hooks.compilation.tap(ID, compilation => {
compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(ID, async (data, cb) => {
// get stats, write to disk
await fs.ensureDir(this.targetDir)
const htmlName = data.plugin.options.filename
const tempFilename = path.join(this.targetDir, `legacy-assets-${htmlName}.json`)
await fs.writeFile(tempFilename, JSON.stringify(data.body))
cb()
})
})
}
applyModern (compiler) {
const ID = `vue-cli-modern-bundle`
compiler.hooks.compilation.tap(ID, compilation => {
compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(ID, async (data, cb) => {
// use <script type="module"> for modern assets
const modernAssets = data.body.filter(a => a.tagName === 'script')
modernAssets.forEach(a => { a.attributes.type = 'module' })
// inject Safari 10 nomdoule fix
data.body.push({
tagName: 'script',
closeTag: true,
innerHTML: Safari10NoModuleFix
})
// inject links for legacy assets as <script nomodule>
const htmlName = data.plugin.options.filename
const tempFilename = path.join(this.targetDir, `legacy-assets-${htmlName}.json`)
const legacyAssets = JSON.parse(await fs.readFile(tempFilename, 'utf-8'))
.filter(a => a.tagName === 'script')
legacyAssets.forEach(a => { a.attributes.nomodule = '' })
data.body.push(...legacyAssets)
await fs.remove(tempFilename)
cb()
})
compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tap(ID, data => {
data.html = data.html
// use <link rel="modulepreload"> instead of <link rel="preload">
// for modern assets
.replace(/(<link as=script .*?)rel=preload>/g, '$1rel=modulepreload>')
.replace(/\snomodule="">/g, ' nomodule>')
})
})
}
}
module.exports = ModernModePlugin