论 Node 的模块应不应该用 ES6 编写?

前言

哪怕是非 Node.js 开发者用户对 NPM 可能也不会陌生,毕竟它已经是世界最大的包管理仓库之一了。在 NPM 上大大小小的 JS 模块数不胜数,例如两年前一个叫做 Azer Koçulu 的开发者删除了自己发布在 NPM 上的一个叫 left-pad 的模块,该模块简单到仅仅就是一个功能函数而已,但是却影响到了大量的项目(大量项目引用这个 模块),可见 Node 模块的开发和使用成本之低。

可能对于很多 Node 开发者来说并不会涉及到模块开发,但是仍然有必要了解一个模块是如何诞生的。不过本文的目的并不是这个,实际上如果想知道如何开发模块文档资料一大堆,简单到不行。我想讨论的是在模块的开发过程中,究竟该不该以及能不能使用 ES6 编写?

兼容性

我们都知道,浏览器这东西是很糟心的。虽说 Node.js 更新迭代很快,对新特性支持已经足够了,但是浏览器却不尽然。哪怕是 Chrome 到如今也需要开启实验室功能才能充分的启用对 ES 新特性的支持,更别提那些不会再更新的毒瘤了(除了 IE 你还有谁……)。所以说,如果这个模块是为前端准备的,那么基本就能 PASS 掉了。

所以情况一:为浏览器环境兼容的模块不应该只提供 ES6 语法代码

再来来谈谈 Node 环境下的模块兼容性,是不是如果只支持 Node 环境的模块就可以使用纯 ES6 了呢?也不尽然…… 毕竟不同的 Node 版本对特性的支持有差异,特别是 6.x 以下的版本有巨大的差异。

所以情况二:即使仅兼容 Node 环境的模块最好也不要只提供 ES6 语法代码

也许你想打我脸了…… 不是有 babel 这种工具可以让模块用户自行转译吗?那如果我告诉你插件也会因为 ES6 语法的模块而造成兼容性问题呢。。。

问题重现

为了让大家清楚的认识到这个问题发生及存在的过程,我们一步一步的来。

首先我们创建一个模块项目(test-module),这是文件树:

.
├── index.js
└── package.json

package.json:

{
  "name": "test-module",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

index.js:

exports.ping = (seq) => {
    console.log(`pong:seq=${seq}`);
};

这就是一个最简的模块,暴露了一个 ping 函数,仅此而已。

接下来创建引用该模块的项目(blog-ping),文件树:

.
├── package.json
├── src
│   └── main.js
├── webpack.config.js
└── yarn.lock

package.json:

{
  "name": "blog-ping",
  "version": "1.0.0",
  "description": "博客示例项目 - ping",
  "author": "Hentioe",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-env": "^1.6.1",
    "webpack": "^3.10.0"
  },
  "dependencies": {
    "test-module": "file:../test-module"
  }
}

注意上面的 dependencies 中的 test-module 模块,它引用了上面创建的本地模块项目(你需要改成自己环境下的相对路径)。
你也可以删除掉 dependencies,然后执行 npm install <test-module absolute path> 它会自动添加并转换成相对路径。

webpack.config.js:

const path = require('path');
const webpack = require('webpack');
const buildPath = path.resolve(__dirname, 'dist');
const srcPath = __dirname + '/src';

module.exports = {
    entry: {
        bundle: srcPath + '/main.js'
    },
    output: {
        path: buildPath,
        filename: '[name].js'
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /(node_modules)/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['env']
                    }
                }
            }
        ]
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env': {
                NODE_ENV: '"production"'
            }
        }),
        new webpack.optimize.UglifyJsPlugin({
            compress: {
                drop_console: true,
                warnings: false
            }
        })
    ]
};

main.js

const ping =  require('test-module');

ping(1);

安装完依赖以后,我们启动 webpack 完成构建过程得到 bundle.js 文件:

yarn build # or: npm run build

通常来讲,就是控制台产生一些 webpack 的输出以后顺利的得到了 bundle.js 文件。但是,真实情况真的如此吗?

结果…… 真实情况还真实如此,控制台输出了一些乱七八糟的内容,然后在 dist 目录中产生了 bundle.js 文件。不过,不对劲的是输出中有错误产生:

ERROR in bundle.js from UglifyJs
Unexpected token: operator (>) [bundle.js:81,22]
error An unexpected error occurred: "Command failed.

是 UglifyJs 插件报错了,然后打开 bundle.js 发现果然是未压缩的版本…… 注意了,如此简单的项目结构,如此简单的模块依赖,完全正常且大众的 Webpack 配置,为什么会有插件执行失败呢?假设在生产环境下产生了未压缩的输出文件,岂不是造成了严重的浪费?

解决方案

在 Webpack3 中,所使用的 uglifyjs-webpack-plugin 版本为 0.4.6,这个版本实际上是很低的(暂时不提 Webpack4)。它并不能支持对 ES6 (或之上)的语言版本进行压缩,假若你在代码中导入了任何一个使用 ES6 语法的模块,都将会导致处理失败。

解决办法两种:

  1. 将 ES6 语法的模块交给 babel-loader 处理一遍
  2. 升级 uglifyjs-webpack-plugin 版本

第一种方式(修改 rules 中 babel-loader 的 exclude):

exclude: /(node_modules(?!\/test-module))/

这样对 test-module 进行导入的源码也会被 babel-loader 处理,转译为 ES5 代码。接着被 Webpack3 默认的 UglifyJs 处理的便是纯 ES5 代码了,构建会完美的通过。

第二种方式:

先安装 uglifyjs-webpack-plugin 依赖:

yarn add --dev uglifyjs-webpack-plugin

使用插件,需要删除 Webpack 自己的 UglifyJs plugin 配置,然后直接 new 一个手动导入的 UglifyJs 模块:

const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
 
module.exports = {
  plugins: [
    // other plugins……
    new UglifyJsPlugin()
  ]
}

可选配置不兼容 Webpack3 官方的,但是对于这个例子默认足够了。用这种方式压缩的话,ES6 的代码会保持原样…… 也就是说这个例子最终产生的被压缩的 bundle.js 代码中包含字符串模板和箭头函数。

两种方式分别对应两种不同的场景,为了保持兼容性应该使用前者,相反不在意兼容性的话第二种配置方式的构建速度更快。

结论

从上面的例子可以看出来,如果用 ES6 写模块不仅可能会造成兼容问题,还会给 Webpack 对代码的压缩带来麻烦(我估计还有许多人是不知道 Webpack3 默认依赖的 UglifyJs plugin 是不支持 ES6 语法的)。

还有就是既然我用 babel 那么我肯定是在意兼容性的,但是多数情况下我并不知道我导入的模块是否掺入了 ES6 代码,这就给我造成了许多未知隐患。

所以结论就是:如果要开发模块公开提供他人使用,应该提供 ES5 语法的版本。

至于怎么提供 ES5 版本的代码?其实想想就能明白了,有很多模块是还是用 TypeScript 开发的,为什么能正常被 JS 导入?因为这些模块开发也用了工程化的手段,上传的模块是最终编译输出的结果,而不是开发中的源码。

结束语

这个现象也是我偶然发现的,原本我以为 NPM 上的模块提供的应该都是 ES5 的代码。但是使用了一些不热门的小模块以后发现导入非纯 ES5 语法的代码给我造成了麻烦,而小模块的作者通常没有意识到这些,因为我在前言中说过了很多模块小到非常简单,纯属懒人开发者才会拿来用的那种。当然我的做法是直接将包含 ES6 语法的模块交给 babel-loader 处理了。假设我放过这些模块而直接使用新版 Uglify 插件,那么使用 babel 就是无意义的。