[译] 基于 Webpack 和 ES6 打造 JavaScript 类库
cssmagic opened this issue · 32 comments
[译] 基于 Webpack 和 ES6 打造 JavaScript 类库
- Original: Start your own JavaScript library using webpack and ES6
- Translated by: cssmagic
Two months ago I published a starter pack for React based on webpack. Today I found out that I need almost the same thing but without the React bit. This simplifies the setup but there are still some tricky parts. So, I made a brand new repository webpack-library-starter and placed all the stuff that we need for creating a JavaScript library.
两个月前,我曾发布了一篇基于 webpack 的 React 起步教程。你眼前的这篇文章跟那一篇差不多,只不过不包含 React 那一块。这篇教程稍微简单一些,但仍然会有一些棘手的部分。因此,我特意建了一个全新的代码仓库 webpack-library-starter,把创建一个 JavaScript 类库所需的所有素材都放了进去。
First of all, what I meant by saying “library”
首先,我们说的 “类库” 是指什么
My definition for library in the context of JavaScript is a piece of code that provides specific functionality. It does one thing and it is doing it well. In the ideal case should not depend on another library or framework. A good example for library is jQuery. React and Vue.js could be also considered a library.
在 JavaScript 语境中,我对类库的定义是 “提供了特定功能的一段代段”。一个类库只做一件事,并且把这件事做好。在理想情况下,它不依赖其它类库或框架。jQuery 就是一个很好的例子。React 或者 Vue.js 也可以认为是一个类库。
The library should:
一个类库应该:
- Be available for in-browser use. Understand including the library via
<script>
tag. - Be accessible through npm
- Be compatible with ES6(ES2015) module system, commonjs and amd specifications.
- 可以在浏览器环境下使用。也就是说,可以通过
<script>
标签来引入这个类库。 - 可以通过 npm 来安装。
- 兼容 ES6(ES2015) 的模块系统、CommonJS 和 AMD 模块规范。
It doesn’t matter what is used for developing the library. What is important is the file that is distributed. It should match the above requirements. I prefer to see libraries written in vanilla JavaScript though. It simply makes the contribution easier.
用什么来开发这个类库并不重要,重要的是我们最终产出的文件。它只要满足上述要求就行。尽管如此,我还是比较喜欢用原生 JavaScript 写成的类库,因为这样更方便其它人贡献代码。
Directory structure
目录结构
I choose the following directory structure:
我一般选择如下的目录结构:
+-- lib
| +-- library.js
| +-- library.min.js
+-- src
| +-- index.js
+-- test
Where src
contains the source files and lib
the final compiled version. This means that the entry point of the library is the file under lib
and not src
.
其中 src
目录用于存放源码文件,而 lib
目录用于存放最终编译的结果。这意味着类库的入口文件应该放在 lib
目录下,而不是 src
目录下。
The starter
起步动作
I really enjoy the new ES6 specification. The bad thing is that there is some significant tooling around it. Some day we’ll probably write such JavaScript without the need of transpiler but today that’s not the case. Usually we need some sort of Babel integration. Babel can convert our ES6 files to ES5 format but it is not meant to create bundles. Or in other words, if we have the following files:
我确实很喜欢最新的 ES6 规范。但坏消息是它身上绑了一堆的附加工序。也许将来某一天我们可以摆脱转译过程,所写即所得;但现在还不行。通常我们需要用到 Babel 来完成转译这件事。Babel 可以把我们的 ES6 文件转换为 ES5 格式,但它并不打算处理打包事宜。或者换句话说,如果我们有以下文件:
+-- lib
+-- src
+-- index.js (es6)
+-- helpers.js (es6)
And we apply Babel we’ll get:
然后我们用上 Babel,那我们将会得到:
+-- lib
| +-- index.js (es5)
| +-- helpers.js (es5)
+-- src
+-- index.js (es6)
+-- helpers.js (es6)
Or in other words Babel do not resolve the imports/requires. So we need a bundler and as you may guess my choice for that is webpack. What I want to achieve at the end is:
或者再换句话说,Babel 并不解析代码中的 import
或 require
指令。因此,我们需要一个打包工具,而你应该已经猜到了,我的选择正是 webpack。最终我想达到的效果是这样的:
+-- lib
| +-- library.js (es5)
| +-- library.min.js (es5)
+-- src
+-- index.js (es6)
+-- helpers.js (es6)
npm commands
npm 命令
npm provides a nice mechanism for running tasks - scripts. There should be at least three of those registered:
在运行任务方面,npm 提供了一套不错的机制——scripts(脚本)。我们至少需要注册以下三个脚本:
"scripts": {
"build": "...",
"dev": "...",
"test": "..."
}
npm run build
- this should produce a final minified version of our librarynpm run dev
- the same asbuild
but do not minify the result and keeps working in a watching modenpm run test
- runs the testsnpm run build
- 这个脚本用来生成这个类库的最终压缩版文件。npm run dev
- 跟build
类似,但它并不压缩代码;此外还需要启动一个监视进程。npm run test
- 用来运行测试。
Building the development version
构建开发版本
npm run dev
should fire webpack and should produce lib/library.js
file. We start from the webpack’s configuration file:
npm run dev
需要调用 webpack 并生成 lib/library.js
文件。我们从 webpack 的配置文件开始着手:
// webpack.config.js
var webpack = require('webpack');
var path = require('path');
var libraryName = 'library';
var outputFile = libraryName + '.js';
var config = {
entry: __dirname + '/src/index.js',
devtool: 'source-map',
output: {
path: __dirname + '/lib',
filename: outputFile,
library: libraryName,
libraryTarget: 'umd',
umdNamedDefine: true
},
module: {
loaders: [
{
test: /(\.jsx|\.js)$/,
loader: 'babel',
exclude: /(node_modules|bower_components)/
},
{
test: /(\.jsx|\.js)$/,
loader: "eslint-loader",
exclude: /node_modules/
}
]
},
resolve: {
root: path.resolve('./src'),
extensions: ['', '.js']
}
};
module.exports = config;
Even if you don’t have experience with webpack you may say what is this config file doing. We define the input (entry
) and the output (output
) of the compilation. The module
property says what should be applied against every file during processing. In our case this is babel and ESLint where ESLint is a used for checking the syntax and correctness of our code.
即使你还没有使用 webpack 的经验,你或许也可以看明白这个配置文件做了些什么。我们定义了这个编译过程的输入(entry
)和输出(output
)。那个 module
属性指定了每个文件在处理过程中将被哪些模块处理。在我们的这个例子中,需要用到 Babel 和 ESLint,其中 ESLint 用来校验代码的语法和正确性。
There is one tricky part where I spent couple of ours. It’s related to library
, libraryTarget
and umdNamedDefine
properties. First I tried without using them and the output of the library was something like this:
这里有一个坑,花了我不少的时间。这个坑是关于 library
、libraryTarget
和 umdNamedDefine
属性的。最开始我没有把它们写到配置中,结果编译结果就成了下面这个样子:
(function(modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) return installedModules[moduleId].exports;
var module = installedModules[moduleId] = {
exports: {},
id: moduleId,
loaded: false
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.loaded = true;
return module.exports;
}
__webpack_require__.m = modules;
__webpack_require__.c = installedModules;
__webpack_require__.p = "";
return __webpack_require__(0);
})([
function(module, exports) {
// ... my code here
}
]);
This is how every webpack compiled code looks like. It uses similar approach like browserify. There is a self-invoking function which receives all the modules used in our application. Every of them stays behind an index of the modules
array. In the code above we have only one and __webpack_require__(0)
effectively runs the code in our src/index.js
file.
经过 webpack 编译之后的文件差不多都是这个样子。它采用的方式跟 Browserify 很类似。编译结果是一个自调用的函数,它会接收应用程序中所用到的所有模块。每个模块都被存放到到 modules
数组中。上面这段代码只包含了一个模块,而 __webpack_require__(0)
实际上相当于运行 src/index.js
文件中的代码。
Having a bundle like this one do not fulfill all the requirements mentioned in the beginning of this article because we do not export anything. The file is meant to be dropped in a web page. However, adding library
, libraryTarget
and umdNamedDefine
makes webpack injecting a really nice snippet at the top:
光是得到这样一个打包文件,并没有满足我们在文章开头所提到的所有需求,因为我们还没有导出任何东西。这个文件的运行结果在网页中必定会被丢弃。不过,如果我们加上 library
、libraryTarget
和 umdNamedDefine
,就可以让 webpack 在文件顶部注入一小段非常漂亮的代码片断:
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("library", [], factory);
else if(typeof exports === 'object')
exports["library"] = factory();
else
root["library"] = factory();
})(this, function() {
return (function(modules) {
...
...
Setting libraryTarget
to umd
means using universal module definition for the final result. And indeed, this piece of code recognizes the environment and provides a proper bootstrapping mechanism for our library.
把 libraryTarget
设定为 umd
表示采用 通用模块定义 来生成最终结果。而且这段代码确实可以识别不同的运行环境,并为我们的类库提供一个妥当的初始化机制。
Building production version
构建生产环境所需的版本
The only one difference between development and production mode for webpack is the minification. Running npm run build
should produce a minified version - library.min.js
. webpack has a nice build-in plugin for that:
对 webpack 来说,开发阶段与生产阶段之间唯一的区别在于压缩。运行 npm run build
应该生成一个压缩版——library.min.js
。webpack 有一个不错的内置插件可以做到这一点:
// webpack.config.js
...
var UglifyJsPlugin = webpack.optimize.UglifyJsPlugin;
var env = process.env.WEBPACK_ENV;
var libraryName = 'library';
var plugins = [], outputFile;
if (env === 'build') {
plugins.push(new UglifyJsPlugin({ minimize: true }));
outputFile = libraryName + '.min.js';
} else {
outputFile = libraryName + '.js';
}
var config = {
entry: __dirname + '/src/index.js',
devtool: 'source-map',
output: { ... },
module: { ... },
resolve: { ... },
plugins: plugins
};
module.exports = config;
UglifyJsPlugin
does the job if we add it to the plugins
array. There is something else that we have to clarify. We need some conditional logic where we instruct webpack what kind of bundle to produce (production or development). One of the popular approaches is to define an environment variable and pass it from the command line. For example:
只要我们把 UglifyJsPlugin
加入到 plugins
数组中,它就可以完成这个任务。此外,还一些事情有待明确。我们还需要某种条件判断逻辑,来告诉 webpack 需要生成哪一种类型(“开发阶段” 还是 “生产阶段”)的打包文件。一个常见的做法是定义一个环境变量,并将它通过命令行传进去。比如这样:
// package.json
"scripts": {
"build": "WEBPACK_ENV=build webpack",
"dev": "WEBPACK_ENV=dev webpack --progress --colors --watch"
}
(Notice the --watch
option. It makes webpack continuously running and watching for changes)
(请留意 --watch
选项。它会让 webpack 监视文件变化并持续运行构建任务。)
Testing
测试
I’m usually using Mocha and Chai for testing and that’s what I added in the starter. Again there was a tricky part making Mocha understands ES6 files but thankfully to Babel the problem was resolved.
我通常采用 Mocha 和 Chai 来运行测试——测试环节是这篇起步教程特有的内容。这里同样存在一个棘手的问题,就是如何让 Mocha 正确识别用 ES6 写的测试文件。不过谢天谢地,Babel 再次解决了这个问题。
// package.json
"scripts": {
...
"test": "mocha --compilers js:babel-core/register --colors -w ./test/*.spec.js"
}
The important bit is the --compilers
option. It allows us to process the incoming file before running it.
这里最关键的部分在于 --compilers
这个选项。它允许我们在运行测试文件之前预先处理这个文件。
A few other configuration files
其它配置文件
Babel received some major changes in the newest version 6. We now have something called presets
where we describe what kind of transformation we want. One of the easiest ways to configure that is with a .babelrc
file:
在最新的 6.x 版本中,Babel 发生了一些重大的变化。现在,在指定哪些代码转换器将被启用时,我们需要面对一种叫作 presets
的东西。最简单配置的方法就是写一个 .babelrc
文件:
// .babelrc
{
"presets": ["es2015"],
"plugins": ["babel-plugin-add-module-exports"]
}
ESLint provides the same thing and we have .eslintrc
:
ESLint 也需要一个类似的配置文件,叫作 .eslintrc
:
// .eslintrc
{
"ecmaFeatures": {
"globalReturn": true,
"jsx": true,
"modules": true
},
"env": {
"browser": true,
"es6": true,
"node": true
},
"globals": {
"document": false,
"escape": false,
"navigator": false,
"unescape": false,
"window": false,
"describe": true,
"before": true,
"it": true,
"expect": true,
"sinon": true
},
"parser": "babel-eslint",
"plugins": [],
"rules": {
// ... lots of lots of rules here
}
}
Links
相关链接
The starter is available in GitHub here github.com/krasimir/webpack-library-starter.
这篇起步教程还可以在 GitHub 上找到:github.com/krasimir/webpack-library-starter。
Used tools:
用到的项目如下:
Dependencies:
具体依赖如下:
// package.json
"devDependencies": {
"babel": "6.3.13",
"babel-core": "6.1.18",
"babel-eslint": "4.1.3",
"babel-loader": "6.1.0",
"babel-plugin-add-module-exports": "0.1.2",
"babel-preset-es2015": "6.3.13",
"chai": "3.4.1",
"eslint": "1.7.2",
"eslint-loader": "1.1.0",
"mocha": "2.3.4",
"webpack": "1.12.9"
}
译者后记
是不是意犹未尽?其实准确来说,这篇文章是作者对 webpack-library-starter 项目的一个简要解说,讲解了代码之外的背景知识。
因此,作为学习者,光读文章是远远不够的,我们真正需要的是研读这个项目提供的源码,并且动手实际操作和演练,如此方能掌握要领。加油!
本文在 “CSS魔法” 微信公众号首发,扫码立即订阅:
(占楼备用)
(@ο@) 哇~
赞一个~
第一次这么前
学习了,赞!
“library”译为“库”或“代码库”可能比“类库”更准确些,因为传统上js库可能并不以“类”的形式导出。
👍
Nice
Good
试了一下,我发现最终出来的结果虽然是umd的,但是比如在浏览器上访问的时候,本来预期Wind应该是一个类,但是Wind.default才是,不知是否是要设置什么属性么?
@leozdgao 我早就试过了,跟我预期的结果不一样。
let Wind = function(){
alert(123)
}
export default Wind
在浏览器中预期的结果是
期望的结果:
window.Wind() // alert 123
实际的结果:
window.Wind() //Uncaught TypeError: Wine is not a function
在控制台中,看了下Wind
,得到的结果:
{
default:function Wind(){}
}
所以访问时需要:window.Wind.default() // alert 123 成功。
所以我才觉得这样不应该,想来询问是否是少了某个属性?webpack 打包的
好吧,我应该明白你的意思了,之前是我会错意了。
由于之前遇到过类似的坑,不清楚有没有改变 Babel 编译结果的可能,我目前统一使用 commonjs 的模块导出语法。由于我清楚 import ... from ...
的编译结果,所以有的时候为了方便我模块导入语法用的时 ES6 的语法。
@icepy
赞同 @leozdgao 的回答。我展开聊一下,如有错漏,也请大家指正。
主要问题在于 ES6 和 CommonJS 的模块特性并不是完全对应的,在相互转换的过程中难免出现一些信息丢失或错位。如果要给浏览器用,则需要经历 ES6 → CommonJS → UMD 这样的转换和包装过程,你在 ES6 源码中的意图无法准确传达到浏览器环境。
不应该啊,不然那用ES6来写就没意义了,API或者接口不统一啊,每一个都要添加一个
default
。
你可能是用 ES6 的默认导出方式(export default class MyClass {}
)来导出这个类的,Babel 会把 MyClass
这个默认接口转换为 CommonJS 模块的 exports
上的 default
属性,然后整个 exports
会并被 UMD 赋给 window
的一个属性(你给这个属性起的名字是 Wind
)。
如果讨厌 default
这个名字,可以采用 ES6 的具名导出方式(export class MyClass {}
)。此时 MyClass
会被 Babel 转换为 CommonJS 的 exports.MyClass
,然后 exports
被 UMD 赋给 window.Wind
。在浏览器中实际使用时,需要用 Wind.MyClass
来调用你写的类。
如果觉得后者还是不理想,则只有用 CommonJS 模块来导出你的类(module.exports = class MyClass {}
),这样 UMD 会把 MyClass
用你指定的名字(比如 Wind
)直接挂到 window
上。此时在浏览器中,全局的 Wind
就是你写的类了。
在最后一种方法中,虽然你写的是 CommonJS 模块,但你在这个模块内仍然可以使用 ES6 语法(包括 class 等)。只不过在这种情况下 Babel 只需要编译 ES6 语法,不需要转换模块格式。
@icepy:
……想来询问是否是少了某个属性?webpack 打包的
你看一下 Babel 对 ES6 模块的转译结果,以及 UMD 对 CommonJS 模块的包装原理,应该就明白了。
@cssmagic 是换成了CommonJS的语法,没用export和default,才和我预期的结果一致,我以为Babel有什么属性可以设置,直接转译成module.exports = Wind
,然后UMD包装成root['Wind']
= factory(),这个factory中return 的就是从__webpack包装里取出来的Wind,谢谢啦。
@icepy 前段时间刚在微博上讨论过 babel 从 ES6 module 转到 CommonJS 模块的话题,在这里刚好复制一下,可以解答你的疑问
- Babel 的这个特性是 by design 的,理由可以看这篇文章。大概就是说 babel 5- 的行为(模块中有且仅有
export default
语句时把它转换为module.exports =
)与其他情况下的表现不具有一致性、如果模块后续增加了具名导出的话容易在开发者未察觉的情况下破坏接口一致性,等等 - 官方 issue 的链接是这个
- 如果非得要用的话可以引入插件 babel-plugin-add-module-exports
babel-plugin-add-module-exports 好物 👍
🌹 感谢~
找了好久,终于看到这么清晰明了的配置了
现在我有一个依赖 jQuery 的库:
import $ from 'jquery'
...
我在作者原文下找到 krasimir/webpack-library-starter#3 ,所以我这样写了 externals:
externals: {
'jquery': 'jQuery'
}
这样的话,我的库就很小了。但是,当我使用我的库并且用 webpack 打包的时候,发现我的库和jquery打包在了一起。
这种情况怎么解?
当我使用我的库并且用 webpack 打包的时候,发现我的库和jquery打包在了一起。
简单说一下我的猜测:使用你的库的时候(此时你的库已经不再是主项目,而是和 jQuery 一样是依赖),Webpack 的配置同样需要加上这段 externals
。
感谢。
赞一个
感谢,最近在找这个打包功能~
非感谢译者,刚好在找这方面的文章
謝謝大佬啊,找了一個下午的資料,看到這個文章太棒了真的!