likes
comments
collection
share

前端项目终于在生产环境用上es6了

作者站长头像
站长
· 阅读数 9

背景

目前很多前端项目虽然采用了很多新技术,但是最终还是会被编译成es5,这样就能在较低版本的浏览器中运行了,但现实情况是现在的浏览器都已经比较成熟,压根不用编译成es5再运行了,而谷歌一位工程师在17年就分享了一篇文章deploying-es2015-code-in-production-today(网上也有很多译文),给出了比较实用的技术方案,但可惜的是绝大多数项目依然遵循旧的开发方式,让项目更加臃肿,运行也变慢。是时候做出改变了,而今天就想谈谈前端项目怎么整合相关技术方案实现es5/es6两套代码动态加载,最终能在新版浏览器中跑es6的目标。

技术方案

本篇的技术方案基本上是在上面提到的那篇文章的延伸,简单来说,改造前后的区别如下:

改造前:

  1. js modules编译成es5版本的bundle;
  2. 浏览器加载js运行; 改造后:
  3. js modules编译成es5和es6两个版本的bundle;
  4. 判断浏览器是否支持es6,然后决定动态加载对应版本的bundle; 只要你看了参考文章【deploying-es2015-code-in-production-today】,你会发现他是这样做的:
<!-- Browsers with ES module support load this file. --> 
<script type="module" src="main.mjs"></script> 

<!-- Older browsers load this file (and module-supporting --> 
<!-- browsers know *not* to load this file). --> 
<script nomodule src="main.es5.js"></script>

会有一个问题,就是有些浏览器会把所有的js都下载下来,然后执行其中的支持一个,虽然作者也说没多大问题,并给出了一大段理由,但是我就是不想下载那么多无用的js,因此我的实现就不是直接引入入口bundle,而是先判断一下需要加载哪个版本,然后动态创建标签引入。其实整体实现不难,只是需要改造一下webpack的配置即可。

实现思路

双版本输出

这里利用webpack本身支持多版本同时编译输出的功能,例如官网给出的示例:

module.exports = [{
  output: {
    filename: './dist-amd.js',
    libraryTarget: 'amd'
  },
  name: 'amd',
  entry: './app.js',
  mode: 'production',
}, {
  output: {
    filename: './dist-commonjs.js',
    libraryTarget: 'commonjs'
  },
  name: 'commonjs',
  entry: './app.js',
  mode: 'production',
}];

两个版本的差异就体现在babel-loader的配置上,改造之后的配置如下(无关紧要配置省略):

=== before ===
const configs = {
    output: {
        ...
        filename: '[name]-[fullhash:10]' +'.js',
        chunkFilename: '[name]-[fullhash:10]' +'.js',
    },
};
module.exports = configs;

=== after ===
function createConfigs(ecmaVersion = 'es2015') {
    return {
        output: {
            ...
            filename: ecmaVersion +'_[name]-[fullhash:10]' +'.js',
            chunkFilename: ecmaVersion +'_[name]-[fullhash:10]' +'.js',
        },
        module: {
            rules: [{
                loader: 'babel-loader',
		options: {
                    presets: [[
			"@babel/preset-env", {
                            useBuiltIns: "entry",
                            targets: ecmaVersion === 'es6' ? { chrome: "71" } : { chrome: "58", ie: "11" }
                    }]
                }
            }]
        }
    }; 
}
module.exports = [createConfigs(), createConfigs('es5')];

在改造过程中,还遇到了一些问题,例如clean-webpack-plugin插件,会把第一次生成的静态资源都删除了,因此项目就不使用这个插件了,自己实现一个简单的脚本,在编译之前去删除编译的目标文件夹;

"scripts": {
    "clean": "node ./scripts/cleanDist.js",
    "build": "npm run clean && ...."
}

cleanDist文件很简单,也满足我们的需求了:

// scripts/cleanDist.js
const fs = require('fs');
const path = require('path');

function cleanDist(dir = path.join(__dirname, '..', 'dist')) {
    fs.rmSync(dir, {
        force: true,
        recursive: true,
    });
}

cleanDist();

经过上面的改造,成功编译后的文件就像下面这样了,通过前缀就知道对应的版本:

前端项目终于在生产环境用上es6了

初始化脚本注入

生成的html文件默认会包含所有入口bundle文件,但是按照我们的需求,这里默认不引入任何bundle(css不受影响),因此需要改造HtmlWebpackPlugin插件:

new HtmlWebpackPlugin({
    inject: DEBUG, // 本地开发,还是直接注入,生产模式则不自动注入
    ......
    templateParameters: (compilation, assets, assetTags, options) => {
        // 过滤掉所有script标签
	assetTags.headTags = assetTags.headTag.filter((tag) => {
            tag.tagName !== 'script';
        });
	assetTags.bodyTags = assetTags.bodyTags.filter((tag) => {
            tag.tagName !== 'script';
        });
	return {
            compilation,
            webpackConfig: compilation.options,
            htmlWebpackPlugin: {
		tags: assetTags,
		files: assets,
		options
            },
	};
    }
}),

html模板页也要进行改造:

<!DOCTYPE html>
<html class="borderbox">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1">
        <meta name="renderer" content="webkit">
        <%= htmlWebpackPlugin.tags.headTags %>
    </head>
    <body>
        <div id="app"></div>
        <%= htmlWebpackPlugin.tags.bodyTags %>
    </body>
</html>

这样就行了么,还是不行啊,我们要的效果是这样的:

<script type="text/javascript">
(function() {
    // 所有版本的bundle列表
    var es2015 = ["/resources/js/es2015_197-697d619283-1.0.0.js","/resources/js/es2015_main-697d619283-1.0.0.js"];
    var es5 = ["/resources/js/es5_197-6cc8e71f60-1.0.0.js","/resources/js/es5_main-6cc8e71f60-1.0.0.js"];
    
    if (support es2015) {
        load(es2015);
    } else {
        load(es5);
    }
}());
</script>

这里的问题在于要获取到两次编译的js列表,而webpack的两次编译都是独立的,因此需要将这两个数组缓存起来,等编译都完成后,再用脚本重写html页面,实现上面的结构。

在配置HtmlWebpackPlugin过滤掉所有script标签的时候,其实就知道了本次编译的js列表,保存到本地即可,

function appendTagToCache(ecmaVersion, injectTags) {
    fs.appendFileSync('./cache.js', `exports.${ecmaVersion} = ${JSON.stringify(injectTags)};\n\r`);
}
......
templateParameters: (compilation, assets, assetTags, options) => {
    // 保存所有js列表到本地
    appendTagToCache(ecmaVersion, assets.js);
    return ...;
}

得到了所有的js列表和对应的版本,这样就只差最后一步了:重写html。

// package.json

"scripts": {
    "injectScripts": "node ./scripts/injectScripts.js",
}
// scripts/injectScripts.js

const fs = require('fs');
const path = require('path');

const tagCache = require('./cache.js'); // 缓存的所有js列表文件
const htmlPath = path.join(__dirname, '../', 'dist', 'index.html');

function getVars() {
    let vars = ``;
    for (const [key, value] of Object.entries(tagCache)) {
        vars += `var ${key} = ${JSON.stringify(value)};\n`;
    }
    return vars;
}

const scriptStr = `
<script type="text/javascript">
(function() {
    function createScript(src) {
        var s = document.createElement('script');
        s.type = 'text/javascript';
        s.src = src;
        document.body.appendChild(s);
    }
    function injectTags(srcArray) {
        for (var i = 0; i < srcArray.length; i++) {
            createScript(srcArray[i]);
        }
    }
    ${getVars()}
    var check = document.createElement('script');
    if (check.noModule === false) { // support es6
        injectTags(es6);
    } else {
        injectTags(es5);
    }
}());
</script>
</body>
`;

const templateContent = fs.readFileSync(htmlPath, { encoding: 'utf8' });
fs.writeFileSync(htmlPath, templateContent.replace('</body>', scriptStr));

最后记得编译完之后运行npm run injectScripts,最终就如我们所愿了,到此也就完成了动态加载不同版本的目标了。