打包工具系列之——webpack 打包原理及优化

一、webpack 简介

webpack 是就是一个模块打包器(module bundler),它会根据模块建的依赖关系,递归的构建出一张依赖关系图(dependency graph)。图中包含了应用程序所依赖的每个模块,最后将所有的模块打包成一个或多个 js 文件(bundle)

二、核心概念


2.1 入口 entry

入口是告诉 webpack 从哪个文件开始构建依赖关系图。

2.2 出口 output

告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值是 ./dist

单入口

const path = require('path')
module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist')
    }
}

多入口

const path = require('path')
module.exports = {
    entry: {
        main: './src/main.js',
        home: './src/home/index.js',
        about: './src/about/index.js'
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'dist')
    }
}

2.3 模块 module

在 webpack 中,一切皆是模块。一个文件就是一个模块,


2.4 代码块 chunk

代码块,一个 chunk 由多个模块组合而成,用于代码合并和分割


2.5 加载器 loader

webpack 自身只能解读 JavaScript,对其进行文件的合并、压缩处理。但是我们实际项目中会用到很多类型的文件,如 css、less、jpg、jsx、vue 等等,webpack 本身是处理不了它们的,这个时候就要借助于各种 loader。

loader 的作用就是将各种类型的文件转换成 webpack 能够处理的模块,例如我们项目中使用了 less 语法,就要使用 less-loader 去把它转译成 css,然后 css-loader 去加载 css 文件,处理后交给 style-loader ,把内容写入到 html 中的 style 标签内。

module: {
    rules: [{
        test: /\.css$/,
        use: ['style-loader', 'css-loader', 'less-loader']
    }]
},

注意 loader 是从右往左执行的,一个 loader 处理完的结果会交给下一个 loader 继续处理,就像一条工厂流水线一样。 

这里需要注意一个非常重要的 loader,他就是 babel-loader。

2.6 插件 plugin

插件是用来处理各种任务的,比如代码的压缩,打包优化,等等。比如自动生成 html 文件的插件

const HtmlWebpackPlugin = require('html-webpack-plugin'); 
 
module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
        template: './src/index.html'
    })
  ]
};

2.7 模式 mode

告诉 webpack 使用相应模式的内置优化,有两种配置方式

配置文件

module.exports = {
  mode: 'production'
};

命令行

// CLI 参数中
webpack --mode=production

代码中可以这种区别环境

if(process.env.NODE_ENV === 'development'){
    //开发环境 do something
}else{
    //生产环境 do something
}

 2.8 源代码映射 source map

source map 是一种映射关系,当程序出问题时,我们想要从打包后的代码中去排查问题时比较困难的。这个时候我们就要借助于源代码去排查。具体配置有好几种方式:

devtool: 'source-map',

 source-map:会生成 map 格式的文件,里面包含映射关系的代码

inline-source-map:不会生成 map 格式的文件,包含映射关系的代码会放在打包后的代码中

inline-cheap-source-map:cheap有两种作用:一是将错误只定位到行,不定位到列。二是映射业务代码,不映射loader和第三方库等。会提升打包构建的速度。

inline-cheap-module-source-map:module会映射loader和第三方库

eval用 eval 的方式生成映射关系代码,效率和性能最佳。但是当代码复杂时,提示信息可能不精确

推荐方式

开发环境:

devtool: 'cheap-module-eval-source-map',

生产环境

devtool: 'cheap-module-source-map',

 三、基本原理

关于 webpack 打包原理,这篇文章讲的通俗易懂 深入剖析 webpack 打包生成的一大堆代码到底是啥_小辣抓-CSDN博客

我大致复述一下整个流程,加深一下自己的理解和印象,注意这里是基于 webpack4。

3.1 module 和 exports

(function (modules) {
    ...
})({
    './main.js': (function (module, exports, __webpack_require__) {}),
    './user.js': (function (module, exports, __webpack_require__) {}),
    './about.js': (function (module, exports, __webpack_require__) {})
})

webpack 打包完输出的 bundle.js 大概就是上面整个样子。它是一个自执行的函数,参数是 Object 对象,key 是我们项目中的每个模块的js 文件名称。value 是一个function,模块中的代码都会被解析后放在这个 function 内。

这些 function 会接收 module 和 exports 做为参数,这也是为什么我们在模块中使用 CommonJs 语法(也就是 modules.exports = {} 或者 直接 exports = {}),浏览器能够识别的原因。我们在模块的最后其实使用 module.exports = {},其实就是把模块的内容放进了 exports 对象里而已。

3.2 数组的形成

那上面这个传递给匿名函数的数组又是怎么形成的呢?过程大致如下:

  1. webpack 中有这么一个 Complier 类,它首先会读取 webpack.config.js 中的内容
  2. 然后通过 @babel/parse,从入口文件 entry 来构建一颗抽象语法树 AST
  3. 然后通过 @babel/traverse,根据抽象语法树得出入口文件的依赖模块
  4. 然后通过 @babel/core 和 @babel/preset-env,根据抽象语法树解析出入口文件的代码。这一步已经把 ES6 或更高版本的 js 转换了
  5. 然后循环递归入口文件的依赖,构建出一张依赖关系图。这就是我们上面用到的数组了
     

3.3 匿名函数内部

var installedModules = {};
 
function __webpack_require__(moduleId) {
 
    if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
 
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
 
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
}
return __webpack_require__(__webpack_require__.s = "./src/main.js");

这里定义了一个 __webpack_require__ 函数,是用来装载模块的。它的入参是模块的唯一标识,从 3.1 中我们可以这个唯一标识就是模块的文件名称。

装载后的变量会放在变量 installedModules 里面,防止同一模块重复装载

那么什么是装载?装载就是执行传进来的 modules 中的一个个函数,也就是会执行我们所写的业务模块里面的代码,把 exports 传进去,然后我们会这样 module.exports = {...},把内容给放进去。简而言之,装载就是把模块代码装进 exports 对象。

__webpack_require__ 函数最终会返回这个 exports。

我们模块中的依赖也会调用这个 __webpack_require__,比如 main.js 引入了 user,那么 main 函数内部会有这么一句 const user = __webpack_require__(/*! ./user */ "./user.js"); 这样我们就可以在 main 中开心愉快的使用 user 所提供的功能啦!(其实就是在用 user.js 中最后赋值的那个 exports)

3.4 整个流程

四、打包优化


4.1 代码拆分


4.1.1 CommonsChunkPlugin

  • 现在我们已经知道,webpack 会以入口文件 entry 为起点,将项目中所有依赖到的模块全都打包到 bundle.js 中。试想一下,我们把 react、react-router、redux、antd、lodash 等等第三方库,加上我们自己的业务代码全都打包进一个 js 文件,那么会有一下问题
     
  • 文件过大,十几 M 的 js 文件加载会需要很长时间,会导致页面长时间处于空白状态
  • 打包慢,每次 webpack 都要处理繁多的第三方库
  • 不利于缓存,我们一旦选择了一个版本的第三方库,就不会轻易改变它。但是哪怕只是修改了一行业务代码,打包只有又是一个全新的 bundle.js,浏览器还是得下载所有的代码 
  • 那么怎样去解决这三个问题呢?答案是代码拆分
  • 把稳定的、不变的代码打包在一起(第三方库)
  • 把公用的业务模块打包在一起,减少代码的重复量(避免多入口时,每个入口都要打包一份)
  • 把经常变动的代码打包到一起(业务代码)。
     

拆分之前

 抽离出第三方库

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor', // chunk 名称
    filename: '[name].js',
    minChunks: function (module, count) {
        // 项目中依赖的 node_modules 中的模块,都会被抽离到 vendor.js 中
        return (
            module.resource &&
            /\.js$/.test(module.resource) &&
            module.resource.indexOf(
                path.join(__dirname, './node_modules')
            ) === 0
        )
    }
}),

从 vendor.js 中抽离 webpack 运行代码

new webpack.optimize.CommonsChunkPlugin({  
// 这里是从vendor里面把manifest 这部分代码抽离出来
    name: 'manifest',
    chunks: ['vendor'] 
// 这个属性的意思是通过 chunk name 去选择 chunks 的来源。
//chunk 必须是  公共chunk 的子模块,指定source chunk,
//即指定从哪些chunk当中去找公共模块,省略该选项的时候,默认就是entry chunks
// minChunks: Infinity 
 // 这种写法和上面的写法效果一样,会马上生成公共chunk,但里面没有模块
}),

 抽离 entry1.js 和 entry2.js 中的公共模块

new webpack.optimize.CommonsChunkPlugin({
    name: 'common',
    filename: '[name].js',
    chunks: ['entry1', 'entry2']
})

这个时候页面的引入顺序应该时 manifest.js、vendor.js、common.js、入口文件

利用 hash 实现浏览器缓存

先来说一下哈希值的不同:

  • hash 是 build-specific ,即每次编译都不同——适用于开发阶段
  • chunkhash 是 chunk-specific,是根据每个 chunk 的内容计算出的 hash——适用于生产

缺点,很大的一点就是不好做懒加载

  • 它可能导致下载更多的超过我们使用的代码
  • 它在异步chunks中是低效的
  • 配置繁琐,很难使用
  • 难以被理解

4.1.2 SplitChunksPlugin


在webpack4抛弃了CommonsChunkPlugin,换成了更先进的SplitChunksPlugin。它们的区别就在于,CommonChunksPlugin 会找到多数模块中都共有的东西,并且把它提取出来(common.js),也就意味着如果你加载了 common.js,那么里面可能会存在一些当前模块不需要的东西。

而 SplitChunksPlugin 采用了完全不同的 heuristics 方法,它会根据模块之间的依赖关系,自动打包出很多很多(而不是单个)通用模块,可以保证加载进来的代码一定是会被依赖到的。

这是 splitChunks 的默认配置
 

splitChunks: {
    // async,只会去分离异步加载的模块,默认值
    // all,满足条件的任意一个模块都会参与分离
    // initial,只会分离入口文件中的公共模块
    chunks: "async",
    minSize: 30000,    // 默认只会分离大于 30KB 的文件,你可以根据实际情况修改它
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}

我认为实际项目中我们通常使用的应该是 all,entry1 中的 lodash 被打进了 vendors~entry1.js,entry1 和 entry2 中都有的 react 被打进了 vendors~entry1~entry2.js。

4.1.3 DllPlugin & DllReferencePlugin


不管是 CommonsChunksPlugin 还是 SplitChunksPlugin 都有一个问题,那就是每次打包时我们都会把庞大的第三方依赖包重新打一遍。这对最终上线的版本没有影响,应为第三方库的内容没有变,hash 值也不会变,所以不影响浏览器缓存。

但是它影响我们开发呀!!!每次都要去打这些包,没有必要的不是吗?dll 就是用来解决这个问题的,它就是把第三方包先打好,然后把再通过 dll 插件把这些打好的包,通过 <script> 标签引入到 html 里面去。这样我们在打包业务代码的时候就不用每次都对第三方库重新打一遍了,大大提高了打包效率。
手动配置会比较繁琐,这里有别人写好的例子:webpack_learn/optimization at master · skychx/webpack_learn · GitHub

可以选中这个插件自动配置,会简单很多 autodll-webpack-plugin

但是注意:Vue 和 React 官方 2018 都不再使用 dll 了

vue-cli 是这样说的:dll 配置将会被移除,因为 Webpack 4 的打包性能足够好的,dll 没有在 Vue ClI 里继续维护的必要了。

create-react-app 是这样说的:webpack 4 有着比 dll 更好的打包性能。

我的建议还是要根据项目的实际情况,如果项目中依赖的第三方库比较多,那么引入 dll 对打包提速还是有不少帮助的。


4.1.4 总结

4.3 懒加载

我们在页面初始化的时候,其实有很多隐藏的模块是不需要马上加载的,这个时候我们就可以利用懒加载,等到用到这个模块的时候才来加载进来

const user= () => import(/* webpackChunkName: "user" */ '@/pages/user.vue');
const father= () => import(/* webpackChunkName: "user" */ '@/pages/father.vue');

webpackChunkName 可以生命讲多个异步模块合并到一个文件当中 


4.2 本地分模块启动

当我们项目非常庞大时,全量打包会相当耗时,严重影响了我们的工作效率。这时候我们其实可以只打包我们需要的模块,其他的模块不打包。

例如当我们使用 vue 进行单页面开发的时候,会把项目中的全量路由传给 VueRouter

import routes from '@routes';
const router = new VueRouter({
  mode: 'history',
  routes
})

 那么这个时候我们只要控制引入的 routes 只有我们需要的模块就行了,也就是控制 '@routes'。这是一个别名,webpack 中可以配置

resolve: {
    alias: {
        '@routes': path.resolve(__dirname, 'src/routes/temp-routes.js'),
    }
}

全量的路由放在 routes.js 中,temp-routes.js 是一个中间过度文件。也就是说我们最终引入的路径是 temp-routes.js 文件,这个文件怎么来的呢?

  1. 在执行 npm run build 的时候后面加的参数 entry=xxx(路由)
  2. 在 webpack module.exports 之前,通过 process.argv.find(argv => argv.includes('entry')); 把命令行输入的 entry 解析出来;
  3. 然后使用 fs 模块,把 entry 联通其他的一些基本信息写入到 temp-routes.js 里
  4. 之后 new VueRouter 的时候,取的是 temp-routes(也是就上面写的别名 @routes) 就行了。

如此就实现了我们按需加载路由了,启动速度杠杠的


4.3 本地打包

当我们在测试环境,打包机器有限的情况下,很可能会出现多个项目排队打包的现象。这样会浪费测试同学很多时间。

在解决一个问题之前,我们需要充分的了解这个问题的本质。为什么排队时间会这么长呢?对于功能模块很多的项目,绝大部分时间是花在 webpack 打包 dist 目录静态文件上的,而把 dist 目录拷贝到 nginx 容器内,其实花不了多长时间。

那么既然知道问题的痛点在哪里了,我们就可以把视线聚焦多打包这个环节。我们可以利用开发同学本地机器的高性能,将打包好的 dist 目录推到远程仓库,这样在部署的时候,类似于 jenkins 等集成工具就可以直接拉取到打包好的 dist 目录,而不用执行 npm run build 那一步。大致步骤如下:

  1. 在 gitlab 远程仓库,新建一个项目专门用于本地打包
  2. 本地项目中新建一个 local.sh 脚本,package.json 中增加一条命令 "build:local":"./build/local.sh" 用于本地打包
  3. local.sh 中执行 npm run build 打包命令,生成 dist 目录
  4. 通常 dist 目录我们会在 .gitignore 中禁止提交到远程仓库的,现在我们要修改它:在 local.sh 中 fs.readFile('.gitignore', (err, data) => { data = data.replace('/dist', ''); fs.writeFile('.gitignore', data)})
  5. 现在我们要把 dist 提交到打包仓库去,但是我们本地的代码关联的远程仓库其实是 origin,所以现在我们要增加一个 build 仓库
  6. git remote add build 打包项目 git 地址(一份代码提交到两个 git 仓库,https://blog.csdn.net/qq_25458977/article/details/87875641
  7. 然后依次执行 git add .   git commit -m "xxx"   git push build ${branch} -f
  8. 到此处,dist 目录就已经提交到 build 仓库了,Jenkins 就可以直接拉去 build 仓库中的代码去部署,在也不用排队啦!
  9.  但是我们修改了 .gitignore 对不对?并且增加了一次 commit 对不对?我们只是想本地打个包,并不想影响到 origin 仓库啊!那我们就在 local.sh 最后加一行 git reset --hard HEAD~1,一切还原到打包之前,搞定!
     

热更新原理:

Webpack 热更新实现原理分析 - 简书

https://segmentfault.com/a/1190000020310371

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

结果才重要

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值