一:前端工程化的发展
很久以前,互联网行业有个职位叫做 “软件开发工程师” 在那个时代,大家可能会蒙,放在现今社会,大家会觉得这个职位太过笼统,所以在此提一下那个时候的互联网行业状态。
- 那个时候的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源码给大家分析一下,简单的包装了一下操作