从零搭建webpack前端类库脚手架[4]-实践

本节我将开始实践操刀,进行前端类库脚手架的搭建。为了完成这一目标,我们用到了webpack的所有导出一个库library的理论知识。

npm包

npm是node.js自带的包管理工具。现在包括node在内的前端模块,都已经使用npm发布和管理。俨然,npm已成为前端领域的包管理标准平台

package.json

在npm包管理器中,每个项目/模块都有一个 package.json 文件,该文件用来记录本包的基本配置以及依赖等信息。有几个主要的字段需要注意:

  • peerDependencies
    peerDependencies相对难以理解,它表示本包所依赖的主工具以及主工具的版本要求。比如有个包叫做 babel-loader, 这个模块是webpack程序所依赖的一个 loader程序。 因此其依赖关系是这样的: “webpack运行时需要跟babel-loader配合”,也就是说 babel-loader 并不是webpack项目的开发过程依赖,也不是webpack项目运行时必须的一个依赖。而是说 babel-loader 要跟一个webpack来配合使用,可以理解为 webpack是babel-loader的一个运行环境。所以peerDependencies指的就是运行环境的依赖
  • devDependencis这个是开发依赖。指的是我们在开发一个项目时,在开发阶段需要用到的一些工具。比如开发一个前端类库,webpack打包程序就是这个项目的开发依赖。babel-loader、mocha、babel的一些插件也都是项目的开发依赖。

peerDependencies, devDependencies的区别

npm包管理工具就提供了一种package.json的机制,来告诉插件所依赖的主工具以及主工具的版本要求。 那就是 peerDependencies.

如,babel-loader插件就需要webpack来运行(被webpack来用,也相当于说webpack在做某些操作时要依赖babel-loader). 其package.json中指定的webpack版本号,就是希望webpack这个宿主运行时候的版本号(就是说我babel-loader要给谁用以及给哪个版本的谁用)

所以babel-loader这个项目的作者在开发这个包自身时,不应该安装上webpack这种东西。
而是说,使用webpack的人会依赖到babel-loader。 (所以,你在babel-loader目录内执行npm install,是不会安装上peerDependencies的)

devDependencies是指的开发本项目的依赖,比如我开发vue项目时,要用到很多webpack相关的东西。所以webpack会放到我vue工程的devDependencies里面,当在这个项目里执行npm install, 就会安装上DevDependencies的东西。 又比如我开发一个jQuery插件,我也用到webpack作为构建工具,这个都要放到devDependencies里。(当开发这个插件项目时,肯定会在项目目录中执行npm i ,这个命令会安装本项目的开发依赖)

npm scripts

由于node程序基本上都依赖于npm包管理工具进行管理、开发、发布等。所以npm结合package.json提供了内置的npm脚本行为。

在package.json中可以这样定义一些脚本命令:

1
2
3
4
5
6
{
"scripts": {
"test": "echo \"Error: no test specified \" && exit 1",
"start": "node ./bin/www"
}
}

然后,在当前项目下执行npm run test 或者 npm run start 就能启动对应的命令。 其中 npm start是个默认的,所以可以省略 run 关键字。脚本的内容可以是一些系统命令,也可以是利用node命令执行某个js脚本。

系统命令例如webpack,babel这些,全局安装也可以,但更多时候推荐局部安装就行,因为npm script里的脚本命令执行时,npm会自动从 node_modules/.bin下寻找可执行的命令。

下面介绍一些工具,可以结合npm scripts进行一些比较有用的操作。

利用shx和shelljs进行命令行操作

shx 这是个兼容各个平台的node命令工具,可以执行Unix like命令。他可以全局安装,安装后可以执行类似shx cd的命令,就能跨平台地使用linux命令了。 局部安全也是可以的。所以,我可以利用他在npm脚本中进行一些Unix命令,例如我的clean脚本可以这样写:

1
"clean": "shx rm -rf dist && shx echo clean finished!",

就是利用局部安装的shx这个命令工具删除了dist目录,并且利用shx执行了 echo 命令打印了一句话 “clean finished”

其实webpack官方也提供了 clean 的办法: https://webpack.js.org/guides/output-management/#cleaning-up-the-dist-folder

利用cross-env进行环境变量设置

大部分的程序,都有依赖环境变量进行不同控制的能力。

制作一个html5单页应用脚手架

参考:
https://github.com/wjf444128852/webpack-config
segmentfault.com/a/11900000006178779

webpack配置

entry写法

由于我们是开发的一个libary,所以其入口一般是一个单个文件。所以采用entry的写法为 单个入口写法参考

目录结构

loader

ES6

ES6语法如此之酷,我们肯定要支持的。通过使用babel-loader,我们可以现在开始写很酷的es6,最后再编译为浏览器可识别的es5.

1

其实,chrome浏览器已经支持了大部分ES6特性,在这里你可以看到各个平台对于ESMA的支持情况: http://kangax.github.io/compat-table/es6/#chrome61。所以如果你的目标用户是chrome,其实甚至可以省去了babel这个环节。

external设置peerDependencies

https://webpack.js.org/guides/author-libraries/

文章里一个node的意思是,你的库依赖了lodash,别人的库依赖了你的库,那么别人的webpack配置里的external就要用数组声明俩依赖了。

External Limitations的意思

build命令

build命令要有2步操作,一个是删除上次编译出来的内容,第二个是执行编译。两个操作可以通过 && 操作符连接起来。 其中clean操作,可以利用上文讲到的shx来实现。


1
2
3
4
5
6
{
"script": {
"clean": "shx rm -rf dist && echo \"> clean finished\"",
"build": "npm run clean && node build/build.js"
}
}

然而实际操作时发现,最新版的npm在执行脚本时,会打印出脚本内容,有点不太好看:

所以,我们采取把clean的工作也放置到build.js里面实现。在build.js中可以基于shelljs库来实现linux命令。

webpack执行方式

从我搜集的资料来看,目前使用webpack大致有这几种方式:

  1. 直接命令行执行,使用默认webpack.config.js配置文件. 如果需要区分环境,则直接在webpack.config.js里判断。如我做的 html5app-template
  2. 直接命令行执行,使用webpack.config.js文件,但是该文件是个傀儡,他负责根据环境变量加载webpack.dev.config.js或者webpack.prod.config.js. 如 https://github.com/mlxiao93/webpack-demo
  3. 不使用命令行,通过在build.js文件里调用webpack来进行编译,但也根据环境变量区分来加载不同的文件-webpack.dev.config.js或webpack.prod.config.js。 如我的felib-template

第一种方式直观、简便,但可能造成webpack.config.js内部代码增多,不容易理解;
第二种方式介于1、3之间,相对清晰了一点
第三种方式比较先进,自由度比较大,build.js里亲自调用webpack,可以充分发挥node.js的能力,比如利用ora、chalk在webpack编译阶段让终端上的提示变得优雅又漂亮。然而一方面这增大了复杂性,另一方面由于webpack.config.js已经消失,所以方便的webpack-dev-server这个工具就不能直接用webpack.config.js的配置直接调用了。 除非webpack-dev-server在命令行里自己写参数. 所以第三种方式实现本地server的时候,通常使用webpack-dev-server的node.js API方式全手工搞的

如何导出接口

类库导出时不要使用ES6的方式导出

这只能被webpack使用ES6方式引入兼容,而不能直接在浏览器内方便使用(浏览器内需主动读取输出变量的default属性才能拿到导出对象)

由于我们是一个库,很可能被用在html中以 <script> 方式引入,也可能会在require.js等AMD模块化系统中使用,也可能会被用在commonjs模块系统中(例如被webpack项目所依赖甚至运行在node.js平台),所以必须让entry入口js中的导出内容以合适的方式挂载在目标模块系统中。

比如 script标签 方式引入的话,就需要把entry入口中要导出的内容全部挂载在全局对象window上。以AMD模块化的系统中,则应该将entry导出的内容注册为一个AMD模块。

所以,在fet项目的webpack配置中,我们需要告诉webpack我们这是个库,需要挂载在对应的环境中。而不是仅仅把entry入口执行一遍就完事了。在webpack中,可以通过将我们的output设置为UMD模式来实现:

1
2
3
4
5
output: {
path: path.resolve(__dirname, 'dist'), // webpack输出目录
filename: 'app.js', // 编译后文件名
libraryTarget: 'umd' // 设置为UMD打包模式
}

如果你希望直接执行入口函数,那么webpack默认的配置就是默认加载和执行的你的入口函数,你无须做任何操作。

如果你希望在入口函数里面,把一个变量挂载到全局window对象上面。这里请注意你必须显式地声明 window.xxx = {} 这样挂载,而不能使用 this.xxx = {}xxx = {}. 因为在webpack包裹后的模块代码里面,this指向的是当前模块的module.exports,而不是window。

导出类库,一般使用这样的匿名自执行函数:

1
2
3
(function(root) {
... // 做环境判断,实在不行就赋值给root变量
})(window)

我们来分析下导出的类库的代码结构,他主要有以下部分构成:

  1. 一个UMD函数包装器
  2. 一个工厂函数

UMD函数包装器

在bundle代码的最开始,就是这个UMD函数包装器。如果只看这个bundle的主逻辑,其实整个Library库的代码就是执行了一个UMD函数包装器逻辑而已,其代码如下:

1
2
3
4
5
6
7
8
9
10
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define([], factory);
else if(typeof exports === 'object')
exports["test"] = factory(); // 现在webpack里这个判断已经没用了
else
root["test"] = factory();
})(window, loadEntryFunction)

我们看到运行这个库的时候,就是执行了这么一个函数。函数的实参有2个,第一个是全局window对象,第二个是一个js函数。在UMD包装函数里这个loadEntryFunction函数被称作工厂函数,这意味着factory函数承担着返回这个类库对外接口的功能。

为什么要传一个全局window函数进去呢? 这个是写js插件的一个常用手法。因为一般js插件或其他类库为了隐藏局部变量,都会采用一个自执行函数把逻辑包裹起来(就像上面的这段UMD代码),而为了在自执行函数内部尽量减少对顶层作用域的查找,并且为了在代码压缩阶段可以对window变量进行压缩,所以通常都是采用将window作为实参传入的手法。参考这里。 有些类库例如jquery还会传入一个undefined参数,以确保函数内部拿到的undefined没有被该写.

1
2
3
(function(window, undefined) {
...
})(window)

我们再来看下第二个参数loadEntryFunction. 在我前面的文章中已经讲过了非Library导出的webpack编译结果,它是一个基于webpackBootstrap模块管理运行时的自执行函数,我们来回顾下他的结构:

1
2
3
4
5
6
7
8
9
10
(function(modules) { // webpackBootstrap
// The require function require函数
function __webpack_require__(moduleId) {
...
}
// Load entry module and return exports 加载入口模块;加载过程就是执行入口模块的js代码,所以入口自然就得到了执行(同时入口依赖的其他模块也会被加载执行)
return __webpack_require__(__webpack_require__.s = "./index.js");
})({
... // 这个对象就是模块的列表。他们是key-value的对象,key就是模块名,value就是js模块(一个匿名函数)
})

可以看到这个基本代码骨架执行了你js源码中的入口函数,而最关键的这一句,它是把执行结果return返回了的:

1
return __webpack_require__(__webpack_require__.s = "./index.js");

因此,如果你的入口js里面导出了一些东西。则这个匿名函数会返回你导出的内容。 因此,对这个匿名函数稍微包裹一下,就可以成为上文UMD包装器里所需要的factory函数:

1
2
3
4
5
6
7
8
9
var loadEntryFunction = function() {
return (function(modules) { // webpackBootstrap
...
// Load entry module and return exports 加载入口模块;加载过程就是执行入口模块的js代码,所以入口自然就得到了执行(同时入口依赖的其他模块也会被加载执行)
return __webpack_require__(__webpack_require__.s = "./index.js");
})({
... // 这个对象就是模块的列表。他们是key-value的对象,key就是模块名,value就是js模块(一个匿名函数)
})
}

所以UMD包装器理解起来其实也比较简单,我们可以简写为如下的代码:

1
2
3
4
5
6
7
8
9
var loadEntryFunc = function() {
return (function webpackBootstrap(){})() // webpackBootstrap函数会返回你的入口js导出的内容
}
// 以上loadEntryFunc如果执行,会返回你的入口js导出的内容

// 下面我们用UMD包裹函数,去执行上面的入口函数,并把入口导出的结果赋值给当前环境上
(function webpackUniversalModuleDefinition(root, factory) {
// 判断当前环境,并执行factory获得你的Library的导出内容,再将导出内容赋值给当前环境的模块对象上
})(window, loadEntryFunction)

简单解释下UMD包裹器里面环境判断的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 如果有exports和module,说明是commonjs环境(在使用ebpack编译代码期间,其实也会属于这种环境。因此你在写vue.js项目时可以注意到项目中引用的是vue/dist/下的vue.common.js版本)
if(typeof exports === 'object' && typeof module === 'object')
// 把类库的导出内容挂载在commonjs模块对象上
module.exports = factory();
// 如果有define函数,且有define.amd标示,说明是AMD环境,此时把你的类库当做一个AMD模块来定义
else if(typeof define === 'function' && define.amd)
// 由于你的webpack预编译的,所以不会在运行时依赖其他模块。所以define的dependencies参数是空数组
define([], factory);
else if(typeof exports === 'object')
// 我也不知道这是啥环境
exports["test"] = factory();
else
// 如果什么环境都没有,就把导出内容挂载在window对象上
root["test"] = factory();

调试server

那么,开发过程中,经常需要通过开启一个本地server来托管我们的html或js,从而实现预览和测试我们的代码。

如果仅仅是普通的起一个server进行静态资源查看,那么可以使用 http-server 这种, 直接启动一个当前目录下的server即可。 如:

1
"build": "rollup -c && http-server",

如果希望能启动静态资源托管server后能够检测html、css改动后自动刷新的话,可以使用 browser-sync 这个:

1
"watch": "browser-sync start --proxy='localhost:8080' --files='*.html, dist/*.js'",

然而启动server后要能够看到我们webpack对我们代码打包后的bundle,因为我们要测试的肯定是我们代码的最终结果。

这个server的功能webpack官方也想到了,那就是安装一个 webpack-dev-server. 这是webpack内置组件,但要单独安装。

既然是个单独的组件,他就是单独使用的咯,跟webpack一样,他支持命令使用:
webpack-dev-server --content-base build/
命令中指定了content-base参数,用来告诉这个server托管的目录是谁。

注意这里要记住,这个content-base是指的server托管的目录,也就是设置后,本地这个server域名的根就是指向该目录,所以直接通过localhost:port 就可以访问到该目录下的所有静态资源。 然而我们如何访问webpack给我们编译后的bundle.js/app.js呢。

假如托管目录下有个index.html, 但index.html所在的位置不一定会有bundle.js,何况只要你不执行webpack的编译,是没有dist/bundle.js生成的(dist目录是不存在的~)。 然而webpack-dev-server命令运行时,其实是先用webpack执行编译过程的,但是不会编译到真实的项目磁盘上,而是在内存中编译,所以其编译目标不会基于webpack的path和filename配置。其编译到内存后的访问地址,是基于webpack中 output.publicPath 的属性配置的,因此如果output.publicPath配置为 assets, 则执行 webpack-dev-server 命令后,可通过 localhost:xxxx/assets/app.js 访问编译后bundle的内容(并不需要你contentBase中真的有这个目录),而实际上这个编译后的东东只是在内存中而已。

由于是内置的组件,所以webpack配置时享受了天然的顶级待遇。所以我们可以将webpack-dev-server命令的配置放在webpack.config.js的配置文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
entry: __dirname + '/src/index.js', // webpack加载入库
output: {
path: __dirname + '/public/js', // webpack输出目录
filename: 'app.js' // 编译后文件名
},
devtool: 'source-map', // 开启sourcemap
devServer: {
contentBase: __dirname + '/public', // 托管目录
colors: true, // 终端中输出为彩色
historyApiFallback: true, // h5的history模式,可以任何链接都返回index.html
inline: true, // 实时刷新
port: 9090
}
}

如上配置,并没有设置webpack的 output.publicPath, 因此内存编译后的js结果需要通过 http://localhost:9090/app.js 来访问(因为默认bundle会放在域名根目录后访问)。另外由于配置的webpack-dev-server的托管静态目录为public,所以通过 http://localhst:9090/index.html 可以访问到public目录下的index.html. 所以,你应该知道index.html中应该如何引入app.js了吧?

1
<script src="./app.js"></script>

由于我们开启了devServer配置中的 inline,所以只要修改源代码,则我们的浏览器会自动刷新(webpack-dev-server通过websocket实现). 其原理是webpack-dev-server执行的时候,在webpack编译的bundle结果js包里注入了一个websocket的模块,所以页面加载后会发起一个websocket连接到webpack-dev-server的服务端,而服务端监控了代码目录,发生改动会通过websocket通知到浏览器端。

vue.js等项目中使用dev脚本命令当做本地server启动的命令,我们也继承他的惯例,采用 npm run dev. 同时我又认为应该遵从node.js的惯例采用 npm start 作为启动命令,所以我支持了2个命令来启动本地server:

1
2
"dev": "node build/dev.js",
"start": "npm run dev",

package.json

main节点设置

main节点表示了该npm包的主入口是什么,对于一个前端项目来说,无论是被哪种项目引用,都可以将编译后的dist/app.js设置为main入口。因为最终的 app.js 已经被UMD打包,所以可以支持任何环境下的引用。

package.json中的脚本

另一种删除方式是使用rimraf。
“clean”: “rimraf build”,

pkg.module

现在给你的库的 package.json 文件增加一个 “module”: “dist/my-library.es.js” 入口,可以让你的库同时支持 UMD 与 ES2015。 这很重要,因为 Webpack 和 Rollup 都使用了 pkg.module 来尽可能的生成效率更高的代码 ——在一些情况下,它们都能使用tree-shake 来精简掉你的库中未使用的部分

热更新

eslint配置

关于eslint的更多知识点,请参看我单独写的一篇博文

eslint有个命令可以用交互的方式初始化一些规则
eslint –init

.eslintrc, .eslintignore的配置文件配置。

.gitignore配置

由于我们使用git进行代码版本管理。 .gitignore文件主要是为了设置git的忽略列表,从而避免一些日志、依赖包之类的目录提交到git仓库。我的配置如下:

1
2
3
*.log
node_modules
lib

node_modules是nodejs依赖的第三方包,这个一般要在线上环境或前置的部署机进行npm install,而不是提交到代码仓库。如果真的需要对第三方包进行修复,一般建议在自己业务代码的最开始对第三方包进行hack.

http://eux.baidu.com/blog/fe/ES6+%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA%E8%A6%81%E7%82%B9

  1. sourceMap
    在开发环境下,我们实际在浏览器上运行的代码并不是源码,而是编译后的结果,因此我们需要生成sourcemap,让我们在debug的时候能够定位到源码的具体哪一行。

  2. 其它资源处理
    多数项目还需要对html/css/image等资源进行处理,开发环境需要考虑这些点。

  3. DEV/RD/QA/ONLINE环境区分
    一般来说,我们在本地开发、开发环境、测试环境、线上环境都有一些不同的配置,同时为了方便debug,我们在本地环境和开发环境一般会有一些日志和辅助debug代码。依赖手工每次在上线的时候进行修改是非常不靠谱的,因此我们的开发脚手架应当包含DEV/RD/QA/ONLINE等环境区分。

  4. 自定义动作
    我们往往还需要根据具体的情况进行一些操作,如:

将编译后的代码转换为一个和原来不同的目录结构以适应线上环境
提供接口,以引入一些其它的插件

gulp 来定义复杂的构建任务

如果只是简单的几个build、watch、test可能不需要gulp,npm script就够了。

如果是有各种资源处理,先后顺序要求,可能你需要定义多个子任务,然后用gulp把它们串联起来。有了gulp也比较方便去执行多个任务,查看当前可用的任务等。 在gulp里面调用webpack也有两种办法:

  • 一种是通过webpack模块,调用webpack函数来通过api执行webpack,执行时传入独立的配置文件地址。
  • 另一种是利用gulp-webpack,这种适用于gulp有一些前置的文件操作任务,交给webpack打包时的entry不是一个具体的物理文件而是gulp前置处理的结果流。

tree-shaking

使用过 DLLPlugin + DLLReferencePlugin 吗?了解过 Tree-shaking 代码优化技术吗?webpack 都做到了

开始书写源代码

webpack 2 支持原生的 ES6 模块语法,意味着你可以无须额外引入 babel 这样的工具,就可以使用 import 和 export。但是注意,如果使用其他的 ES6+ 特性,仍然需要引入 babel

后续

这就完了吗? 恩,差不多了。 但更进一步,我们需要测试。后面我再来开一章节,我们学习下如何给前端库加入单元测试,让我们的代码更健壮。最后我们还会学习如何把我们的前端库项目在github上进行开源。

想尝鲜试用吗

Refer