dahong icon indicating copy to clipboard operation
dahong copied to clipboard

Webpack 构建策略 module 和 nomodule

Open shaodahong opened this issue 6 years ago • 0 comments

Webpack 构建策略 module 和 nomodule

前言

前端性能优化已经过了刀耕火种的年代,现在更多的优化是从代码层面,其中重中之重的当然是 JS 的优化,之前看到 React 16 加载性能优化指南这篇文章中有提到 ES2015+ 编译减少打包体积,核心就是依赖 <script type="module">的支持来分辨浏览器对 ES2015+ 代码的支持,并且可以用<script nomodule>进行优雅降级

浏览器支持

看一下 Can I use… Support tables for HTML5, CSS3, etc上面的支持情况

0e0793ab-0d8d-4c33-86cd-ceed9601eb76

除了 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

总得来说性能的提升结合公司的实际使用情况,尽可能的在构建层面解决掉,这样可以二分之一劳永逸

参考连接

  1. Deploying ES2015+ Code in Production Today — Philip Walton
  2. 在浏览器中使用JavaScript module(模块) – WEB骇客
  3. JavaScript modules:

shaodahong avatar Jul 11 '18 06:07 shaodahong