一:前端工程化的发展
很久以前,互联网行业有个职位叫做“软件开发工程师”在那个时代,大家可能会蒙,放在现今社会,大家会觉得这个职位太过笼统,所以在此提一下那个时候的互联网行业状态。
- 那个时候的 APP 还不是 ios/ 安卓 多数是嵌入式应用(和网页没关系 使用 c ++ 或 java 开发)后续到了 Symbian 时代出现了可移植的 sis[x]类型应用,曾经一统移动 APP 市场。
- 那个时候 css 百分之 90 还是用来做布局
- 那个时候 js 仅仅是为了类似弹出提示的功能而存在的
- 那个时候从服务器 – 数据库 – 业务逻辑 – 页面 全都由所谓的“软件开发工程师”来完成
- 所以大家不必问软件开发工程师具体是干啥的,我只能说 啥都干
1:个人对前端开发的体验过程
第一个阶段就是开始对着 W3C 的文档,拿着 txt 文本文件一个字母字母的敲着代码,那个年代,真的单纯舒服,上来就是一个项目的文件夹,然后就开始 img、js、css 三个完美的文件夹,再接上一个 index.html,就开始到网上各种下载类库 jquery、underscore.js,然后手动的引入各种类库,当然过程也伴随着痛苦。
- 每次来一个项目就开始建立各种繁琐的文件夹,和拷贝复制类库
- 引入完类库的时候发现控制台报错,$ is not defined,依赖关系出错,部分类库需要把 jquery 作为依赖
- 新写一个页面就需要去重新复制其他页面的 header 资源,维护变的困难(完全没有组件化的想法)
- 部分 css3 属性需要自己不断的手动添加样式前缀
- 最大的问题再维护的时候,不敢轻易的去动一些类库的引入,不清楚各个库之前的依赖关系
第二个阶段就是在接触到 Vue 的时候直接上 vue-cli 的时候,几行脚本就可以启动本地开发服务和打包线上资源,初次尝试了 webpack 这个打包工具,好像对其他问题不需要做过多的考虑,直接开始业务开发,其他的事情 cli 都帮助处理好了。vue init webpack helloWorld,cd helloWorld && cnpm install && npm run dev,真香警告,对工程化一知半解,但是好用,方便
- 前端需要一些工具来处理重复的大量繁琐的事 (资源压缩 代码混淆 css 前缀),以前会用 Gulp 等 Task 处理任务
- 前端需要一些需要用一些预处理器来处理样式文件,less 以前用第三方工具去编译 -> less 提供的命令行去编译 -> 配置到 webpack 中自动化实时编译
- 前端需要更加细粒度的配置一下代码体积的优化,代码混淆压缩的操作
- 前端部分业务越多,代码量越多(文件体积越大)需要做文件合并,压缩以及按需加载等
- 开发阶段依然在本地开发,但同时保持和线上 API 的同步,为此我们需要本地服务器(可以是静态服务器)代理转发(Nginx webpack-dev-server node-server 等方式)
第三个阶段就是给公司项目升级 webpack 过程中体验到了更细粒度的控制工程,体验工程化的过程,实际的体验到了工程化为我们做的事情
- 模块化开发,不用在担心以往开发方式带来的全局作用域的污染问题,当然以往也可以通过闭包来实现私有变量的概念
- 组件化开发,代码重用度高,便于维护
- 多了构建,编译过程,可以在适当的时间去做一些提高工程质量的任务,比如代码规范的检测,常用的是 Eslint (Airbnb, Prettier 规范等)
- 提高开发效率,比如 css 浏览器前缀的自动添加,使用 postCss 甚至可以提前使用一些好用的东西,比如 css 变量的概念
- 通过 chunkHash,contentHash 等实现资源的缓存
- 根据工程代码通过合理的代码分割提升用户体验,做到按需加载,甚至可以在未来做一些用户使用的习惯,做一些提前的预加载
二:webpack 的基本使用
1:webpack 和 Grunt / Gulp 的区别
这两类是不同的东西,一个可以理解为是任务执行器而另外一个是模块打包器,任务执行器是可以自动化的执行一些以前你需要手动操作的过程,见下面简单的代码
// coffee 源码
console.log 'Hello World'
//coffee 转 js coffee -c a.coffee
(function(){console.log('Hello World');
}).call(this);
// 执行编译压缩 uglify -s a.js -o a.min.js
(function(){console.log("Hello World")}).call(this);
coffee 需要编译成浏览器支持的 js,你需要手动的去执行上面的几个命令,如果现在需要去修改源码,在 Hello World 后面加上一个!,加完你需要手动的去执行两条命令重复的去编译操作。以 gulp 为例,编写 gulpfile.js 来帮助我们完成重复的工作
gulp = require('gulp')
coffee = require('gulp-coffee')
uglify = require('gulp-uglify')
rename = require('gulp-rename')
file = './src/js/a.coffee'
gulp.task('coffee', function(){gulp.src(file)
.pipe(coffee()) // 编译
.pipe(uglify()) // 压缩
.pipe(rename({extname: ".min.js"}))
.pipe(gulp.dest('./build/js'))
gulp.task('watch', function(){gulp.watch(file, ['coffee'])
})
gulp.task('default', ['coffee'])
我只要执行一下 gulp watch 就可以检测到 coffee 文件的变化,然后为你执行一系列的自动化操作,同样的操作也发生在 less, scss, 这些 css 的预处理器上。在修改到源文件的情况下的编译,压缩这些重复操作都交由它来完成,在我看来 Grunt / Gulp 算是一个能够自动化执行一些繁琐重复的操作,提高生产效率,算是一个任务执行器。
这些操作同样也可以由 webpack 完成,接下来我们看一下官网给出的 webpack 的定义。
webpack is a module bundler
官方给出的解释是,webpack 是一个模块打包器,不是一个任务执行器,它是可以配合 Grunt / Gulp 使用的,相关链接 webpack 集成。webpack 打包器 (bundler) 帮助你生成准备用于部署的 JavaScript 和样式表,将它们转换为适合浏览器的可用格式。例如,JavaScript 的压缩、chunk split 和懒加载,以提高性能。所以 webpack 和 Gulp/Grunt 之间是有一定功能的重叠,但是处理合适,是可以一起配合工作的,不存在所谓的谁替代谁,只是在某些场景下 webpack 的能够独当一面,完成了 Grunt/ Gulp 的功能。
2:webpack3.x 和 webpack4.x 的对比
rollup 以及 Parcel 的出现,号称零配置,足以让一个配置成本比较高的 webpack 出现了 4.0 版本,当然也号称零配置使用,开箱即用,现在来一起看看 webpack4.x 和 3.x 比较大的区别,先给出一个 Release 链接 webpack Release v4.0.0 下面简要的介绍一些大的改动
- Node 环境的升级,不在支持 node 4.0 的版本,最低支持 6.11.5
- 配置增加了 mode:’production’, ‘development’, ‘none’,所谓的开箱即用的支持点,在不同的 mode 下开启了一些默认的优化手段
- 生产模式开启了各种优化去生成 bundle 文件、默认开启作用域提升、process.env.NODE_ENV 设置为 production
- 开发模式优化内部 rebuild 流程,提升开发效率和体验
- 删除了和添加了一些配置项,NoEmitOnErrorsPlugin、ModuleConcatenationPlugin(default in production mode)、NamedModulesPlugin(default in development mode)—> 转到 optimization.* 的配置项
- 原生支持处理 JSON 文件格式,不需要 json-loader
- 内置的插件 uglifyjs-webpack-plugin 升级到了 V1, 而 V1 是可以支持并行处理压缩混淆 JS 的,webpack3 之前的内置依赖的版本 0.4.6 不支持并行处理。常用手段使用 webpack-uglify-parallel 插件并行处理,利用多核 CPU 的优势,升级到 webapck4 可以不需要了,使用默认也可以
- 一个算是比较大的改动,webpack3.x 的 ComoonsChunkPlugin 废弃,代替的是 optimization.splitChunks 和 optimization.runtimeChunk (会在下文着重介绍)
3:webpack4.x 的一些基本概念
首先先看下图整体了解一下 webpack 的一些常用配置项
接下来简单的了解一下 webpack 的一些基本概念
- mode:三种模式,production、development、none。设置 mode,webpack 会根据 mode 做相应的优化
- entry:入口,webpack 会从入口递归寻找所有的依赖,形成依赖关系图(dependency graph)。目前应用主要分为单入口和多入口,直观上表现为经过 webpack 处理之后是一个 JS 文件还是多个 JS 文件(在没有代码分割以及懒加载的前提下)
- output:输出,主要通过 filename 来定义生成 chunk 的命名,可以通过标识符 [name]、[contenthash]、[chunkhash] 来实现资源的缓存,chunkFileName 针对 async chunk
- loader:loader 用于对模块的源代码进行转换。既然是模块打包器,那么就会出现依赖各种各样的文件和内容,图片,字体,它们需要经过编译才能被浏览器识别(less、sass,、stylus、ES2015、ts 等 module),都需要通过对应的 loader 转换成现代浏览器支持的东西。loader 支持链式传递,loader 运行在 Node.js 中,并且能够执行任何可能的操作,比如存在一些不是用来转换文件的 loader,thread-loder(多个 node 进程处理 loader 转码文件,提高编译速度)
- plugin:插件和 loader 不同的地方在于,loader 是针对模块,比如 import 以及 require 的 module 文件进行转码。plugin 是在 webapck 的 complier 整个生命周期中起作用,在这个编译阶段你可以在提供的 hook 中执行你需要的任何操作。比如 htmlWebpackPligun 插件,可以在 webpack 编译 emit 文件的钩子中,生成 html 去使用这些 webpack 生成的 JS Chunk
- module:module 的概念可以理解为一个个需要加载的文件,Js 也好,Css 文件也好,都需要经过 loader 处理,module 里面的 rules 去就是配置 module 需要什么 loader 去处理
- chunks:两种,init chunks(这些 chunks 文件是会以 script 标签添加到 htmlWebpackPlugin 生成的 html 文件中,当然也可以通过插件内置到 html 文件中,比如 mainifest 文件)和 async chunks。初始化 chunks 是从提供给 webapck 的 entries 中开始递归的寻找依赖的 module,生成的一个 chunks。异步的 chunks 可以理解为是需要按需加载的,主要可以分为以下 3 个来源:第一、从初始化的 chunk 中抽出去的代码,形成的 chunk 文件(这个就是 webapck 的 splitChunk 和 runtimeChunk 的配置)。第二、可以通过 webpack 识别的特定语法 require.ensure (vue-router 中懒加载的写法)。第三、ES6 的动态导入 import(/chunkname/)
- optimization:这是 webpack4.x 出现的一个优化类的配置项,常用配置项:splitChunk、runtimeChunk(下文介绍)、minimize、noEmitOnErrors、namedModules、sideEffects(配合 tree shaking 使用)等等
-
resolve:如上图,这个配置会增量的告知 webpack 如何的去寻找依赖,alias(避免一些深层次引用 module 代码的别名)、modules(从哪些目录寻找依赖)、extensions(module 的扩展名)、mainFields(第三方类库存在多个版本的时候,优先使用哪个版本)。alias 可以手动指定第三方库的使用,比如当 Vue 没有用 CDN 的时候,如果从 node_modules 引用,引入的代码是 runtime 运行时的代码,是没有包含解析单文件.vue 的 template 部分的功能,这个时候需要依赖它的其他版本,手动指定,常见于用 Vue-cli 去生成项目架构的时候,发现 alias 默认有一项,告知 webpack 引入 Vue 的时候 module 的位置是 vue/dist/vue.esm.js(包含了解析 template 的代码)。mainFields 的使用是针对第三方类库使用各种模块化写法以及语法。有 ES6 的 mport、export 的,也有 CommonJs 的模块导出。有压缩的 min.js 也有 Ts 版本的,这些会在 package.json 中看到,至于引入第三方模块的引入那个版本,对于一些成熟的类库比如 Vue,Vue-router 等有多个版本,可以通过设置 mainFields 告知 webpack 从 package.json 中的那个字段导入类库。ES6 的 improt、export 存在静态分析,配合 tree shaking 使用,这也是 webpack 号称能提速 98% 的原因,但是目前的状况是第三方库参差不齐,很多都没有提供 ES6 模块导出的版本,所有目前效果还不是很理想 - externals:指定外部扩展。从 bundle 中排除依赖,比如项目一些基本不会变更版本的第三方类库,通过引用 CDN 资源,常见 Vue、VueRouter、element-ui、echart 等等类库
- devServer:开发模式的配置。在 webpack4.x 之前的版本通过 node 的 express 框架搭建的本地服务器,配合 webpack-dev-middleware 和 webpack-hot-middleware 搭建的开发环境。现在可以通过 webapck-dev-server 类库结合 devServer 配置项去开启本地开发环境,这个类库封装了 express 的操作,同时内部使用了 webpack-dev-middleware。给出配置链接:devServer 配置项
三:项目 webpack 升级流程
先简短的介绍一下项目,升级的项目为多入口项目,每个 module 模块代码一个入口,然后共用很多业务组件
*/build
webpack.base.conf.js // dev 和 prod 共用部分
webpack.dev.conf.js // dev 模式下特有配置
webapck.prod.conf.js // prod 模式下特有配置
*/common
components // 多个模块共用的组件
assets // 静态资源
*/src
module1 // 模块 1 的代码
index.js // 模块 1 代码的入口
module2 // 模块 2 的代码
module3 // 模块 3 的代码
*/mock
mock-xxx.js // mock api 的接口
*/config // 配置文件
index.js
dev.js
prod.js
1:package.json 依赖的管理
升级 webpack,安装 webpack-cli。全局安装 npm-check-updates,查看 package.json 中可升级的依赖的版本。
cnpm install -g npm-check-updates // 全局安装
// 在项目根目录下执行 ncu
ncu
// 可升级的依赖,列举部分依赖情况
axios 0.17.1 --> 0.18.0
webpack 3.5.5 --> 4.16.5
webpack-merge 4.1.0 --> 4.1.4
// 升级 webpack 安装 webpack-cli
cnpm install webpack webpack-cli --save-dev
// 升级相应的 loader 和 plugin
cnpm install url-loader file-loader vue-loader sass-loader css-loader babel-loader html-webpack-plugin --save-dev
2:修改 webpack 配置
公司使用的 vue-cli2.0 的脚手架生成的项目,简单的列举关于 webpack 的目录,针对需要可进行自行调整
*/build
webpack.base.conf.js // dev 和 prod 共用部分
webpack.dev.conf.js // dev 模式下特有配置
webapck.prod.conf.js // prod 模式下特有配置
// 通过 webpack-merge 合并配置输出最后的 webpack 配置
1: 增加 mode 模式
// webpack.dev.conf.js 开发模式的配置 开启 webpack 默认的配置优化
mode: 'development'
// webpack.prod.conf.js 生产模式的配置
mode: 'production'
2:升级 vue-loader,vue-loaderv.15 版本和之前的有所区别,vue-loader 不在使用自身的配置,而是解析.vue 文件之后使用 webpack 里配置的 loader,详细文档见 Vue-loader 的使用,补充一点:在.vue 文件中 style 提供的 scoped 标记,就是通过 vue-loader 去实现在 template 中加入了适当的 hash,配合样式去做到组件内样式的独立
// webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin') // 插件解析.vue 文件把 template script style 分别交给 webpack 配置的 loader 去处理
module.exports = {
// ...
plugins: [new VueLoaderPlugin()
]
}
3:部分 webpack 的插件已经配置停用。如有的话,按照提示删除掉,比如 module 里的 loaders,webpack4 不再支持。然后部分插件转化为通过 optimazation 进行配置,主要配置点在于 code split 以及 mainifest 文件的提取,同样见下文 splitChunks 和 runtimeChunks 的分析
4:开发模式加入 devServer。项目是否需要通过手动 node 搭建本地服务器,取决于是否需要 node 层面去处理其他东西,在公司项目中启动服务前有两个操作,编译 scss 文件(皮肤文件),以及 mock 文件夹(mock 接口),所以还是保留了手动档搭建 node 层面启动服务,其实完全可以有 webpck-dev-server 的 before,after 配置项完成
// 下面代码块针对 devServer 的部分配置项做说明,结合相关知识进行分析
devServer: {contentBase: path.join(__dirname, '../static'), // 静态资源提供,不是 webpack 生成的 bundle, 生成的 bundle 在内存中见注释 1
host: host || 'localhost', // 主机名,如果你希望服务器外部可访问,需要设置为 0.0.0.0,默认 localhost
port: process.env.PORT || port, // 端口号
historyApiFallback: true, // handle fallback for HTML5 history API 了解一下这个东西见注释 2
proxy: proxyTable, // 后端接口转发见注释 3
hot: true, // 热更新见注释 4
quiet: true, // webpack 打包信息省略
publicPath: assetsPublicPath, // bundle 的位置 outpath:publicPath 类似
clientLogLevel: "none" // HRM WDS 在浏览器控制台的输出
}
// 注释 1:写过 express 的就知道有一个指定 static 中间件用来指定资源目录的
const express = require('express')
const app = express()
app.use(staticPath, express.static(xxx)) // xxx/js/vendor.js,就可以通过 localhost:prot/staticPath/js/vendor.js 访问相关静态资源。所以 contentBase 就是利用 express 静态中间价提供个一种访问静态资源文件的能力
// 注释 2:vue-router 的 history 模式充分利用 history.pushState API 来完成 URL 跳转而无须重新加载页面 example: history.pushState({a:1}, '测试', '/attendance/index'),然后内部去处理相应的 router 对象的展示 Vue 页面和逻辑,所以这就是你顺着程序点路由可以进去,但是刷新的时候,就显示 404 的原因,因为该路由在服务器上不是真实存在的,而是在 index.html 中通过 JS 去解析模拟的,这就需要我们生产模式下生产的 dist 文件所有的请求都转发到 index.html。处理方式:在服务器上通过 nignx 代理,或者起一个 express 服务,通过第三方类库 connect-history-api-fallback,当然也可以原生 Node 去写,此时 Node 只是一个文件服务器
const http = require('http')
const fs = require('fs')
const httpPort = 80
http.createServer((req, res) => {fs.readFile('index.htm', 'utf-8', (err, content) => {if (err) {console.log('We cannot open"index.htm"file.')
}
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'})
res.end(content)
})
}).listen(httpPort, () => {console.log('Server listening on: http://localhost:%s', httpPort)
})
// 注释 3:请求代理,常常被问起跨域请求有哪些方案,jsonp、CORS(后端配合)、window iframe 等方式,还有一个就是通过 node 代码转发请求,服务端请求不存在跨域的概念,webpack 中可以 node 自己使用 http-proxy-middleware 去进行代理,本次更新使用的 wbepack-dev-server 模块同时也依赖了 http-proxy-middleware 的库,目前 Node 不作为纯后端,而是作为中间代码,连接纯后端 (java php) 和前端。// 注释 4:热更新,它允许在运行时更新各种模块,而无需进行完全刷新,目前的公司项目都是纯刷新的方式,热更新 HRM,这个是需要你是用的 loader 或插件帮助你完成,他们能监听 webpck complier 期间得钩子,然后给出相应源码更新后需要 patch,推送到前端,打补丁,然后实现热更新,而不是刷新整个页面去重新加载页面。好处自然提高开发效率,在修改.vue 文件的 tempalte 和 style 以及 script 中不是 vue 生命周期函数时,是能够保留到当时的 vue 的各种状态
5:针对生产模式的优化配置,废弃生产模式中使用的优化插件,转为 webpack4 的 optimization.* 配置
- NoEmitOnErrorsPlugin // 编译错误跳过编译阶段,不生成文件(default in production)
- ModuleConcatenationPlugin // 作用域提升,加快代码执行(default in production)
- NamedModulesPlugin // 默认的 module 在打包进入 chunks 的时候都会以 module.id 为标识(这个是随着依赖递增的),这会影响到缓存,使用这个插件将会使用文件的路径作为标识,详细见 splitChunks and runtimeChunk 分析
- CommonsChunkPlugin // 最为晦涩难懂的 webpack 插件,作用代码分割,被 splitChunks && runtimeChunks 代替
下面为升级之前的配置
// 依赖 module(require,import 导入的文件)来自 node_modules 且以.js 结尾的文件将会被打包到名为 vendor 的 bundle 中
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {
// any required modules inside node_modules are extracted to vendor
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(path.join(__dirname, '../node_modules')
) === 0
)
}
}),
// 在 vendor 的 bundle 中把 manifest 提取出来(mainifest 算是 webpack 实现的在浏览器端进行模拟加载模块和具体加载逻辑的代码块,这个最好拆出来,不然没法做缓存,具体见到长效缓存分析)new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
})
// 打包之后就会生成一个 vendor.js(里面有着来自 node_modules 的第三方类库)和 mainifest.js(具体加载逻辑)
升级之后,完成原有的配置很简单,简单的照着 API 实现即可,本次升级伴随着两个重点,开发模式的 rebuild 时间,生成环境的打包文件体积和时间,所以还需要做其他优化
- 某些第三方类库只有某个 module 才加载,比如只有 module1 中用 mint-ui,这是可以单独提出来一个 chunk 或者直接就加载到 module1 这个入口的 init chunk 中 —-> 配置层面
- 某些公共组件比如 component 下公共组件 layout 在基本每个系统模块全部都条用了,可以考虑拉出来共用,减少重复代码量 —-> 配置层面
- 利用 ES 的语法去动态的引入模块 import(‘a.js’).then(module => {})去懒加载一个模块,比如只有在某些 vue 中才会用到的 vue-qrcode-component 的类库,这个类库不应该出现在 vendor 中(来自 node_modules)—-> 代码层面
optimization: {
splitChunks: {
cacheGroups: {
// module 只要满足下面就会从原来的 chunks 中抽取出来打包到对应的 chunks 之中
// 这个 vendors 是在至少同时有两个 initial Chunk 中引入的来自 node_modules 的第三方类库会被打包到 chunks-vendors 中
vendors: {
name: 'chunk-vendors',
test: /[\\\/]node_modules[\\\/]/,
priority: -10,
minChunks: 2, // 至少同时有几个 chunk 满足才会有可能从这些 chunks 中提取一些代码到新的 chunk 中,也就是至少两个共用才会提取,不然就直接打包都所在 module 的 init chunk 中
chunks: 'initial' // chunk 的概念:代码分割这些操作作用于那些 chunk 文件,initial 是通过提供给 webpack 入口生成的 chunks,async 是通过之前提到的 import() 路由懒加载形成的 chunk,all 就是所有的 chunk 文件},
// 这个 common 跟上面的区别在于没有 test 检测 module 来源,只是只要有两个 chunk 共用就是提取出来,很显然就范围来讲,下面的大于上面,这时候到底这个来自 node_modules 的 module 进入哪个 chunk,取决于 priority(优先级),谁高进入哪个 chunk
common: {
name: 'chunk-common',
minChunks: 2,
priority: -12,
chunks: 'initial',
minSize: 0
}
}
},
runtimeChunk: {name: 'manifest'}
}
// 比如某些第三方类库只在某个 Vue 文件中使用,通过动态引入 import('vue-qrcode-component').then() 一个类库只在一个地方用,完全没有必要打包到 vendor 中(因为 vue-qrcode-component 来自 node_module)// import 就是新建一个 chunk 这个 chunk 是没有名字的,需要通过 /* webpackChunkName: "loadsh"*/ 生成这个 loadsh 的 chunk.name
import(/* webpackChunkName: "loadsh"*/ 'loadsh').then(m => {
const _ = m.default
console.log(_.join(['hello', 'world']))
})
总结:一般经过这几步骤就能完成一个 webapck 项目的升级,对于自己项目的复杂的地方需要额外的处理,写着写着发现篇幅越来越长了,把上文一直出现的 splitChunk 和 runtimeChunk 留到下个篇幅着重介绍一下。在公司做升级之前,给的指标不仅仅是升级框架,还需要在 dev 模式开发的 rebuild 的速度更快(修改一个地方,rebuild 的时间 12s 左右才能看到效果,痛苦,可以通过 lessModule 来解决),在 prod 模式下打包的项目文件体积减小已经打包所需要的时间更短(一次测试环境发布需要 5,6 分钟)。现在 vue-cli3.0 已经出现了,找时间把 vue-cli3.0 源码给大家分析一下,简单的包装了一下操作