webpack的概念
webpack 是前端静态资源打包器,通过指定一个入口文件,形成模块的依赖关系树,每一个依赖俗称 chunk,然后通过指定每一个 chunk 对应的打包方式,使之输出为浏览器可以解析的 bundle。我们得到了不同的bundle之后,再在html页面中分别引入,我们就可以看到呈现出来的页面了。
webpack的五个核心的概念
Entry:指示webpack以哪个文件为入口起点开始打包。
Output:指示webpack打包之后的bundle输出到哪里去,以及如何命名。
Loader:webpack 本身是只能处理js文件和json文件,而loader则充当了一个翻译官的角色。将别的类型的文件处理成webpack能够看懂的文件。
Plugins:用于执行范围更加广泛的任务,插件包括优化和压缩,一直到重新定义环境中的变量等等。
Mode:指示 webpack 使用相应的模式。development/production
webpack打包各种资源
打包样式资源
我们在项目中写样式的时候,通常不会直接写成css而是会借助less scss等样式资源的预处理器,那么我们在使用webpack进行打包的时候就要考虑处理.css .less .scss等后缀的文件。由于webpack只能处理js和json结尾的文件,所以我们要借助loader来帮我们把其它后缀的文件“翻译”成webpack能够处理模块。
// 假设我们使用scss,应该进行如下webpack配置
module.exports = {
entry: 'main.js',
// ...
// 在 webpack 配置中定义loader时,要定义在module.rules中
module: {
rules: [
{
test: /\.scss$/,
use: [
{ loader: 'style-loader' }, // 第三步执行
{
loader: 'css-loader', // 第二步执行
options: {
modules: true,
},
},
{
loader: 'scss-loader', // 第一步执行
},
],
},
],
},
}
上述配置的意思是:“嘿,webpack 编译器,当你碰到「在 require()/import 语句中被解析为 '.scss' 的路径」时,在你对它打包之前,先使用 scss-loader 转成css文件,然后在用css-loader将css文件翻译成webpack可处理的模块,然后在用style-loader将处理好的css插入至html的head标签中。”
可以发现loader的解析顺序是从数组的最后一项向前执行的,这是配置的规定,不能够乱序。正是因为有了loader的存在,我们才能够在js文件中大胆的引入scss文件,其它文件同理。,如:
// 在vue的主入口main.js中,我们时常这样写
import '@/style/index.scss'
而通常我们对css的处理不仅仅是这么简单,我们可能还要对它进行压缩,提取以及进行浏览器的兼容性处理等等
兼容性处理:postcss-loader与postcss-preset-env
{
loader: 'postcss-loader',
options: {
plugin: () => [
require(postcss-preset-env)(),
],
},
},
postcss-preset-env是帮助postcss找到package.json中的browsersList(也可能是在项目目录下的.browsersListrc)的配置,通过配置对css进行兼容处理。
提取css:mini-css-extract-plugin这是一个插件,这个插件的作用是将 css-loader 转化后的模块抽取成独立的css文件,打包之后通过link标签将样式引入进 html,我们可以指定打包后的css存在的文件路径,在插件中传递filename参数即可(显然这和style-loader的功能是冲突的,这个插件中有自己内置的loader。所以按照自己的需求进行配置)。
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
};
压缩css:optimize-css-assets-webpack-plugin
至此我们就完成了项目中对css文件的打包处理,过程大致如下:
- 当解析到
import 'xxx.scss'文件时,首先我们需要配置loader,将scss转化成css-scss-loader- 经过第一步我们得到了转化来的css,然后要将它转化成webpack可解析的模块-
css-loader- 使用
style-loader将处理好的css以<style>标签的形式插入到html的head标签中- 如果不想操作3步骤,我们选择将css提取出来,那么就用到了
mini-css-extract-plugin- 我们需要根据
package.json中的browsersList选项的设置,来对css进行兼容性处理-postcss-loader与postcss-preset-env- 做好了兼容性的处理我们需要对css进行压缩,以减少项目包的体积-
optimize-css-assets-webpack-plugin
这是一个生产环境的配置,如果开发环境考虑调试等因素,可以不做压缩等处理,根据团队需求进行配置。
打包js资源
语法检查:eslint-webpack-plugin对js语法做校验,使用eslint进行代码检查,必须要先进行eslint的校验规则,现成的语法校验已经有很多种风格的规则,可以在github上面进行搜索查看。可以使用现成的,也可以是我们自己进行配置。这个配置可以是我们在项目目录下创建的.eslintrc文件,也可以是我们在package.json中的eslintConfig选项。如果我们使用vue与vue-cli等框架搭建项目,我们创建的时候会提供这个选项以及选项中的一些默认配置。
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
// ...
plugins: [new ESLintPlugin(options)],
// ...
};
兼容性处理:babel-loader babel @babel/core(babel的核心库)。
@babel/preset-env:只能够处理js新增的语法变化,不能够处理js的全局对象,如:Promise@babel/polyfill:能够做全部的js兼容性处理,但它不是一个插件,直接在写js文件的时候第一行引用即可。但是如果只向处理部分兼容性的问题,缺引入了所有兼容性的代码,会导致代码体积太大!core-js:按需加载
js压缩:生产环境下将mode设置为production即可
至此我们就完成了项目中对js文件的打包处理,大致过程如下:
- 先对js文件进行eslint语法检查
- 使用
babel-loader对js语法做兼容性的处理,在其中需要设置presets,babel会根据preset的设置对我们写的代码进行语法校验。preset会有三种值:@babel/preset-env@babel-polyfillcore-js。core-js需要设置在@babel/preset-env之后。{ userBuiltInt:'usage', // 指定按需加载 corejs:{ version:3 // 指定core-js使用的版本 }, target:{ // 具体的兼容性做到那个版本的浏览器 Chrome:60, firefox:60 } }
- 处理完成之后,考虑代码体积我们需要对js进行压缩。由于webpack本身就能够处理js文件。所以压缩js并不用使用任何loader与plugin。直接设置
mode:production即可module.exports = { mode: 'production' };
打包图片资源
转化成webpack可处理的模块:url-loader。但是它依赖于file-loader,所以要安装这两个依赖。url-loader可以设置limit参数,限制几kb以下就使用base64的处理方法。
module.exports = {
module: {
rules: [
{
test: /.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
],
},
};
处理html中的图片:url-loader有一个缺点,就是处理不了html文件中通过img标签引用的图片,所以我们还需要借助html-loader来处理。html-loader默认采用的是commonjs的模块化规范,而url-loader默认是使用es6的模块化规范,所以使用html-loader的时候需要将url-loader中的options传入esModule:false选项
打包其它资源
在webpack4中可以使用file-loader,在webpack5中,可以直接设置Asset Modules的type,详情参考Asset Modules
对html的处理
使用html-webpack-plugin,这个插件主要作用是在最终生成的目录下创建一个空的html文件,然后引入所有打包好的资源。如果想要其不创建新的模板,而是使用我们自己写好的模板,那么就需要传入一个 template 选项
DevServer
作用:用来自动化,自动编译,自动打开浏览器,自动刷新浏览器等等。
特点:只会在内存中编译打包,不会有任何的本地输出。
webpack的优化项
开发环境的优化项
开发环境我们需要优化的打包项为:
- 优化打包构建的速度
- 优化代码调试
HMR(hot module replacement)模块热替换/热模块替换
作用:一个模块发生变化,只会重新打包这一个模块,而不是打包所有,这样会极大地帮我们提升代码的构建速度。
用法:
DevServer:{
hot:true
}
- css可以使用HMR功能,这全都依赖于
style-loader,详情参考HMR with Stylesheets,我们想要在开发环境使css也发生热更新,则需要配置style-loader - js默认是不支持HMR的,想要支持HMR需要修改js的代码(只能处理非入口文件),我们需要在
index.js文件中写下代码:
// index.js
if (module.hot) {
module.hot.accept('./print.js', function() { // 如果print.js文件发生了变化,只执行回调函数
console.log('Accepting the updated printMe module!');
printMe();
})
}
- nodejs的热更新,请参考Via the Node.js API
- html不用做热更新
source-map
提供源代码到构建后代码映射的技术,如果构建后代码出错了,可以通过映射追踪到源代码
用法:在 webpack 的配置中配置devtool:'source-map'即可
source-map 的取值:[inline-][eval-][hidden-][nosources-][cheap-[module-]]source-map
source-map:外部的,会提示到代码的错误位置和错误信息。inline-source-map:内联的,不会生成 map 文件,会内联在打包后的 js 文件中,会提示到代码的错误位置和错误信息。eval-source-map:内联的,不会生成map文件,每一个文件都生成对应的source-map文件,放在eval中,会提示到代码的错误位置和错误信息,指示会多出一个hash值。hidden-source-map:外部的,会生成 map 文件,会告诉你错误原因,但是不能追踪到源代码的错误。它所指示的错误位置还是构建后的代码。nosources-source-map:外部的,能够找到错误代码的准确信息,但是不能找到源代码。cheap-source-map:外部的,也能够找到准确的信息,和源代码,但是只能精确到行,其它的是能够精确到行和列的cheap-module-source-map:外部的
内联和外部的区别:外部生成了 map 文件,而内联没有,内联的构建速度相对来说会快一些
开发环境考虑速度快,调试更友好
- 构建速度:eval>inline>cheap>...
- eval-cheap-source-map
- eval-source-map
- 调试更友好
- source-map:精确到行和列
- cheap-module-source-map:module 会将 loader 的一些 source-map 也融合进来,它更加的全面具体
- cheap-source-map:精确到行
- 最佳选择: eval-source-map > eval-cheap-mudule-source-map
生产环境考虑源代码要不要隐藏,调试要不要更友好?
- 源代码隐藏:
- nosources-source-map 源代码和构建后代码都隐藏
- hidden-source-map 只隐藏源代码,不提示构建后的代码错误
- eval-source-map 因为它是内联的,生产环境下我们不考虑内联的这种方式,因为内联会让包的体积变得非常的大
- 考虑调试的话:
- source-map
- cheap-mudule-source-map
生产环境的优化项
生产环境我们需要优化的打包项为:
- 优化构建速度
- 优化代码性能
OneOf
这个配置项是处理loader的,通常情况下很多个loader会挨个匹配,直到命中。加了oneof配置项之后,就设置了底下的loader只会命中一个。
注意:Oneof 的配置项中,不能够同时写两个相同的test匹配规则,否则只会生效优先级高的,优先级低的都不生效。假设我们写了eslint-loader和babel-loader,那么babel-loader就不生效了。因为eslint-loader权重高一些。这时候需要将eslint-loader写在与oneof同级并在其配置项之前。这样我们就可以使 loader正常生效了。
缓存
babel缓存
我们在编译了代码之后,可能想要修改某一个 js 模块,但是 babel 在此编译的时候会将所有模块在处理一遍,这样子性能也是十分低下的。所以这时候我们可能需要为 babel 设置一下缓存。
方法:在babel-loader的options中添加cacheDirectory:true。这样就设置了babel再次编译的时候取缓存,只重新编译改动的模块
静态资源缓存
我们打包编译后的代码,被浏览器请求访问的时候,可以设置缓存。也就是强缓存与协商缓存。
- 强缓存资源后,在没有过期之前不会重新发送请求获取
- 协商缓存是在资源发生变化了之后,重新发送请求确认是否需要走缓存。如果需要就使用本地缓存,如果不需要就重新请求
我们使用webpack打包之后,是可以通过配置打包的规则,来协助处理请求资源时的缓存。
chunkHash:根据chunk生成hash值,如果打包的来源是同一个chunk,那么生成的hash值还是一样的。这样导致的问题是:css是从js被引入的,他们来源是同一个 chunk。如果只改变了css文件或者是js文件,js和css文件会一起重新请求,不会命中缓存,因为二者是使用的同一个 hash 值,一个变了,另外一个就会发生变化。为了解决这个问题,webpack 提出了 chunkHash 的配置项。
contentHash:根据文件内容生成的 hash,不同的文件生成的 hash 一定是不一样的。
所以我们更新迭代的时候,如果hash值发生了变化就代表是更新后的代码。浏览器请求时会重新请求新资源,如果没有发生变化,那么就走缓存。
Tree Shaking去除打包后的无用代码
使用前提:
- 项目使用的是es6的模块(
tree shaking本质就是对静态模块进行分析) - 需要设置
mode:prodution
如果不想某一些文件进行tree shaking,可以在package.json中设置sideEffets:['*.css'],那么webpack在处理这个文件的时候就不会对其进行tree shaking,如果sideEffects:false就表示所有代码都没有副作用,可以进行tree shaking
code split 代码分割
代码分割分为三种情况:
- 多入口打包
- webpack的优化选项
- 动态导入
多入口打包
在
Entry选项中设置为对象或数组。对象属性值和数组的每一项为一个入口,有几项就有几个值。webpack打包的时候会从这几个入口分别构建关系树,对代码进行分割。
webpack的优化选项
在webpack的optimization选项中配置
splitChunks:{chunks:'all'},这样配置的好处:
- 在多入口打包的时候自行分析多入口的chunk文件看是否含有公共文件,如果有的话也会单独打包成一个chunk
- webpack把node_modules中引入的包单独打包。这样做的好处是我们在很多js文件中都引入了同一个公共模块的时候。公共模块会被单独打包一次,然后进行引用,而不是每次加载一个文件,公共模块就重新加载一次
动态导入
指定某一个模块单独打包。
方式:
import('./test.js')特点:打包后的文件名为webpack自动生成的id,如果以后动态引入了其他模块,这个id是会随着打包的顺序发生变化的。所以如果我们想要对这一点进行控制,就需要在引入的时候自行设置这个模块打包之后的名字:
import(/*webpackChunName:'test'*/./test.js)
懒加载和预加载
前提:进行了代码分割,分割成为了单独的模块之后才能实现懒加载或者是预加载(浏览器正常的加载顺序是,资源并行加载,同一个时间可以加载多个文件)
- 懒加载:使用 js 动态引入的方法,让资源不去挤最初的加载通道。需要的时候再使用 import()加载
- 预加载:在 import(/* prefetch:true */'./test'),动态引入中加入注释,注释资源为预加载资源。那么浏览器正常加载完其它资源之后,就会偷偷的加载标记为预加载资源的内容。(兼容性问题,慎用!)
PWA
让网页像一些 app 一样离线也可以访问。渐进式网络开发应用程序。
PWA的使用一定要借助serviceWork,在webpack中的配置需要使用work-box-plugin,这个插件帮助serviceWork快速启动并且我们在打包后的根目录生成一个service-work.js的文件。我们需要在index.js 中写下如下代码:
if(serviceWork in navigator){
window.addEventListener('load',()=>{
navigitor.serviceWork.register('./serviceWork').then().catch()
})
}
这样我们就注册了serviceWork文件。但是serviceWorker代码的运行必须是在服务器上,所以我们需要启动一个服务来运行service-worker。
多进程打包thread-loader
使用场景:只有在工作消耗时间长才需要多进程打包,不然进程的开启和通信都是需要时间的,默认开启cpu核数-1个进程来处理内容。
thread-loader放在某一个loader的后面,它就会来启动进程打包,通常在配置中我们会选择放在babel-loader的后面。
使用cdn的方式引入,不需要打包
有时候我们用cdn引入的文件不想让webpack打包,使用external把它排除掉。
比如我们引用了JQuery的cdn,但是不想让它被webpack处理,就在external选项中屏蔽它:
externals:{
jQuery:jQuery, // 库名:npm 对应的包名
}
dll动态链接库
使用场景:不使用CDN的方式引入,需要打包。但只需要打包一次,后面的打包只要当前的库没有发生变化,就不用再重新打包。例如:我们没有通过CDN引用JQ,在本地放入了JQ的包,引用使用,我们在打包的时候希望webpack单独打包。这时候就可以使用dll来灵活配置它。以jQuery为例:
- 创建一个名为
webpack.dll.js的文件来单独打包第三方的库。设置:module.exports = { entry:['...','...','...'], output:{ library:'jQuery', // 设置我们通过哪一个变量访问Jquery } // ... }
- 借助
dllPlugin生成一个manifest.json映射表,只要webpack在这个映射表中查找到了某一个包的映射,就不会再去打包它了。const path = require('path'); new webpack.DllPlugin({ context: __dirname, name: 'jQuery', // 要与library的值相对应 path: path.join(__dirname, 'manifest.json'), });
- 借助
dllReferencePlugin插件,让webpack去查找manifest映射表,告诉webpack在这个映射表下的第三方模块都是不需要打包的。- webpack 忽略了映射表中的第三方模块,但是没有与开发者的业务代码建立关联关系,所以我们需要使用
add-assets-html-webpack-plugin,这个插件会将某个文件打包输出出去,并在 html 中引入
手写一个mini版的webpack
webpack处理的过程首先是通过指定的入口文件,进行模块解析,生成一份模块之间的依赖关系图。根据这一份依赖关系图生成对应环境能够执行的代码。
首先我们可以创建一些模块,让这些模块之间有一些引用关系,例如:
- npm init -y
- 创建
name.js,写入export default "yeyeye"- 创建
message.js,写入import name from "./name.js"export default 'Hello-${name}'- 创建
entry.js,写入import message from "./message.js"console.log(message)
其次,创建一个webpack.js文件来实现我们的mini-webpack
/**
* 手写webpack
*/
const fs = require('fs')
const babylon = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const path = require('path')
// 读取模块内容
let ID = 0
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8')
// 生成ast
const ast = babylon.parse(content, {
sourceType: 'module',
})
const dependencies = []
// 遍历当前ast
traverse(ast, {
// 找到所有import语法对应的节点
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value)
},
})
const id = ID++
const { code } = babel.transformFromAstSync(ast, null, {
presets: ['@babel/preset-env'],
})
return {
id, // 当前所解析的模块的id
filename, // 当前这个模块的名称
dependencies, // 当前这个模块所依赖的模块
code, // 当前这个模块的源码
}
}
// 从入口开始分析依赖,生成依赖关系图
function createGraph(entry) {
const mainAsset = createAsset(entry)
// 既然要广度遍历,那么一定需要一个队列,而队列的第一项就是createAssets返回的信息
const queue = [mainAsset]
// 开始进行遍历
for (let asset of queue) {
const dirname = path.dirname(asset.filename)
// 新增一个属性来保存自已来项的数据
// 保存一个类似 这样的数据结构------>{'./message.js':1}
asset.mapping = {}
asset.dependencies.forEach(relativePath => {
// 得到当前依赖项的绝对路径
const absolutePath = path.join(dirname, relativePath)
// 获取依赖项的具体信息
const child = createAsset(absolutePath)
// 将子依赖项的id与信息存储起来
asset.mapping[relativePath] = child.id
// 在接着遍历子依赖项
queue.push(child)
})
}
// 这个是广度遍历之后的结果,项目中的每一个模块的应用都扁平化的存在其中
return queue
}
// 根据依赖关系图,生成对应环境能执行的代码,目前是生产浏览器可以执行的
function bundle(graph) {
let modules = ''
// 循环关系图,并把每个模块的代码存在function作用域中
graph.forEach(mod => {
modules += `${mod.id}:[
function(require,module,exports){
${mod.code}
},
${JSON.stringify(mod.mapping)}
],`
})
// require,module,exports是cjs的标准不能再浏览器中直接使用,所以这里模拟common.js的模块加载,执行,导出操作
const result = `
(function(modules){
// 创建一个require函数,它接受一个模块ID,它会根据模块ID寻找对应的模块
function require(id){
const [fn,mapping] = modules[id]
function localRequire(relativePath){
// 根据模块的路径在mapping中找到对应的模块id
return require(mapping[relativePath])
}
const module = {exports:{}}
//执行每个模块的代码
fn(localRequire,module,module.exports)
}
// 执行入口文件
require(0)
})(${modules})
`
return result
}
const graph = createGraph('./entry.js')
const ret = bundle(graph)
fs.writeFileSync('./bundle.js', ret)
执行node webpack.js即可在当前目录下生成一个bundle.js,我们将其在在html页面中引入,在浏览器中打开控制台,即可看到hello-yeyeye。以上就是webpack处理我们代码的大致流程。
错误之处请多指正,遗漏之处欢迎补充