从零搭建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大致有这几种方式:
- 直接命令行执行,使用默认webpack.config.js配置文件. 如果需要区分环境,则直接在webpack.config.js里判断。如我做的 html5app-template
- 直接命令行执行,使用webpack.config.js文件,但是该文件是个傀儡,他负责根据环境变量加载webpack.dev.config.js或者webpack.prod.config.js. 如 https://github.com/mlxiao93/webpack-demo
- 不使用命令行,通过在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 | output: { |
如果你希望直接执行入口函数,那么webpack默认的配置就是默认加载和执行的你的入口函数,你无须做任何操作。
如果你希望在入口函数里面,把一个变量挂载到全局window对象上面。这里请注意你必须显式地声明 window.xxx = {} 这样挂载,而不能使用 this.xxx = {}
或 xxx = {}
. 因为在webpack包裹后的模块代码里面,this指向的是当前模块的module.exports,而不是window。
导出类库,一般使用这样的匿名自执行函数:
1 | (function(root) { |
我们来分析下导出的类库的代码结构,他主要有以下部分构成:
- 一个UMD函数包装器
- 一个工厂函数
UMD函数包装器
在bundle代码的最开始,就是这个UMD函数包装器。如果只看这个bundle的主逻辑,其实整个Library库的代码就是执行了一个UMD函数包装器逻辑而已,其代码如下:
1 | (function webpackUniversalModuleDefinition(root, factory) { |
我们看到运行这个库的时候,就是执行了这么一个函数。函数的实参有2个,第一个是全局window对象,第二个是一个js函数。在UMD包装函数里这个loadEntryFunction函数被称作工厂函数,这意味着factory函数承担着返回这个类库对外接口的功能。
为什么要传一个全局window函数进去呢? 这个是写js插件的一个常用手法。因为一般js插件或其他类库为了隐藏局部变量,都会采用一个自执行函数把逻辑包裹起来(就像上面的这段UMD代码),而为了在自执行函数内部尽量减少对顶层作用域的查找,并且为了在代码压缩阶段可以对window变量进行压缩,所以通常都是采用将window作为实参传入的手法。参考这里。 有些类库例如jquery还会传入一个undefined参数,以确保函数内部拿到的undefined没有被该写.
1 | (function(window, undefined) { |
我们再来看下第二个参数loadEntryFunction. 在我前面的文章中已经讲过了非Library导出的webpack编译结果,它是一个基于webpackBootstrap模块管理运行时的自执行函数,我们来回顾下他的结构:
1 | (function(modules) { // webpackBootstrap |
可以看到这个基本代码骨架执行了你js源码中的入口函数,而最关键的这一句,它是把执行结果return返回了的:
1 | return __webpack_require__(__webpack_require__.s = "./index.js"); |
因此,如果你的入口js里面导出了一些东西。则这个匿名函数会返回你导出的内容。 因此,对这个匿名函数稍微包裹一下,就可以成为上文UMD包装器里所需要的factory函数:
1 | var loadEntryFunction = function() { |
所以UMD包装器理解起来其实也比较简单,我们可以简写为如下的代码:
1 | var loadEntryFunc = function() { |
简单解释下UMD包裹器里面环境判断的逻辑:
1 | // 如果有exports和module,说明是commonjs环境(在使用ebpack编译代码期间,其实也会属于这种环境。因此你在写vue.js项目时可以注意到项目中引用的是vue/dist/下的vue.common.js版本) |
调试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 | module.exports = { |
如上配置,并没有设置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 | "dev": "node build/dev.js", |
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 –init
.eslintrc, .eslintignore的配置文件配置。
.gitignore配置
由于我们使用git进行代码版本管理。 .gitignore
文件主要是为了设置git的忽略列表,从而避免一些日志、依赖包之类的目录提交到git仓库。我的配置如下:
1 | *.log |
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
sourceMap
在开发环境下,我们实际在浏览器上运行的代码并不是源码,而是编译后的结果,因此我们需要生成sourcemap,让我们在debug的时候能够定位到源码的具体哪一行。其它资源处理
多数项目还需要对html/css/image等资源进行处理,开发环境需要考虑这些点。DEV/RD/QA/ONLINE环境区分
一般来说,我们在本地开发、开发环境、测试环境、线上环境都有一些不同的配置,同时为了方便debug,我们在本地环境和开发环境一般会有一些日志和辅助debug代码。依赖手工每次在上线的时候进行修改是非常不靠谱的,因此我们的开发脚手架应当包含DEV/RD/QA/ONLINE等环境区分。自定义动作
我们往往还需要根据具体的情况进行一些操作,如:
将编译后的代码转换为一个和原来不同的目录结构以适应线上环境
提供接口,以引入一些其它的插件
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上进行开源。
想尝鲜试用吗
请试用lime-cli安装该脚手架即可,具体请移步Github: https://github.com/cuiyongjian/felib-template
基于该脚手架开发的前端工具库项目scoop: https://github.com/limefe/scoop