webpack基础——《webpack实战 入门、进阶与调优》读书总结

这篇是我看《webpack实战 入门、进阶与调优》这本书的一个笔记,也相应扩充了部分内容,可以算是给没读过的人做个引子。这本书比较系统地介绍了webpack的基础,阅读量也不大,让我弄清楚了很多以前模糊的点。

1、安装webpack

安装webpack建议本地安装(不使用全局),因为全局安装的话项目在不同机器下可能出现版本不一(本地安装能保证团队的版本一致),并且使用时可能出现本地和全局webpack版本混乱的情况。所以干脆就本地安装。

安装:npm i webpack webpack-cli --save-dev

webpack为核心库,webpack-cli是命令行工具。

由于是安装于本地,所以可以使用 npx webpack 来使用。(npx是nodejs自带的自动执行本地模块的一个命令,具体可以参考npx 使用教程

2、JS的模块管理

在es6 module未成为标准前,有2个比较多人使用的模块管理方案:AMD和commonJS。这两者都是通过编译后生成runtime,在代码运行过程中动态引入。目前commonJS是nodejs的模块管理标准。

  • AMD(Asynchronous Module Definition 异步模块定义):通过声明回调函数异步加载模块,将其他模块以依赖注入的方式加入进当前模块,由于是异步加载所以不会阻塞当前模块加载。(参考:RequireJS和AMD规范
// foo.js export
define({
    method1: function() {},
    method2: function() {},
});
   
// import
require(['foo'], function ( foo ) {
        foo.method1();
});
  • commonJS:通过一个模块对象引出、引入其他模块,引入的值为拷贝值,相较AMD语法更简单方便;
// export
module.exports = {name: 123}

// import 
var name = require('./export.js').name
  • es6 module:通过语法静态引出、引入其他模块,是在编译期间进行,引入的值只读的变量映射(因此可以解决循环引用的问题,通过包裹一个函数的方式,参考:JavaScript 模块的循环加载),所以原始值改变会影响到引用者。因为是静态引用,所以不像前两者可以将import语句写在任意地方,如if判断内,需要在编译过程就确定是否引入。
// export
export const a = 123;

// import 
import {a} from './export'
  • 三个模块引入的方式,都在第一次引入时将该目标模块代码执行一遍。
    PS:因为前两者不是官方标准,所以都需要借助webpack等模块打包工具进行编译打包后才可以在浏览器上运行,而es6 module可以直接在浏览器上运行,只需要把script标签的type设置为module。但如果你在本地想试验一下es6 module则会发现,本地引入模块时用的是file协议,因为在浏览器引入js资源时需要域名、协议、端口一致,所以在file协议下没有域名会触发跨域限制(chrome、firefox会,ie不会),导致引入失败。
  • UMD(Universal Module Definition 通用模块标准):这不是一个模块管理方案,是一个统一所有模块管理方案的解决。webpack中则应用了这个方案。由于会通过全局是否有define函数来判断AMD环境,但在AMD的规则下是无法使用commonJS和es6 module的,所以如果项目全使用commonJS却因为某些原因出现了define函数,则可能导致全部模块失效,需要手动去修改webpack UMD模块的判断顺序。
/*
 UMD判断模块管理方案的源码
 */
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['b'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory(require('b'));
    } else {
        // Browser globals (root is window)
        root.returnExports = factory(root.b);
    }
}(this, function (b) {
    //use b in some fashion.
    // Just return a value to define the module export.
    // This example returns an object, but the module
    // can return a function as the exported value.
    return {};
}));

3、chunk、entry、bundle的基本概念

chunk:打包的模块
entry:打包的入口文件
bundle:每个模块打包好后的文件1

image
图片来自《Webpack实战:入门、进阶与调优》

4、配置资源入口

// webpack.config.js
module.exports = {
    /*
    * context配置entry的路径前缀,可以理解为入口的文件上下文,所以是绝对路径,这样在多个entry的时候写起来比较方便
    */
    context: path.join(__dirname, './src'),
    
    /*
    * 打包入口文件
    * 入口可为多个,entry的值可以是数组、字典,用函数或promise返回这两种数据结构也可以
    */
    entry: './index.js'
}

5、提取公共模块

像loadsh、jquery这些第三方的库,如果都跟业务代码一起打进一个bundle文件就会很大,并且每次代码更新都需要更新整个文件。这时候可将一些公共模块抽出来,就不用跟业务代码混杂在一起了。

module.exports = {
    entry: {
        app: './index.js', // 主入口
        vender: ['react', 'lodash', 'jquery'] // vender是‘提供商’的意思,这里理解为第三方模块
    }
}

6、配置资源出口

资源出口的配置在output对象种配置。

module.exports = {    
    entry: './src/app.js',    
    output: {        
        // bundle文件名
        filename: 'bundle.js',       
        // bundle导出路径
        path: path.join(__dirname, 'assets'), 
        // 资源访问上下文       
        publicPath: '/dist/',    
    },
};

output详解:

  • filename的书写形式:
// 直接写bundle名
filename: 'bundle.js'

// 相对路径,webpack会自动帮你创建src文件夹
filename: './src/bundle.js'

// 动态指定文件名,具体看下图
filename: '[name].js'
image
图片来自《Webpack实战:入门、进阶与调优》
  • path:打包资源输出路径,默认为项目根目录的dist文件夹,需要配置绝对路径;
  • publicPath:资源的请求位置,html直接请求的资源如sript标签里的js,和js或css的间接请求的资源资源如引入模块和css加载背景/图,这些都属于资源请求,他们的路径都会在前面加上publicPath;
// 相对路径则会从当前请求的文件路径开始衔接
publicPath: './js'
// 在app目录下的html直接请求的资源index.js => www.example.com/app/js/index.js 

// 以 / 开头,则直接从域名后开始衔接
publicPath: '/js'
// 请求资源index.js => www.example.com/js/index.js

// 绝对路径,一般用CDN的场景
publicPath: 'www.cdn.com/js'
// 请求资源index.js => www.cdn.com/js/index.js
  • output.path建议跟devServer里面的publicPath保持一致,这样在开发和生产环境才不会搞混,具体原因可以看2、webpack-dev-server的解释;

7、webpack模块打包的简单原理(理解地比较粗浅)

通过声明一个installedModules字典来存储每个模块,给每个模块设置一个唯一key,全部传入一个立即执行的匿名函数,有个入口模块,在里面执行所有模块并存储进installedModules,已经执行过的模块会直接拿缓存。

webpack编译打包后的代码,在浏览器中是这么运行的:1、初始化环境和一些数据结构;2、执行入口模块代码;3、执行模块代码,记录export和导出import(递归);4、所有模块代码执行完毕,控制权回到入口模块;

8、loader

loader可以译为装载机,在webpack中一切皆模块,这也是为什么引入css需要在js中import,因为webpack只能识别js,而一个组件或者页面的js+css就是一个模块,所以通过在js中引用css的方式来将其绑定成一个模块。

// app.js
import './style.css';

// style.css
body {    
    text-align: center;    
    padding: 100px;    
    color: #fff;    
    background-color: #09c;
}

loader其实一个函数,它的输入和输出是源码或上一个loader的输出(字符串、source map、AST),所以loader的调用是链式的,像一个流水线一样将模块打包出去。这也意味着loader的声明是需要注意顺序的。
ps:source map是一个json文件,用来解决代码编译前后的映射问题

loader配置:

module.exports = {      
    module: {        
        rules: [{         
            // 正则匹配需要进入loader的文件   
            test: /\.css$/,    
            
            // 用到的loader数组(loader的执行顺序从后到前,所以这里是css-loader先执行)         
            use: [
                'style-loader',  
                
                // loader除了上面'style-loader'这种直接声明字符串
                // 还可以像下面'css-loader'这样声明一些配置项
                {
                    loader: 'css-loader', 
                    options: {
                          // css-loader 配置项
                      }
                }
            ],  
            
            // loader处理文件的排除范围
            exclude: /node_modules/,  // 正则 
            
            // 处理范围,exclude优先于include,意味着如果两个配置有重叠,include是不能覆盖exclude的
            include: /src/,  // 正则 
            
            /*
            * 在Webpack中,我们认为被加载模块是resource,而加载者是issuer。
            * 比如在这个例子里,css文件是加载模块(resource),js文件则是加载者(issuer)
            * 所以下面是配置js文件,则是配置加载者
            * 前面的loader则是配置加载模块
            */
            issuer: {
                test: /\.js$/,
                include: /src/pages/  // 正则 
            },
            
            // loader执行顺序:
            // normal(默认,按排列顺序)、pre(在所有正常loader前)、post(在所有正常lodaer后)
            enforce: 'normal',
        }],    
    },
};

9、写一个最简单的loader

上面说了loader其实就是一个有输入输出的函数,所以最简单的loader其实只要写一个函数就行。

  1. 用 npm init 初始化一个项目;
  2. 创建一个index.js写入以下代码;
// 这个loader可以在js文件头部加上 “这是我加上去的代码” 这句注释
// content 则是loader的输入即源码或上一个loader的输出字符串
module.exports = function(content) {     
    var useStrictPrefix = `
        // 这是我加上去的代码
    `;

    return useStrictPrefix + content;
}
  1. 在另一个项目通过 npm install <绝对路径> 来安装loader;
  2. 在webpack配置文件中写入loader配置;
  3. 执行编译;

10、webpack-dev-server

开启一个热更新的服务,可以修改代码后通过websocket通知浏览器更新。devServer会对代码进行编译打包,但不会生成文件,打包后的代码会放进内存访问,当浏览器对这个服务发起请求,它会先校验请求的url是不是配置文件里devServer的publicPath。

安装:npm install --save-dev webpack-dev-server

配置:

devServer: {
    /*
        devServer.contentBase
        
        决定了 webpackDevServer 启动时服务器资源的根目录,默认是项目的根目录。
        
        在有静态文件需要 serve 的时候必填,contentBase 不会影响 path 和 publicPath,
        它唯一的作用就是指定服务器的根目录来引用静态文件。
        
        可以这么理解 contentBase 与 publicPath 的关系:contentBase 是服务于引用静态文件的路径,
        而 publicPath 是服务于打包出来的文件访问的路径,两者是不互相影响的。
    */
    contentBase: './dist',
    
    /*
        devServer.publicPath
        
        在开启 webpackDevServer 时浏览器中可通过这个路径访问 bundled 文件,
        静态文件会加上这个路径前缀,若是devServer里面的publicPath没有设置,
        则会认为是output里面设置的publicPath的值。
        (如果有使用htmlWebpackPlugin,建议devServer.publicPath不填或者跟output.publicPath一致,
        因为在开启devServer后,htmlWebpackPlugin插入js会使用devServer.publicPath)
        
        和 output.publicPath 非常相似,都是为浏览器制定访问路径的前缀。
        但是不同的是 devServer.publicPath 只影响于 webpackDevServer(一般来说就是 html),
        但各种 loader 打出来的路径还是根据 output.publicPath。
    */
    publicPath: './dist'
}

参考:https://github.com/fi3ework/blog/issues/39

11、代码分片

考虑到缓存和减少请求时间等原因,需要将公共代码分块。不同于之前使用的CommonsChunk-Plugin插件,webpack4有了改进版的代码分片配置optimization.SplitChunks。

不像CommonsChunk-Plugin需要去将特定的模块提取出来,使用SplitChunks只需要配置提取条件,webpack就会将符合条件的模块打包出来。下面是默认配置:

optimization: {        
    splitChunks: {     
        // chunks: async(默认,只提取异步模块) | initial(只提取入口) | all(前两者都提取)
        chunks: 'all',    
        
        // 按cacheGroups的提取规则,并以automaticNameDelimiter为分隔符命名chunks
        // eg: vendors~a~b~c.js意思是该chunk为vendors规则所提取,并且该chunk是由a、b、c三个入口chunk所产生的。
        name: true,
        // chunk命名的分隔符
        automaticNameDelimiter: '~', 
        
        /* 
        * 根据chunk资源本身情况配置规则
        */
        // 提取后的Javascript chunk体积大于30kB(压缩和gzip之前),CSS chunk体积大于50kB
         minSize: {      
             javascript: 30000,      
             style: 50000,    
         },         
         // 在按需加载过程中,并行请求的资源最大值小于等于5
         maxAsyncRequests: 5,  
         // 在首次加载时,并行请求的资源数最大值小于等于3  
         maxInitialRequests: 3,
         // 备注:设置maxAsyncRequests和maxInitialRequests是因为不希望浏览器一次发出过多请求,
         // 所以希望把一次加载的模块限定规定次数;
        
        /* 
        * 根据chunk来源配置提取规则
        */
        cacheGroups: {        
            // 模块来自node_modules目录,vendors只是chunk命名,可灵活调整;
            vendors: {            
                test: /[\\/]node_modules[\\/]/,            
                priority: -10, // 优先级,这里vendors优先        
            },
            // chunk被至少两个模块引用则重用
            default: {            
                minChunks: 2,    
                reuseExistingChunk: true,   
                priority: -20,          
            },
        }
    },    
}

// 正常只需要像下面这样声明即可
optimization: {        
    splitChunks: {     
        chunks: 'all'
    }
}

12、webpack异步模块加载

// 异步地将b.js加载进来
import('./b.js').then((b) => {
    ...
})

这个异步import,webpack是通过动态插入script标签来实现的,因为之前提过,通过script加载进来的属于间接资源请求,这个资源位置需要通过output.publicPath来确定,所以需要配置号output.publicPath;

13、环境区分

在开发过程中,需要区分开发环境和生产环境,开发环境一般完成基本的编译打包工作,让代码能在浏览器运行就好,而生产环境为了更小的包体通常还会进行压缩、tree-shaking等操作。将这两种环境的操作区分开来,一般有两种方案:

  • 通过命令传入环境变量
// package.json
{  ...  
    "scripts": {    
        "dev": "ENV=development webpack-dev-server",    
        "build": "ENV=production webpack"  
    },
}
        
// webpack.config.js
const ENV = process.env.ENV;
const isProd = ENV === 'production';
module.exports = {  
    output: {    
        filename: isProd ? 'bundle@[chunkhash].js' : 'bundle.js',  
    },  
    // mode模式如果为production,webpack会默认添加一些配置,帮助压缩代码
    mode: ENV,
};
  • 为两种环境分别写一个配置文件,公用的部分可以通过webpack-merge来合并
{  ...  
    "scripts": {    
        "dev": " webpack-dev-server --config=webpack.development.config.js",    
        "build": " webpack --config=webpack.production.config.js"  
    },
}
  • 通过webpack中可以通过DefinePlugin插件将环境信息注入到js运行环境:
// webpack.config.js
plugins: [        
     new webpack.DefinePlugin({            
         ENV: JSON.stringify('production'),        
     })    
 ]
 
 // app.js
 document.write(ENV);

14、tree-shaking

tree-shaking能使webpack在打包过程中,将一些没引用到的多余包体剔除,但有两点需要注意,一是tree-shaking只在es6 moudle下生效(依靠es6 moudle静态引用实现),意味着commonjs的模块管理是不可行的;二是tree-shaking只是做多余包体的标记工作,实际剔除代码还是需要借助压缩插件如terser-web-pack-plugin,但在webpack4只需要将mode设置为production即可。

针对上面第一点,需要注意在babel-loader中设置module=false,禁止bable将模块转为commonjs。

15、模块热替换(hot module replace)

监听文件变化,不同于live reload(刷新页面,全量更新),热替换是增量修改,不刷新网页,只更改局部。

开启HMR:

const webpack = require('webpack');

module.exports = {  // ...  
    plugins: [    
        new webpack.HotModuleReplacementPlugin()  
    ],  
    devServer: {    hot: true,  },
};

HMR原理:

  • 首先浏览器端会有HMR runtime,webpack会起一个webpack-dev-server(WDS),两者依靠websocket通信;
  • 当WDS监听到文件变化,会向客户端推送更新事件,并带上构建的hash。客户端根据这个hash和之前资源的对比,判断是否需要更新;
  • 当需要更新,客户端就会向WDS请求更改的资源列表。WDS会返回需要构建的chunk name和资源版本hash。客户端再根据这些信息向WDS请求增量更新的资源;
  • 拿到更新的资源,HMR runtime就会开始决定哪些地方需要替换。webpack会暴露一个module.hot接口,用于给使用者知道热替换的时机,module.accept则是设置需要替换的模块。一般loader都会设置相应的模块热替换的补丁操作,对替换模块进行操作。如果runtime对某个模块没有检测到对HMR的update handler,则会将替换操作冒泡到父级模块,以此类推。(参考:webpack中文文档)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容

  • 写在开头 先说说为什么要写这篇文章, 最初的原因是组里的小朋友们看了webpack文档后, 表情都是这样的: (摘...
    Lefter阅读 5,273评论 4 31
  • Webpack学习总结 此文只是自己学习webpack的一些总结,方便自己查阅,阅读不变,非常抱歉!! 下载安装:...
    Lxs_597阅读 932评论 0 0
  • 在现在的前端开发中,前后端分离、模块化开发、版本控制、文件合并与压缩、mock数据等等一些原本后端的思想开始...
    Charlot阅读 5,428评论 1 32
  • 写在前面 第一次接触webpack,是在一个react项目参与中,刚开始使用的时候,甚至不知道是做什么用的,只看到...
    默默先生Alec阅读 640评论 0 3
  • 《好的爱情:陈果的爱情哲学课》:每个人都向往爱情,但是却少有人懂得如何真正去爱。多少情侣明明爱得死去活来,结果却还...
    simple涯阅读 215评论 0 5