dahong
dahong copied to clipboard
Webpack 构建策略 module 和 nomodule
Webpack 构建策略 module 和 nomodule
前言
前端性能优化已经过了刀耕火种的年代,现在更多的优化是从代码层面,其中重中之重的当然是 JS 的优化,之前看到 React 16 加载性能优化指南这篇文章中有提到 ES2015+ 编译减少打包体积,核心就是依赖 <script type="module">
的支持来分辨浏览器对 ES2015+ 代码的支持,并且可以用<script nomodule>
进行优雅降级
浏览器支持
看一下 Can I use… Support tables for HTML5, CSS3, etc上面的支持情况
除了 IE 外,现在主流的现代浏览器基本上都得到了支持,尤其是 IOS 从 10.3 版本就开始支持了,这样在移动端的体验会大大增强,当然了 10.3 也会有个 BUG,大家可以看到上图的 10.3 有个 4 的标识,意思是
Does not support the nomodule attribute
不支持 nomodule 属性,这样带来的后果就是 10.3 版本的 IOS 同时执行两份 JS 文件,所以Safari 10.1 nomodule
support · GitHub上面也有 hack 写法
// 这个会解决 10.3 版本同时加载 nomodule 脚本的 bug,但是仅限于外部脚本,对于内联的是没用的
// fix 的核心就是利用 document 的 beforeload 事件来阻止 nomodule 标签的脚本加载
(function() {
var check = document.createElement('script');
if (!('noModule' in check) && 'onbeforeload' in check) {
var support = false;
document.addEventListener('beforeload', function(e) {
if (e.target === check) {
support = true;
} else if (!e.target.hasAttribute('nomodule') || !support) {
return;
}
e.preventDefault();
}, true);
check.type = 'module';
check.src = '.';
document.head.appendChild(check);
check.remove();
}
}());
语法支持
module 给我们带来好处就是支持 ES6 的语法,支持且不限于
- 箭头函数
const fn = () => {
}
- Promise
new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 1000)
})
- Class
class fn {
constructor () {
this.age = 100
}
}
- Import
import { doSome } from 'util.js'
Babel
想要支持 module 和 nomodule 核心就是 Babel,利用 Babel 我们可以编译出两份文件
script type="module" src="app.js"></script>
script nomodule src="app-legacy.js"></script>
legacy 是遗产的意思,在这里面叫做老旧的意思,理解成老旧的语法
Webpack
改造下 webpack,思路就是构建两次,分别用不同的 babel 配置
// index.js
const fs = require('fs-extra')
const babelSupport = require('./babel-support')
const merge = require('webpack-merge')
const webpackConfig = require(`./build`)
handle()
async function handle() {
// 构建前清空下构建的目标目录
await fs.remove('build')
await build(
merge(webpackConfig('es2015'), {
module: {
rules: [babelSupport('es2015')]
}
})
)
await build(
merge(webpackConfig('legacy'), {
module: {
rules: [babelSupport('legacy')]
}
})
)
}
async function build(webpackConfig) {
const compiler = webpack(webpackConfig)
return new Promise((resolve, reject) => {
compiler.run((err, status) => {
if (err) {
reject()
throw err
}
resolve()
})
})
}
利用 webpack-merge 我们可以轻松的得到想要的 webpack 配置,上面的代码可以看到我们在 handle 中 build 了两次,一次是 ES2015+ 的,一次是 legacy,接下来看下 build 的配置
// build.js
// base 是基础的配置
// 根据 target 我们构建出不同的文件名
module.exports = target => {
const isLegacy = target === 'legacy'
return merge(base, {
output: {
...
filename: isLegacy
? 'js/[name]-legacy.[chunkhash].js'
: 'js/[name].[chunkhash].js',
chunkFilename: isLegacy
? 'js/[name]-legacy.[chunkhash].js'
: 'js/[name].[chunkhash].js'
},
plugins: [
// 这里要对 HtmlWebpackPlugin 处理下,template 指的是源文件,构建两次,第一次构建的是 ES2015+,所以我们直接用 src 目录下的模板即可,第二次构建的 legacy 的,我们直接用构建目标目录的的模板就好了,这样构建完成后模板中会同时有两份文件
new HtmlWebpackPlugin({
template: isLegacy ? 'build/index.html' : 'src/index.html',
filename: 'index.html',
inject: 'body'
})
]
})
}
再来看下 babel 的动态配置
// babel-support.js
// 使用 babel 7 我们可以轻松的构建
// babel 7 的 preset-env 有个 esmodules 支持可以让我们直接编译到 ES2015+ 的语法,如果你使用的是 babel 6 的话那么可以自己去写对应的 browserlist
module.exports = target => {
const targets =
target === 'es2015'
? { esmodules: true }
: { browsers: ['ios >= 7', 'android >= 4.4'] }
return {
test: /\.js[x]?$/,
loader: 'babel-loader?cacheDirectory',
options: {
presets: [
[
'@babel/preset-env',
{
debug: false,
modules: false,
useBuiltIns: 'usage',
targets
}
],
[
'@babel/preset-stage-2',
{
decoratorsLegacy: true
}
]
]
}
}
}
这样构建出来的文件就会根据不同的 targets 实现不同的语法,接下来再来处理下模板中的 module 和 nomodule 属性,写个 HtmlWebpackPlugin 插件
// 把 IOS 10.3 的 fix 代码单独拎出来
const safariFix = `!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 ModuleHtmlPlugin {
constructor(isModule) {
this.isModule = isModule
}
apply(compiler) {
const id = 'ModuleHtmlPlugin'
// 利用 webpack 的核心事件 tap
compiler.hooks.compilation.tap(id, compilation => {
// 在 htmlWebpackPlugin 拿到资源的时候我们处理下
compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(
id,
(data, cb) => {
data.body.forEach(tag => {
//遍历下资源,把 script 中的 ES2015+ 和 legacy 的处理开
if (tag.tagName === 'script') {
// 给 legacy 的资源加上 nomodule 属性,反之加上 type="module" 的属性
if (/-legacy./.test(tag.attributes.src)) {
delete tag.attributes.type
tag.attributes.nomodule = ''
} else {
tag.attributes.type = 'module'
}
}
//在这一步加上 10.3 的 fix,很简单,就是往资源的数组里面的 push 一个资源对象
if (this.isModule) {
// inject Safari 10 nomdoule fix
data.body.push({
tagName: 'script',
closeTag: true,
innerHTML: safariFix
})
}
})
cb(null, data)
}
)
// 在 htmlWebpackPlugin 处理好模板的时候我们再处理下,把页面上 <script nomudule=""> 处理成 <script nomudule>,正则全局处理下
compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tap(id, data => {
data.html = data.html.replace(/\snomodule="">/g, ' nomodule>')
})
})
}
}
module.exports = ModuleHtmlPlugin
构建后出现两份 js 文件,用最新的 Chrome 跑一下运行正常,并且体积优化相比 legacy 减少 30%-50%,但更多的期待是浏览器对新语法的性能优化
后记
module 和 nomodule 虽然早已经不是2018年的技术点了,但是对于前端的性能优化也是开了一扇窗,但是也会遇到一些问题
下载两份
实测低版本的 Firefox 会下载两份 js 文件,但是只会执行一份,感兴趣的可以测试下其他的其他的浏览器,测试连接
Deploying ES2015+ Code in Production Today — Philip Walton
Import 路径
只支持显示的绝对和相对路径
// 支持
import { doSome } from '../utils.js'
import { doSome } from './utils.js'
// 不支持
import { doSome } from 'utils.js'
Defer
module 的脚本默认会像 <script defer>
一样加载,所以如果出现 JS 报错可以看下是不是在文档加载完成前就使用了文档的元素
CROS 跨域限制
// 不会执行
<script type="module" src="https://disanfang.com/cdn/react.js"></script>
凭证-credentials
这个是在 vue-cli 的 Modern mode failing to load module when under HTTP Basic Auth use credentials · Issue #1656 · vuejs/vue-cli · GitHub看到的,已经被 fix 掉了,具体的可以看下这个 issues
总得来说性能的提升结合公司的实际使用情况,尽可能的在构建层面解决掉,这样可以二分之一劳永逸