引入模块化后,解决了大体量项目的开发问题,但是又带来了一些新问题。比如:
ES Module还存在兼容性问题
模块文件过多,网络请求频繁
所有前端资源都需要模块化,不仅仅是JS,还需要css,html图片等静态资源
所以需要工具满足我们以下设想:
所以出现了前端模块化打包工具
webpack
常见工具:webpack,RollUp,Parcel
webpack,模块打包器,在打包过程中,可以通过模块加载器(Loader)对新特性进行编译转换,它还具备代码拆分(code splitting)的能力,将模块按我们的需要去分组打包,用渐进式加载解决文件太碎或文件太大,这两个极端的问题,还支持资源模块(Asset Module),支持载入任意资源类型的文件,比如import一个css文件。
打包工具解决的是前端整体的模块化,并不单指JS的模块化
打包结果运行原理:
webpack打包过后的代码并不会特别复杂,只是把所有模块翻到了同一文件当中,还提供了基础代码,让模块与模块间相互依赖的关系还可以保持原有状态。
几个关键概念
// webpack.config.js
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader', // 把样式转化成style标签插进html
'css-loader' // 把css转换成js模块
]
}
]
}
}
- webpack.config.js 是配置webpack的文件,运行在node上,所以我们需要遵循commonJS
- entry: 入口起点(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。进入入口起点后,webpack 会递归找出有哪些模块和库是入口起点(直接和间接)依赖的
- output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件
- mode:通过选择 development 或 production 或 none;none不会开启任何默认插件。
- loader 让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。
注意,loader 能够 import 导入任何类型的模块(例如 .css 文件),这是 webpack 特有的功能。
以上配置中,对一个单独的 module 对象定义了 rules 属性,里面包含两个必须属性:test 和 use。这告诉 webpack 编译器(compiler) 如下信息:
“嘿,webpack 编译器,当你碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在你对它打包之前,先使用 raw-loader 转换一下。”
webpack只是打包工具,loader用来转换编译代码。
loader
常见的资源加载器,有file-loader,url-loader(Data Urls形式)
最佳实践为:小文件用Data Urls,减小请求次数,大文件还是file-loader,提高加载速度。
{
test: /.png$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024 // 10 KB 大于10自动调用 file-loader
}
}
}
常用Loader分类:
编译转换类型:css-loader,babel-loader
文件操作类型:file-loader,
代码检查类:eslint-loader
babel-loader
es6=>es5的编译要安装三个东西
babel-loader @babel/core @babel/present-env
babel-loader是一个开关,只是触发@babel/core去工作,它是转换的核心,但是他的转换需要插件来指定,比如babel/classes是针对es6中class新语法的,@babel/present-env就是插件的集合,把所有新特性都支持了
webpack加载资源的方式
几乎所有代码中所要引用的资源,都会被webpack找出来,根据我们的配置交给不同的loadel去处理,最后将处理结果整体打包到输出目录。
webpack核心工作原理
官网首页其实就很好的解释了工作原理。
从入口文件开始,webpack递归的找到所有依赖,形成了一个依赖树,根据依赖树和配置文件中的rules属性,找到其对应的加载器(loader),交给对应的加载器去处理,最后将处理结果整体打包到输出文件中。其中Loader机制是webpack的核心。
开发一个Loader
我们来实现一个markDown-loader
// m-loader.js
// 首先loader要运行在node上要遵循commonJS
// Loader就是一个函数,函数的参数source就是拿到的源代码
// Loader工作的结果必须是js代码
module.exports = source => {
console.log(source)
// return "hello ~" // 返回的不是js 不行
return 'console.log("hello ~")' // 可以
}
或者可以交给其他loader处理
// m-loader.js
const marked = require('marked')
module.exports = source => {
const html = marked(source)
// 返回 html 字符串交给下一个 loader 处理
return html
}
// webpack.config.js
{
test: /.md$/,
use: [
'html-loader', // 交给你
'./m-loader'
]
}
plugin插件
loader专注资源的加载,plugin解决其他自动化工作。
比如:压缩代码,清除dist目录,拷贝静态文件到输出目录等
webpack+plugin 实现了大多数前端工程化的工作,所以让大家以为webpack就是前端工程化。
常用插件
plugins: [
new webpack.ProgressPlugin(),
new CleanWebpackPlugin(), // 清除dist
// HtmlWebpackPlugin 自动生成使用打包结果bundle.js的html文件
// 用于生成index.html
new HtmlWebpackPlugin({
template: './src/index.html' // 以谁为模版
})
// 多页面应用,再生成个其他的html
new HtmlWebpackPlugin({
filename: 'about.html'
})
// 静态文件拷贝(其实上线前才会用,开发阶段不要用这个,开销大)
new CopyWebpackPlugin(['public']),
]
想找插件就去 github上搜索 关键字[ plugin webpack image mini ]
自己实现一个插件
相比loader,plugin有着更宽的一个能力范围,plugin通过钩子机制实现
class MyPlugin {
// 必须有apply方法
apply (compiler) {
console.log('MyPlugin 启动')
// 自己上官网找钩子时机
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation => 可以理解为此次打包的上下文
for (const name in compilation.assets) {
// ......
}
})
}
}
webpack的开发体验
如果只是完成上线任务,那么上面的功能足够用了,但是实际中,你本地的开发环节时间是远大于你上线的那几分钟的,好的开发体验才能事半功倍,我希望我的开发环境能:
- 项目可以以http serve来运行,才能使用ajax,如果以文件形式运行那是不支持的。
- 边开发,边自动编译,边自动刷新
- 提供SourceMap支持,便能在浏览器调试,定位问题源码
那么对于上述功能,webpack都以实现
自动编译
watch工作模式
webpack --watch 用于观察依赖文件的变化,一旦有变化,则可以重新执行构建流程
自动刷新浏览器
npm install browser-sync
browser-sync dist --files"*/"
browser-sync会监听dist文件下的变化,有变化就刷新。
这样就实现了自动编译+自动刷新,但是效率还是低,因为要用到两个工具,还需要磁盘的写入后读取,还有更好的解决办法。
Webpack Dev Server
Webpack官方提供的工具,它提供了http server,集成了自动编译自动刷新浏览器功能等对开发友好的功能
npm install webpack-dev-server --dev
webpack-dev-server // 启动啦
webpack-dev-server为了提高效率,并没有使用磁盘,它将打包结果暂时存放在内存中,http server也是从内存中把这些文件读出来发给浏览器。
静态资源访问
dev-server默认会将构建结果输出的文件,全部作为开发服务器的资源文件,也就是说只要能够作为webpack打包输出的文件,都可以被正常访问到,如果有些静态资源也需要被访问的话(假如图片没有经过webpack构建),就需要额外告诉webpack
// webpack.config.js
// contentBase额外为httpserver指定查找资源目录
module.exports = {
devServer: {
contentBase: ['./public'] // 指定了public文件为额外的资源路径
},
}
代理API
开发阶段的接口跨域问题
通过开发服务器代理,因为服务器与服务器间的请求是不跨域的。
dev server支持配置代理
我们实现代理github的一个接口
devServer: {
contentBase: './public',
proxy: {
'/api': {
// http://localhost:8080/api/users -> https://api.github.com/api/users
target: 'https://api.github.com',
// http://localhost:8080/api/users -> https://api.github.com/users
pathRewrite: {
'^/api': ''
},
// 不能使用 localhost:8080 作为请求 GitHub 的主机名
changeOrigin: true
}
}
},
Source Map
在开发阶段,调试和报错我们都希望基于开发阶段的代码而不是编译过后的。Source Map(源代码地图)就是最好的一种方法,它是一种映射源代码与编译后代码的关系地图,转换后的代码通过source map逆向解析,就可得到源代码。
source map固定格式
在编译后文件最后一行添加 //# sourceMappingURL=xx.js.map
webpack配置source map
module.exports = {
devtool: 'source-map',
}
webpack支持12种sourcemap风格,每种方式的效果和速度各不同。官网有表格对比差异
选择合适自己的sourcemap,一般来说
开发环境下:eval-cheap-module-source-map (loader转换前,能定位到行)
生产环境下:nosources-source-map
webpack自动刷新问题
自动刷新带来了一个问题就是,会把输入框中你输入的文字刷新掉,还是麻烦一点,最好保留着。
问题核心:自动刷新导致页面状态丢失。最好的解决办法是,在页面不刷新的前提下,模块也可以及时更新。
HMR(模块热更新)
热更新:应用程序运行的过程中,实时的替换掉应用中的某个模块,应用运行状态并不受影响。
HMR是webpack最强大的特性之一,极大提高了开发效率。
开启,
// HMR已经集成在了dev-server中
const webpack = require('webpack')
module.exports = {
devServer: {
hot: true
// hotOnly: true // 只使用 HMR,不会热替换失败又刷新浏览器
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}
我们需要手动处理JS模块更新后的事情,CSS可以不用我们手动处理是因为css有规律可言,js没有,那react可以是因为react也是有规律可言的。
module.hot.accept('./editor', () => {// 处理逻辑})
webpack不同环境下的配置
开发环境注重开发效率,生产环境注重代码运行效率
如何解决呢:
配置文件根据环境不同导出不同配置 或 一个环境对应一个配置文件
// 配置文件根据环境不同导出不同配置
// webpack.config.js
module.exports = function(webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
const config = { ... }
return config
}
// 一个环境对应一个配置文件
DefinePlugin
mode = production模式下面,内部默认开启了很多功能。
比如DefinePlugin,为我们的代码注入全局成员,例如
process.env.NODE_ENV这样一个常量,很多第三方模块都会判断这么一个常量从而做不同操作
plugins: [
new webpack.DefinePlugin({
// 值要求的是一个代码片段
API_BASE_URL: JSON.stringify('https://api.example.com')
})
]
Tree Shaking
摇树,用来去除未引用代码,在生产模式下自动开启,我们来尝试在mode=none下开启Tree Shaking
module.exports = {
// optimization是集中去配置webpack内部优化功能的
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 压缩输出结果
minimize: true
// 尽可能合并每一个模块到一个函数中
concatenateModules: true,
}
}
代码分割
默认webpack会把所有代码都打到一个包中,如果代码太多,bundle就会很大,并不是每个模块在启动时都是必要的,最好是分离到多个bundle中,根据应用按需加载。
代码分割有2种方式:
- 多入口打包
- 动态导入
多入口打包
适用于多页面应用程序,一个页面对应一个打包入口,公共部分单独提取
module.exports = {
mode: 'none',
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // name会取自输入文件name
},
// 不同的打包接口中会有相同的代码出现,于是我们需要提取公共模块
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all'
}
},
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index'] // 指定需要的bundle
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album']
})
]
}
动态导入
动态导入的模块会被自动分包,动态导入更灵活,它可以根据代码逻辑实现是否需要导入
原理是利用import() 和 魔法注释,webpack会自动分包。
注释的格式如下,是固定的,相同的名字就会打包到一起。
在react和vue中,就可以在路由分发模块中利用动态导入实现按需加载
const render = () => {
const hash = window.location.hash || '#posts'
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
// mainElement.appendChild(posts())
import(/* webpackChunkName: 'components' */'./posts/posts').then(({ default: posts }) => {
mainElement.appendChild(posts())
})
} else if (hash === '#album') {
// mainElement.appendChild(album())
import(/* webpackChunkName: 'components' */'./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
}
}
render()
window.addEventListener('hashchange', render)
MiniCssExtractPlugin
提取css到单个文件
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader, // 个人经验超过150k再单独提取
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
OptimizeCssAssetsWebpackPlugin
压缩Css,webpack内置的压缩只能压缩JS,其他的压缩需要其他插件支持。
module.exports = {
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
}
minimizer,允许你通过提供一个或多个定制过的压缩插件,覆盖内置的minimize,所以webpack建议把压缩插件写在这里而不是plugin里
输出文件名Hash
生产模式下,文件名使用Hash,解决上线后缓存的问题
webpack及大多数插件的filename都支持用占位符实现hash。
'[name].[chunkhash:8].js'
hash:hash是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的hash值都会更改,并且全部文件都共用相同的hash值,每一次构建后生成的哈希值都不一样,即使文件内容压根没有改变。
chunkhash:它根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的哈希值,由于采用chunkhash,所以项目主入口文件main.js及其对应的依赖文件main.css由于被打包在同一个模块,所以共用相同的chunkhash。
这样就会有个问题,只要对应css或则js改变,与其关联的文件hash值也会改变,但其内容并没有改变,所以没有达到缓存意义,所以js可以用chunkhash。
contenthash:contenthash表示由文件内容产生的hash值,内容不同产生的contenthash值也不一样,css文件最好使用contenthash。