相信很多人对 webpack 都不陌生,现在流行的前端框架都广泛使用了 webpack,但是我们在开发过程中几乎没怎么用到,所以难免会产生疑问,有没有必要学习 webpack ?答案是肯定的!
随着互联网的发展,前端技术标准发生了巨大的变化。早期的前端技术标准没有预料到前端会有今天这样的规模,所以在设计上有很多缺陷,导致我们在实现前端模块化时会遇到很多问题。虽然说大部分问题都已经被一些标准或工具解决了,但在这个标准的演进过程中有很多东西值得我们思考和学习。
最早我们基于文件划分的方式实现模块化,具体做法是将每个功能模块及相关数据和状态单独放在不同的js文件中,约定每个文件是一个独立的模块。使用时一个script标签对应一个模块,然后直接调用模块中的成员(变量/函数)。
// moduleA.js
function foo() {
console.log('moduleA#foo');
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Stage 1</title>
</head>
<body>
<script src="moduleA.js"></script>
<script>
// 直接使用全局成员
foo() // 可能存在命名冲突
</script>
</body>
</html>
缺点:
这个阶段约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中。具体做法就是将每个模块包裹为一个全局对象的形式,这种方式就像是为模块内的成员增加了“命名空间”,所以称之为命名空间方式。
// moduleA.js
window.moduleA = {
method1: function () {
console.log('moduleA#foo')
}
}
// moduleB.js
window.moduleB = {
data: 'something'
method1: function () {
console.log('moduleB#method1')
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Stage 2</title>
</head>
<body>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块成员依然可以被修改
moduleA.data = 'foo'
</script>
</body>
</html>
这种方式只是解决了命名冲突的问题,但是其他问题依旧存在。
使用立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)为模块提供私有控件,具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现。
// module-a.js
;(function () {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
window.moduleA = {
method1: method1
}
})()
// module-b.js
;(function () {
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
window.moduleB = {
method1: method1
}
})()
这种方式带来了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问,这就解决了前面所提到的全局作用域污染和命名冲突的问题。
在 IIFE 的基础上,我们还可以利用IIFE参数作为依赖声明使用,使得模块间的依赖关系更加明显。
// module-a.js
;(function ($) { // 通过参数明显表明这个模块的依赖
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
window.moduleA = {
method1: method1
}
})(jQuery)
以上4个阶段是早期的开发者在没有工具和规范的情况下对模块化的落地方式,这些方式解决了很多在前端领域实现模块化的问题,但是仍然存在一些问题。
<!DOCTYPE html>
<html>
<head>
<title>Evolution</title>
</head>
<body>
<script src="https://unpkg.com/jquery"></script>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
</script>
</body>
</html>
最明显的问题就是:模块的加载。我们都是通过script标签的方式直接在页面中引入的这些模块,意味着这些模块的加载不受代码的控制,时间久了维护起来会十分麻烦。
更为理想的方式应该是在页面中引入一个入口js文件,其余用到的模块可以通过代码控制,按需加载进来。
为了统一不同开发者、不同项目之间的差异,我们需要制定一个行业标准去规范模块化的实现方式。
CommonJS
提到模块化规范,最先想到的可能就是 CommonJs 规范了,它是 Node.js 中所遵循的模块规范
module 对象
node 提供了一个 Module
构造函数,所有的模块都是 Module
的实例。每个模块都有一个 module
对象,代表当前模块。
module.exports
是 module
对象重要的属性,表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取的 module.exports
变量。
exports 变量
为了方便,node为每个模块提供了exports变量,指向 module.exports。这相当于在每个模块的头部,都有一行这样的代码:
var exports = module.exports;
对外输出
// a.js
exports.msg = 'hello';
exports.say = function(p) {
return p;
}
require 命令
node 内置的 require
命令用来加载模块文件。
require
命令的基本功能就是,读入并执行一个 javascript 文件,然后返回该模块的 exports 对象。
var say = require('a.js');
say('hello);
该规范有以下特点:
每个文件就是一个模块,有自己的作用域,文件里面定义的变量、函数等都是私有的,对其他文件不可见。
服务器端广泛使用的模块化加载机制,以为模块一般都存在本地,不需要考虑网络等因素,所以为同步加载。
模块加载一次就被缓存起来,再次加载直接读取缓存。
模块加载的顺序按照其在代码中出现的顺序加载。
这种方式在服务器端使用没有问题,但是要在浏览器端使用同步的方式加载模块,就会引起大量的同步模式请求,导致运行效率大大降低。
AMD
所以早期并没有直接选择 CommonJS 规范,而是专门针对浏览器重新设计了一个规范,叫做 AMD (Asynchronous Module Definition) 规范,即异步模块定义规范。最出名的实现库就是 Require.js。
在 AMD 规范中约定通过 define() 函数定义模块。
// AMD 定义一个模块
define(['jquery', './module.js'], function($, module) {
return {
method1: function() {
module();
...
},
...
}
})
除此之外,Require.js 还提供了一个 require() 函数用于加载模块,用法与 define() 函数类似,区别在于 require() 只能用来载入模块,define() 可以定义模块。
require(['./module.js'], function(module) {
module.method();
})
目前绝大多数第三方库都支持 AMD 规范,但是它使用起来相对复杂,随着应用规模扩大,模块划分更为细致时,会出现一个页面请求次数过多的情况。
随着技术的发展, JavaScript 的标准逐渐走向完善,而且对前端模块化规范的最佳实践也基本实现了统一。
模块化可以帮助我们解决复杂应用开发过程中的代码组织问题,但是随着模块化思想的引入,前端应用又会产生一些新的问题:
前端模块化的进程和最终统一的 ES Modules 标准都是我们深入学习 Webpack 前必须要掌握的只是,同时也是作为前端开发必不可少的基础储备。
Webpack 发展到今天,已经非常强大了,从一个“打包工具”发展到今天的整个前端项目的“构建系统”,它的初衷“模块化解决方案”透露出的“模块化思想”有很多值得我们学习思考的地方。
javascript 是 web 语言,不同浏览器都会有不同的 javascript 解释器对其进行解释编译运行。由于 js 被广泛接受,随后又有 ECMA (European Computer Manufacturers Association 欧洲计算机制造商协会)
对其进行规范管理,js 所遵循的规范也叫 ECMAScript 或者 ES。
其中第5版也就是 ES5
于2009年定稿,目前主流浏览器都全部支持。
第6个版本 ES2015 即 ES6
,在2015年定稿,目前主流浏览器并不是全部支持。
还有ES7、ES8 在原来的基础上,增加了一些新功能。
如果我们希望立刻就能使用 ES6/ES7/ESNext…,但同时我们也希望我们的代码能在主流浏览器或者node中正常运行,那这就是 Babel 产生的原因了。
简单来说,Babel就是把JavaScript 中 ES6/ES7/ESNext等新语法转换为 ES5(也可以转换为更低的规范,但目前 ES5 规范已经足以覆盖绝大部分主流浏览器,因此可以认为转到 ES5 是一个安全且流行的做法),让低端运行环境如浏览器或者node能认识并执行的工具。
我们可以将babel理解为一个转译器 transpiler 而不是编译器 compiler 更准确,因为它只是把同种语言的高版本规则翻译成低版本规则,但是和编译器类似,它也分为3个阶段,parsing、 transforming、generating,以 ES6 转译为 ES5 为例,babel转译的具体过程如下:
plugins
Babel 本身不具备任何转化功能,它通过配置 plugins
来对不同的语法特性做转译,主要作用于第二个阶段 transforming。
presets
由于不同的语法特性特别多,那么对应的 babel plugin 也会特别多,如果选择手动添加并安装会非常繁琐且不方便管理,为了解决这个问题,babel 提供了一组插件集合 presets
,避免了重复定义和安装。
1、babel-presest-env
比如我们需要转换 ES6 语法,可以再 .babelrc 的 plugins 中引入插件: es2015-arrow-functions、es2015-block-scoped-function 等等不同作用的 plugin:
// .babelrc
{
"plugins": [
"es2015-arrow-functions",
"es2015-block-scoped-functions",
// ...
]
}
但是随着需要转换的语法变多,plugin 也相应增多,不便于维护,所以为了方便 Babel 团队将同属 ES2015 的 transform plugins 集合到了 babel-preset-es2015 的 Preset 中,这样我们只需要在 .babelrc 的 presets 引入一个 ES2015 就可以全部支持 ES2015 语法了。
// .babelrc
{
"presets": [
"es2015"
]
}
随着时间推移,可能会有更多版本的插件,如 babel-preset-es2020,…等等,因此 babel-preset-env 出现了,它类似于 babel-preset-latest ,会根据目标环境来进行转译。
{
"presets": ['env']
}
polyfill
Babel 默认只转换 JavaScript 语法,而不转换新的 API,如 Iterator、Set、Maps、Symbol、Promise等全局对象。以及一些在全局对象上的方法(如 Object.assign)等都不会转码。如果想让这些全局对象或API就需要使用 babel-polyfill 来转换。
同步加载模块、NodeJS
module 对象
node 提供了一个 Module
构造函数,所有的模块都是 Module
的实例。每个模块都有一个 module
对象,代表当前模块。
module.exports
是 module
对象重要的属性,表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取的 module.exports
变量。
exports 变量
为了方便,node为每个模块提供了exports变量,指向 module.exports。这相当于在每个模块的头部,都有一行这样的代码:
var exports = module.exports;
对外输出
// a.js
exports.msg = 'hello';
exports.say = function(p) {
return p;
}
注意: 不能直接将 exports 变量指向一个值,因为这相当于切断了 exports 和 module.exports 的联系。
require 命令
node 内置的 require
命令用来加载模块文件。
require
命令的基本功能就是,读入并执行一个 javascript 文件,然后返回该模块的 exports 对象。
var say = require('a.js');
say('hello);
javascript 是 web 语言,不同浏览器都会有不同的 javascript 解释器对其进行解释编译运行。由于 js 被广泛接受,随后又有 ECMA (European Computer Manufacturers Association 欧洲计算机制造商协会)
对其进行规范管理,js 所遵循的规范也叫 ECMAScript 或者 ES。
其中第5版也就是 ES5
于2009年定稿,目前主流浏览器都全部支持。
第6个版本ES2015 即 ES6
,在2015年定稿,目前主流浏览器并不是全部支持。
还有ES7、ES8 在原来的基础上,增加了一些新功能。
如果我们希望立刻就能使用 ES6/ES7/ESNext…,但同时我们也希望我们的代码能在主流浏览器或者node中正常运行,那这就是 Babel 产生的原因了。
简单来说,Babel就是把JavaScript 中 ES6/ES7/ESNext等新语法转换为ES5(也可以转换为更低的规范,但目前 ES5 规范已经足以覆盖绝大部分主流浏览器,因此可以认为转到 ES5 是一个安全且流行的做法),让低端运行环境如浏览器或者node能认识并执行的工具。
我们可以将babel理解为一个转译器 transpiler 而不是编译器 compiler 更准确,因为它只是把同种语言的高版本规则翻译成低版本规则,但是和编译器类似,它也分为3个阶段,parsing、 transforming、generating,以 ES6 转译为 ES5 为例,babel转译的具体过程如下:
plugins
Babel 本身不具备任何转化功能,它通过配置 plugins
来对不同的语法特性做转译,主要作用于第二个阶段 transforming。
presets
由于不同的语法特性特别多,那么对应的babel plugin也会特别多,如果选择手动添加并安装会非常繁琐且不方便管理,为了解决这个问题,babel 提供了一组插件集合 presets
,避免了重复定义和安装。
1、babel-presest-env
比如我们需要转换 es6 语法,可以再 .babelrc 的 plugins 中引入插件: es2015-arrow-functions、es2015-block-scoped-function 等等不同作用的 plugin:
// .babelrc
{
"plugins": [
"es2015-arrow-functions",
"es2015-block-scoped-functions",
// ...
]
}
但是随着需要转换的语法变多,plugin 也相应增多,不便于维护,所以为了方便 Babel 团队将同属 ES2015 的 transform plugins 集合到了 babel-preset-es2015 的 Preset 中,这样我们只需要在 .babelrc 的 presets 引入一个 ES2015 就可以全部支持 ES2015 语法了。
// .babelrc
{
"presets": [
"es2015"
]
}
随着时间推移,可能会有更多版本的插件,如 babel-preset-es2020,…等等,因此 babel-preset-env 出现了,它类似于 babel-preset-latest ,会根据目标环境来进行转译。
{
"presets": ['env']
}
polyfill
Babel 默认只转换 JavaScript 语法,而不转换新的 API,如 Iterator、Set、Maps、Symbol、Promise等全局对象。以及一些在全局对象上的方法(如 Object.assign)等都不会转码。如果想让这些全局对象或API就需要使用 babel-polyfill 来转换。
一共有以下三种方式:
1、standalone script
,单体文件,最简单快捷的方式,通过script tag 来引用 babel-standalong
package。
<script
src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.18.1/babel.min.js"
></script>
引入后,babel就会自动将任何以 text/babel 为 type 的 script 进行解析。
2、命令行(cli),多见于 package.json 中的某条命令。
3、构建工具插件(webpack 的 babel-loader、rollup 的 rollup-plugin-babel)。
前言
webpack目前已更新至 4.41.2
。在 webpack4
版本下相对 webpack3
,做了很多优化,有了更好的默认值、更为简洁的模式设置、对 chunk
的分割也更加智能,splitChunks
自定义如何分割代码块等等。对于整个项目来说,在包的构建速度、代码体积以及运行效率上都有一个质的飞跃。
webpack4默认的配置对于大部分简单应用来说已经够用了,但作为打包工具本身来说,它不可能满足所有的业务场景和形态,我们还是可以从中做一些优化来适配。
本文所述的优化均是基于 webpack4
而言。
webpack构建完成后,用户可以生成一个包含模块统计信息 stats
的 JSON 文件。这个统计信息可以用来分析应用的依赖以便优化我们的构建。
生成方式如下:
webpack --profile --json > stats.json
输出的 JSON 文件内容如下:
{
'version': '5.0.0-alpha.6', // Version of webpack used for the compilation
'hash': '11593e3b3ac85436984a', // Compilation specific hash
'time': 2469, // Compilation time in milliseconds
'filteredModules': 0, // A count of excluded modules when exclude is passed to the toJson method
'outputPath': ''/', // path to webpack output directory
'assetsByChunkName': {
// Chunk name to emitted asset(s) mapping
'main': [
'web.js?h=11593e3b3ac85436984a'
],
'named-chunk': [
'named-chunk.web.js'
],
'other-chunk': [
'other-chunk.js',
'other-chunk.css'
]
},
'assets': [
// A list of asset objects
],
'chunks': [
// A list of chunk objects
],
'modules': [
// A list of module objects
],
'errors': [
// A list of error strings
],
'warnings': [
// A list of warning strings
]
}
文件中包含了整个应用的构建信息,但是颗粒度不够细,对于分析意义不是很大。
体积分析工具 webpack-bundle-analyzer
, 它可以将打包后的资源文件以方便交互的树状图来呈现,通过这个图我们可以直观的分析应用的模块组成,进一步分析然后优化它。
安装
# npm
npm install -D webpack-bundle-analyzer
#yarn
yarn add -D webpack-bundle-analyzer
使用 在 webpack.config.js 中配置
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
build 完成后,webpack-bundle-analyzer 会自动启动一个静态服务器并打开网页显示资源的树状图。如:
项目构建时加上 –report 可以自动打开浏览器,显示项目依赖界面。
// package.json
"build": "vue-cli-service build --report"
在做优化之前,我们必须对自己的项目工程结构有所了解,针对不同的业务场景,优化的侧重点也是不一样的。主要从以下几种类型几个维度来思考如何划分我们的包,并尽可能的利用浏览器缓存。
类型 | 共用频率 | 使用频率 | 更新频率 | 例如
基础类库 libs
基础类库是项目中不可缺少的,如 vue
+ vue-router
+ vuex
+ axios
这种标准的全家桶,升级频率不高,但每个页面都依赖他们,我们可以将它们单独提取出来进行打包。
UI 组件库
UI 组件库理论上也可以放入 libs 中,但是考虑到它一般比较大,比如 element-ui
压缩完接近 200kb 左右,而且 UI 组件库的更新频率也相对 libs 更高,我们会不时升级 UI 组件来解决 bug 或使用新功能,所以建议将UI 组件库单独拆包。
重要组件/函数
重要组件/函数在项目中必须加载他们才能正确运行,比如全局路由、菜单栏/header/footer等组件以及自定义的 SVG 图标等等,它们是入口必须的,默认打包到 app.js
中。
还有一些组件或函数没有在入口 entry
中引入,但是却被大部分页面使用,比如我们自己封装的 table 组件,由于体积不大,会被默认打包到每一个懒加载的页面 chunk 中去,假如有十个个页面引用了它,就会被重复打包十次,这样会造成不少的浪费。所以应该将那些复用频次高的公共组件单独打包。
懒加载
的方式加载的页面
component: () => import('./Foo.vue')
这样,webpack会将它打包成一个单独的 bundle 。
完整的 splitCunks
配置如下:
splitChunks: {
chunks: "all",
cacheGroups: {
libs: {
name: "chunk-libs",
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: "initial" // 只打包初始时依赖的第三方
},
elementUI: {
name: "chunk-elementUI", // 单独将 elementUI 拆包
priority: 20, // 权重要大于 libs 和 app 不然会被打包进 libs 或者 app
test: /[\\/]node_modules[\\/]element-ui[\\/]/
},
commons: {
name: "chunk-comomns",
test: resolve("src/components"), // 可自定义拓展你的规则
minChunks: 2, // 最小共用次数
priority: 5,
reuseExistingChunk: true
}
}
};
我们可以看看webpack4 提供了哪些默认的 optimization 配置,以便进一步结合自身项目来进行更改适配。
// optimization 默认配置
optimization: {
minimize: env === 'production' ? true : false, // 开发环境不压缩
splitChunks: {
chunks: "async", // 共有三个值可选:initial(初始模块)、async(按需加载模块)和all(全部模块)
minSize: 30000, // 模块超过30k自动被抽离成公共模块
minChunks: 1, // 模块被引用>=1次,便分割
maxAsyncRequests: 5, // 异步加载chunk的并发请求数量<=5
maxInitialRequests: 3, // 一个入口并发加载的chunk数量<=3
name: true, // 默认由模块名+hash命名,名称相同时多个模块将合并为1个,可以设置为function
automaticNameDelimiter: '~', // 命名分隔符
cacheGroups: { // 缓存组,将所有加载模块放在缓存里面一起分割打包
default: { // 模块缓存规则,设置为false,默认缓存组将禁用
minChunks: 2, // 模块被引用>=2次,拆分至vendors公共模块
priority: -20, // 优先级
reuseExistingChunk: true, // 默认使用已有的模块
},
vendors: {
test: /[\\/]node_modules[\\/]/, // 表示默认拆分node_modules中的模块
priority: -10
}
}
}
}
我们在项目开发中经常会引用到一些第三方库,比如lodash,echarts,这些库在我们的项目中默认是全量引入的,但实际我们只用到库里的某些组件或者某些函数,按需打包我们使用的组件或函数就可以减少相当大一部分的体积。
以 lodash
为例,全包引入的话有 400kb 体积,使用模块化按需引入,可减少体积,具体做法是结合 lodash-webpack-plugin
和 babel-pluigin-lodash
,它可将全路径引用的 lodash
自动转换为模块化引入,配置上也是十分简单。 :
// 引入组件,自动转换
import _ from 'lodash'
_.debounce()
_.throttle()
以上写法可能还是不够快捷,每个用到的文件都要写一遍 import,更可取的的方法是,将项目所需的方法,统一按需引入,组建一个本地的 lodash 库,然后导出 export 给我们的项目框架层,如 Vue.prototype
,以便我们全局使用。
import _ from 'lodash'
export default {
cloneDeep: _.cloneDeep,
debounce: _.debounce,
throttle: _.throttle,
size: _.size,
pick: _.pick,
isEmpty: _.isEmpty
}
// 注入到全局
import _ from '@helper/lodash.js'
Vue.prototype.$_ = _
// vue 组件内运用
this.$_.debounce()
对于中等规模以上开发,我们一般区分 开发环境
、生产环境
和测试环境
。对待生产环境,合理的压缩混淆可以有效的减小打包体积,同时移除一些调试信息,如 console
。
webpack4 废弃了 CommonsChunkPlugin
插件,使用 optimization.splitChunks
和 optimization.runtimeChunk
来替换。
webpack4 内置了 UglifyJs
插件,当打包模式 mode
为 production
时,会自动开启压缩js代码。
css压缩可以使用插件 optimize-css-assets-webpack-plugin
。
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
optimization: {
splitChunks: {
chunks: 'all'
},
runtimeChunk: true,
minimizer: [
new OptimizeCssAssetsPlugin({})
]
},
new OptimizeCssAssetsPlugin({
assetNameRegExp: /\.optimize\.css$/g,
cssProcessor: require('cssnano'),
cssProcessorOptions: { safe: true, discardComments: { removeAll: true } },
canPrint: true
}),
webpack 打包时,会根据 webpack.config.js 中 url-loader
设置的 limit
大小对图片进行处理,小于 limit 的图片会转化成 base64 格式,其他的不做操作。对于较大的图片我们还可以用 image-webpack-loader
来压缩图片。
// webpack.config.js
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use:[
{
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
loader: 'image-webpack-loader',// 压缩图片
options: {
bypassOnDebug: true,
}
}
]
}
关于 HTTP 压缩,百度百科的解释如下:
HTTP 压缩是一种内置到网页服务器和网页客户端中以改进传输速度和带宽利用率的方式。在使用 HTTP 压缩的情况下,HTTP 数据在从服务器发送前就已压缩:兼容的浏览器将在下载所需的格式前宣告支持何种方法给服务器;不支持压缩方法的浏览器将下载未经压缩的数据。最常见的压缩方案包括 Gzip 和 Deflate。
可以说,Gzip是 HTTP 压缩的经典。
Gzip 的好处
开启Gzip可以减小文件体积,传输速度更快。服务端和客户端都可以做 Gzip。
const CompressionWebpackPlugin = require('compression-webpack-plugin');
webpackConfig.plugins.push(
new CompressionWebpackPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: new RegExp('\\.(js|css)$'),
threshold: 10240,
minRatio: 0.8
})
)
开启Gzip前
开启 gzip 后
webpack中的 Gzip主要是为了在构建过程中去做一部分服务器的工作,为服务器分压。服务器的 Gzip和 webpack 的 Gzip谁也不能替代谁,需要我们结合业务压力与服务器 CPU 情况来权衡。
删除冗余代码的典型应用就是 Tree-Shaking
。
Tree-Shaking
的消除原理是基于 ES6 的模块特性, 可以在编译过程中确定模块的依赖关系悉,获知哪些模块没有被真正使用,在打包的时候会将这些没用的代码删除掉。
举个例子:
在某个页面中
import { page1, page2 } from './pages'
// show是事先定义好的函数
show(page1)
pages 文件里导出了两个页面:
export const page1 = xxx
export const page2 = xxx
但事实上,page2 并没有被用到,打包结果会把 ‘ export const page2 = xxx ‘这部分代码直接删掉。
注意:要使用 Tree-Shaking
,必须保证引用的模块都是 ES6 规范的,如果项目中使用了 Babel,这就比较麻烦了,因为 Babel的预置默认把模块转译成了 CommonJS 模块,我们可以设置 modules: false 来解决此问题。(注:babel7 不推荐使用 babel-preset-2015, 而是 babel-preset-env 来实现基于特定环境引入需要的polyfill)
在 .babelrc 或者 webpack中设置。
/// .babelrc
{
"presets": [
["env",
{
"modules": false
}
]
]
}
// webpack.config.js
module: {
rules:
{
test: /\.js$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: {
presets: ['env', { modules: false }]
}
}
}
]
}
为了在webpack中使 Tree-Shaking 起作用,需要满足许多条件,需要使用 ES6 模块和 UglifyJsPlugin,同时需要配置 optimization,并将其 usedExports 和 sideEffects 设为 true。
// webpack.config.js
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
mode: 'none',
optimization: {
minimize: true,
minimizer: [
new UglifyJsPlugin()
],
usedExports: true,
sideEffects: true
}
}
在当下,想要合理利用tree-shaking,能尽力做的事:
虽然我们对代码进行了拆分优化,但是在编译过程中,每次都需要对这些基础组件进行打包,对于不常更新的第三方库我们可以提前进行打包,在持续构建的过程中,就可以节省这部分时间了。
DllPlugin
就是这样一个提前打包的插件,在 webpack 中通过 DllReferencePlugin
引入提前打包好的文件,最后使用 AddAssetHtmlPlugin
往里注入 vendor 文件。
webpack.dll.conf.js 配置如下:
// webpack.dll.conf.js
const webpack = require('webpack');
const path = require('path');
const dllDist = path.join(__dirname, 'dist');
module.exports = {
entry: {
vendor: ['vue', 'vuex', 'vue-router', 'axios', 'moment'],
},
output: {
path: const dllDist = path.join(__dirname, 'dist'),
filename: '[name]-[hash].js',
library: '[name]',
},
optimization: {
minimizer: [
],
},
plugins: [
new CleanWebpackPlugin(["*.js"], { // 清除之前的dll文件
root: dllDist,
}),
new webpack.DllPlugin({
path: path.join(__dirname, 'dll', '[name]-manifest.json'),
name: '[name]',
}),
]
};
webpack配置
const manifest = require('./dll/vendor-manifest.json');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
...
plugins: [
// webpack读取到vendor的manifest文件对于vendor的依赖不会进行编译打包
new webpack.DllReferencePlugin({
manifest,
}),
// 往html中注入vendor js
new AddAssetHtmlPlugin([{
publicPath: "/view/static/js", // 注入到html中的路径
outputPath: "../build/static/js", // 最终输出的目录
filepath: path.resolve(__dirname, './dist/*.js'),
includeSourcemap: false,
typeOfAsset: "js"
}]),
]
注意:当组件库更新时需要手动升级,并执行 dll 命令,更新我们的 vendor 文件。
包引入的必要性
现在的前端大多是 MVVM
模式的框架,基于数据的双向绑定,像 jQuery
这样的库,完全没有引入的必要,用几行原生代码能解决的事,没有必要引入这样的庞然大物。
避免引而不用
在大型项目中,很难保证每个引入的库都被利用到,如果有引入却未使用的类库,只会徒增打包体积,影响性能。这时可以借助工具在开发时进行约束,推荐使用 ESlint
等这类工具,注入规则,对声明未使用的代码进行强制提醒,有效避免类似情况发生,还能统一团队开发规范,一举多得。
引入适合的包
类似 momentjs
,它给我们带来便利的同时,也在一定程度上影响了性能,和它功能类似的类库还有很多,我们可以选择体积更加小的,满足开发需求即可。
代码分割的本质就是在源代码直接上线和打包成唯一的脚本main.bundle.js这两种极端方案之间寻找一个更适合实际业务场景的中间状态,用可接受的服务器性能压力增加来换取更好的用户体验。
splitChunks基本配置
1、chunks
分割代码的模式
2、minSize
表示抽取出来的文件在压缩前的最小大小,单位为 byte,默认为30000。
3、 maxSize
表示抽取出来的文件在压缩前的最大大小,默认为 0,表示不限制最大大小。
4、 minChunks
模块最小引用次数
如当值为2时,表示只引用了一次的模块不做分割打包处理。
5、 maxAsyncRequests
按需(异步)加载模块最大并行请求数,默认为5
如:打开某个页面需要同时加载10个库,且设置 maxAsyncRequests: 5,那么这10个库会被分割成5个模块。
6、maxInitialRequests
入口文件可并行加载的最大文件数量,默认为3
如:maxInitialRequests: 3,有pageA中,使用了大量 import xx from ‘yy’,那么pageA依赖的这些非动态加载的模块,最多只会被打包成3个模块。
7、automaticNameDelimiter
缓存组名称和生成文件名称之间的连接字符串
8、cacheGroups
缓存组,splitChunks最核心的配置,上面的属性都是对缓存组进行配置的,且缓存组会继承splitChunks的配置。
当符合代码分割的条件时,就会进入缓存组,把各个模块进行分组,最后一块分割打包。
splitChunks默认有两个缓存组: vendors 和 default:
optimization: {
splitChunks: {
...
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
引入
异步加载模块解决了首页加载慢的问题,但同时可能因为异步加载的那部分代码迟迟不能执行,导致用户交互长时间没有响应,影响用户体验。
这时候就需要 prefetch
和 preload
了。
Webpack 4.6.0 为我们提供了预先拉取(prefetch) 和 预先加载(preloading) 功能,使用这些声明可以修改浏览器处理异步chunk的方式。
预先拉取
表示该模块可能以后会用到,浏览器会在空闲时间下载该模块,且下载是发生在父级chunk加载完成之后。
import(
`./module/a`
/* webpackPrefetch: true */
/* webpackChunkName: 'a'*/
)
这样的导入会在页面头部添加一段如下代码:
<link rel="prefetch" as="script" href="a.js" >
因此浏览器会在空闲时间预先拉取该文件。
预先加载
对资源添加预先加载的注释,指明该模块需要立即被使用,异步chunk和父级chunk并行加载。如果父级chunk先下载好,页面即可显示,同时等待异步chunk的下载。这能大幅提升性能。
import(
`./module/a`
/* webpackPreload: true */
/* webpackChunkName: 'a' */
)
以上代码会在页面头部添加如下代码:
<link rel="preload" as="script" href="a.js" >
小结
使用动态导入能提升应用性能,显著减少页面的初次加载时间。同时配合webpack的额外参数,添加预先拉取和预先加载的支持,可以进一步定制动态导入,优化用户体验。
先看图
router:
以上是使用懒加载的方式加载的路由
打包结果:
看看首页加载的资源
结果显示,首页还是将所有资源加载了。
看看首页head
发现 about.js list.js 被加上了 prefetch,被预先拉取了。某些情况下我们可能不想这么做,查看 vue-cli 官方文档发现:
默认情况下,一个 Vue CLI 应用会为所有作为 async chunk 生成的 JavaScript 文件 (通过动态 import()
按需 code splitting
的产物) 自动生成 prefetch 提示。
这些提示会被
@vue/preload-webpack-plugin
注入,并且可以通过chainWebpack
的config.plugin('prefetch')
进行修改和删除。
// vue.config.js
module.exports = {
chainWebpack: config => {
// 移除 prefetch 插件
config.plugins.delete('prefetch')
// 或者
// 修改它的选项:
config.plugin('prefetch').tap(options => {
options[0].fileBlacklist = options[0].fileBlacklist || []
options[0].fileBlacklist.push(/myasyncRoute(.)+?\.js$/)
return options
})
}
}
再来看看首页:
点击 About,加载了About.js
这样完成了对首页加载资源的优化。
如果我们需要设置资源预先拉取/加载,则可以通过webpack魔法注释 /* webpackPrefetch: true */
/* webpackPreload: true */
来手动设置。
其实可视化的web开发和传统网页开发并没有什么本质区别。都是数据+模板。前后端分离什么的,跟传统网页开发也没山么区别。现在流行的是用node作为中间层,数据处理交给java或者其他。(对于体量小的应用,不需要设计这么复杂)
首先根据项目及人员配备情况,选择合适的前端框架,个人觉得用vue效率比较高且跟得上现在的技术。以及确定是否需要使用前后端分离。
在UI设计过程中,最好能及时沟通一些技术细节,避免设计出的效果现有技术不能达到要求或者具有太大的技术难度。最终商量确定折中方案实施。
可视化的关键就是能直观生动的将数据进行展示,对页面的动效要求和图表统计分析比传统网页要多,选择合适的插件非常重要。常用的echarts或highcharts,d3.js,如果要求3d效果,可以采用wegGL技术。
echarts
echarts是百度开源的,底层是canvas。支持很多丰富的图表,官网实例也非常多,兼容性不考虑IE低版本的话是非常不错的选择。4.0版本据说支持千万级别数据量渲染,同时增加了svg引擎包裹。
highcharts
highcharts底层是svg的,商业使用需要授权。在浏览器兼容性支持上比echarts好,但是实例较少,个人更偏向使用echarts。
canvas vs svg
在开始着手设计页面时,首先得考虑页面以怎样的方式布局,他是一个静态网页,还是一个自适应或者响应式?
静态网页
所谓静态网页就是采用固定的宽度实现的页面。
自适应
自适应是为了解决如何才能在不同大小的设备上呈现同样的网页。
响应式
响应式可以自动识别屏幕宽度,并作出响应调整,布局和展示的内容在不同屏幕下可能不同。众所周知,twitter开源的bootstrap就是一套响应式的UI框架。
在做可视化大屏展示时,根据需求选择使用哪一种网页布局。一般采用静态或者自适应。
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" type="text/css" media="screen and (max-device-width: 400px)" href="tinyScreen.css" />
@media screen and (max-device-width: 400px) {
.column {
float: none;
width:auto;
}
#sidebar {
display:none;
}
}
img { max-width: 100%;}
老版本的IE不支持max-width,可以使用:
img { width: 100%; }
flex容器的主要特性是她可以调整其子元素的宽度或者高度来填充可用的空白区域,以最优的方式达到兼容大小。使用felx布局需要写一些浏览器的hack,IE低版本(10-)不支持flex布局。
有时我们访问一个网页,会感觉很卡顿,一方面除了网络带宽原因外,页面的渲染性能也有很大的影响。
首先,我们得了解浏览器渲染的过程,浏览器从解析一个页面到渲染做了哪些事呢?
在理解渲染性能之前,我们有必要了解一下两个概念,那就是重排(也叫回流,Reflow)和重绘(repaint)。
重排(Reflow)
重排指的是计算页面布局,包括节点的尺寸和位置,还有可能触发其子节点的Reflow。
当渲染树中的一部分或全部因为元素的尺寸、布局、隐藏等改变而需要重新构建,这就叫回流,每个页面至少需要一次回流,就是页面第一次加载的时候。
在web网页中,很多状况会导致回流:
重绘(Repaint)
当渲染树中的一些元素需要更新属性,而这些属性只是影响元素的外观、风格、而不会影响布局的,就是重绘。
回流和重绘都很容易被触发,他们的触发对性能的影响都非常大,我们无法完全避免,只能尽量不触发。
避免触发布局
目前,transform和opacity只会引起合成图层,不会引起布局和重绘。性能是最好的。所以在制作动画时,建议使用transform的translate
替代margin
或position
中的top
、right
、bottom
、left
,同时使用transform
中的scaleX
或scaleY
替代width
和height
will-change
will-change
的主要作用就是提前告知浏览器将会进行的一些变动,告诉浏览器分配资源。但是注意,不要将will-change
应用到太多元素上,也不要过早的应用will-change
,并注意在应用变化之后,取消will-change
的资源分配。
ES6诞生以前,异步编程的方法主要有以下几种:回调函数、事件监听、发布/订阅、Promise对象;ES6中引入了Generator函数;ES7中,async/await将异步编程带入了一个全新的阶段。
文章将对这些异步操作的使用和优缺点进行进一步的比较和分析。本文内容部分摘抄自《Mozilla Promise Reference》。
Promise是抽象异步处理对象以及对其进行各种操作的组件。
// 使用了回调函数的异步处理
getAsync("fileA.txt", function(error, result) {
if(error) { // 取得失败时的处理
throw error;
}
// 成功时的处理
})
Node.js规定在javascript的回调函数中的第一个参数为Error
对象。
下面看个使用了Promise进行异步处理的例子:
var promise = getAsyncPromise("fileA.txt"); // 返回Promise对象
promise.then(function(result) {
// 成功时的处理
}).catch(function(error) {
// 失败时的处理
})
两者比较,回调函数方式可以自己定义回调函数的采纳数,而使用promise对象处理的时候,除了promise规定的then
和catch
以外的方法都是不可以使用的,必须严格遵守固定、统一的编程方式来写代码。
所以,promise的功能就是可以将复杂的异步处理轻松的进行模式化。
Promise的使用大概有以下三种类型:
Promise类似于XMLHttpRequest
, 从构造函数Promise
创建一个新的promise实例
var promise = new Promise(function(resolve, reject) {
// 处理结束后,调用resolve或reject
})
对通过new生成的promise对象,可以使用promise.then()
方法来调用resolve或reject的回调函数。
promise.then(onFulfilled, onRejected)
resolve成功 onFulfilled
会被调用
reject失败 onRejected
会被调用
promise.then
成功和失败时都可以使用。在只想对异常进行处理时,可以采用promise.then(undefined, onRejected)
这种方式,只指定reject时的回调函数。不过这种情况下,promise.catch(onRejected)
是个更好的选择。
promise.catch(onRejected);
Promise包括一些静态方法如Promise.all()
,Promise.resolve()
等。
状态 | 描述 |
---|---|
pending | 初始状态,既不是成功也不是失败,也就是promise实例刚被创建的初始状态。 |
fulfilled | resolve(成功),此时会调用onFulfilled |
rejected | reject(失败), 此时会调用onRejected |
function getURL(URL) {
var promise = new Promise(function (resolve, reject) {
var req = new XMLHttpRequest();
req.open('GET', URL, true);
req.onload = function () {
if(req.status == 200) {
resolve(req.responseText);
}
else{
reject(new Error(req.statusText));
}
};
req.onerror = function() {
reject(new Error(req.statusText));
};
req.send();
})
}
var URL = 'http://xxx.xx.txt';
getURL(URL).then(function onFulfilled(value) {
console.log(value);
}).catch(function onRejected(error) {
console.error(error);
})
Promise实例继承自Promise.prototype. 你可以通过构造器的原型对象在所有的Promise实例上添加属性或方法。
Promise.prototype.constructor
表示Promise的构造函数的原型,以便在其原型上放置then()
、catch()
、finally()
方法。
为promise添加成功或者失败处理,同时返回一个新的包含解析过后的的值或者处理函数的promise,或者这个原始的未被处理的promise。
为promise添加失败的回调处理,如果这个回调函数被调用了,则返回一个包含回调返回值的新的promise,如果这个promise没被处理,则返回原始的值。
为promise添加处理,如果原始的promise对象已经被解析,则返回解析后的promise。无论promise是什么状态,这个处理函数都会被执行。
一般情况下,我们会使用new Promise()
来创建promise对象,我们也可以使用Promise.resolve
或Promise.reject()
例如Promise.resolve(42)
,可以认为是以下代码的语法糖:
new Promise(resolve => {
resolve(42);
})
当iterable参数中所有的promises都被resolve时,或参数中不包含promises的时候,Promise.all(iterable)
会返回一个单一的Promise。当iterable中有一个promise返回拒绝,all()方法会立即终止并返回第一个被reject的promise对象。
var promise1 = Promise.resolve(3);
var promise2 = 42;
var promise3 = new Promise(function(resolve, reject) {
setTimeout(resolve, 100, 'foo');
})
Promise.all([promise1, promise2, promise3]).then(function(values) {
console.log(values);// 3, 42, foo
})
Promise.race(iterable)
方法返回iterable参数中最先被resolve或者reject的promise对象,包括返回值或者失败原因。
var promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 300, 'one');
});
var promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'two');
});
Promise.race([promise1, promise2]).then(result => {
console.log(result);// expected 'two'
})