耽误你十分钟!🎃你可能用得上这些webpack5新特性

2,303 阅读15分钟

前言

18webpack4发布后,经历了无数个小版本的洗礼,时隔两年在201010日终于迎来了5.0版本的发布。借用官网的话来说,人们不喜欢带有突破性的重大变化,所以一直积累了很多困难点,堆积起来不得不做突破性的改动。当然它的生态也要随之更新,所以到目前为止,在企业中4.x版本依旧是主流,不过随着时间的推移,大部分都会迁移至5.x版本。

对于一位技术狂热者早就已经从各种渠道学习实践了5.x的新特性,不过有些比较保守的同学对于新技术或新版本是十分抗拒的。无论怎样,webpack还是构建一哥(vite yes),无论是面试或是工作中实践迟早会用得到的。

本文总结了官网5.x版本的文档,发现大概可以分为两类,一方面是webpack内置优化,跟我们日常使用没有太大关系,了解即可,一方面是一些比较重要的新特性,比如模块联邦等,这些会在本文中重点剖析,最后还会对打包出来的文件进行源码级分析,废话说到这,进入正文~

正文

代码仓库:github.com/my2061/webp…

开箱即用

内置清除输出目录

4.x版本时我们经常用的一个插件就是clean-webpack-plugin用来每次打包的时候清空dist目录。在5.x版本中,我们只需要一个配置项即可开启此功能。在package.json中添加scripts脚本build: webpack,接着配置webpack.config.js。直接运行npm run build就可以看到效果了。

//webpack.config.js
module.exports = {
    mode: "production",
    output: {
        filename: "[name].[hash:5].js",
        clean: true,  //开启每次打包自动清除输出目录
    }
}

渲染原理.gif

更优雅的处理资源模块

4.x版本时我们处理资源文件通常要装很多个loader来处理不同的文件。在5.x版本中webpack为我们内置了处理资源的模块,我们只需要按照它的写法就可以达到跟之前一样的效果不需要额外安装任何加载器。在根目录下新建assets,里面存放四个文件,分别是gifpngjpgtxt。我们想针对这四个文件做不同处理,我们只需要配置output.assetModuleFilenamemodule.rules就能达到想要的效果。

//index.js
import gif from "../assets/123.gif";
import png from "../assets/456.png";
import txt from "../assets/789.txt";
import jpg from "../assets/91011.jpg";

console.log("gif", gif);	//期望输出结果根据文件大小决定是否是路径还是base64
console.log("png", png);	//期望输出结果是路径
console.log("txt", txt);	//期望输出结果是原始内容
console.log("jpg", jpg);	//期望输出结果是base64
//webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
    mode: "development",
    output: {
        filename: "[name].[hash:5].js",
        clean: true,
        assetModuleFilename: "assets/[hash:6][ext]",	//用来配置资源模块输出的位置以及文件名
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./index.html"
        }),
    ],
    module: {
        rules: [
 
            {
                test: /\.png/,          //相当于4.x版本使用的file-loader
                type: "asset/resource", //将png图片使用文件的方式打包
            },
            {
                test: /\.txt/,
                type: "asset/source",   //将文件内容原封不动的放到asset中
            },
            {
                test: /\.jpg/,          //相当于4.x版本的url-loader
                type: "asset/inline",   //jpg文件都处理成base64方式存储
            },
            {
                test: /\.gif/,
                type: "asset",                  
                generator: {
                    filename: "gif/[hash:6][ext]",   //如果处理出来的是文件存放位置命名规则是什么,会覆盖上面assetModuleFilename配置项
                },
                parser: {
                    dataUrlCondition: {
                        maxSize: 4 * 1024,  //如果文件尺寸小于4kb那么使用base64的方式,大于使用文件
                    }
                }
            },
        ]
    }
}

安装webpack-dev-server,使用webpack serve启动开发服务器,来看一下控制台的输出是不是我们想要的。

截屏2022-07-22 上午11.07.16.png

开发环境中可以看到是想要的结果,都按照预期的处理方式处理了不同的文件。注意:rules中的每一项的type都要按照固定格式书写来处理不同的模块。具体详细规则点击查阅 我们再来看一下生产环境照样也能按照预期处理。将mode改为production然后进行打包。可以发现pnggif被单独打包成了文件。txtjpg被处理成了base64打包进了main.js中。

渲染原理.gif

打包体积优化

5.x版本内置的优化做的非常多,对模块的合并、tree shaking、作用域的提升更加智能。举个例子,现有两个不同版本项目一个4.x一个5.x。两个项目中都有两个模块:入口模块index.jshandler.js。其中index依赖handler中的某个方法。所以他们会被构建为一个chunk打包到一个文件中。

//index.js
import { handler1 } from "./handler"

const init = () => {
    const result = handler1();
    console.log("init" + result);
}

init();
//handler.js
export const handler1 = () => {
    return handler2();
}

export const handler2 = () => {
    return "handler2";
}

其实最终逻辑只是为了打印init + handler2。只是绕了好几个弯,来看一下两个版本的项目在生产环境下同样的代码打包出来的结果有啥不一样。

4.x版本打包结果

111.gif

5.x版本打包结果

5678.png

斯国一!可以看到4.x版本的打包结果里有非常多的代码。5.x版本竟然可以将优化做到如此极致。它发现我们的目的就是打印一句话,直接帮我们运算好了最终的结果。这里有个小细节,为什么可以如此智能?如果有深入了解过tree shaking的同学应该知道,使用按需导出按需导入可以更好的让tree shaking发挥作用,如果我这里改用默认导出默认导入的话就webpack就没有那么智能了,所以在书写模块时,尽量使用按需导出按需导入

//index.js
import handler from "./handler"

const init = () => {
    const result = handler.handler1();
    console.log("init" + result);
}

init();
//handler.js
const obj = {
    handler1(){
        return obj.handler2();
    },
    handler2(){
        return "handler2";
    }
}
export default obj;

1.png

打包缓存

4.x版本中需要使用cache-loader来对打包结果进行缓存。在5.x版本中,无需再次安装cache-loader,如果没有做任何配置,默认就开启了打包缓存,不过是缓存到内存(memory)中,内存的空间多么宝贵啊,有些时候内存可能还不够用,我们就可以对cache配置,将缓存结果缓存到硬盘(filesystem)中。同时也可以指定缓存文件被保存的位置。

// webpack.config.js
const path = require("path");

module.exports = {
    mode: "production",
    cache: {
        type: "filesystem", //缓存到内存还是硬盘中 默认为内存memory
        cacheDirectory: path.resolve(__dirname, '.temp_cache'), //缓存保存的位置
    },
}

渲染原理.gif

top-level-await

此配置的意思是方便你在全局中可以直接使用await,可以不在async函数中直接使用await关键字(只是写起来方便,在最终的打包结果中肯定还是包了一层async的)。我们只需要开启experiments.topLevelAwait配置即可。注意:截止到目前位置,此功能仍还是一个实验性功能,并没有成为正式标准,所以请慎用,当然还有一些其他的实验性功能,点击查看详情

module.export = {
    experiments: {
        topLevelAwait: true,
    },
}
//index.js
await new Promise((resolve, reject)=>{
    setTimeout(()=>{
        resolve(1);
    }, 1000)
}).then(data=>{
    console.log(data);
})

模块联邦

什么是微前端

随着时间的推移,各种版本的迭代以及人员的更换,业务的代码很容易变成💩山,为了解决这一问题,微前端横空出世,它的简要目的就是为了将一个大型项目拆分成一个个的小项目,每个项目都有专门技术,运营等负责,技术栈甚至都可以不同,核心理念是基于一个底座支持多个项目运行。这两年微前端的框架也层出不穷,如qiankun等。但也伴随着新问题的诞生,如多个项目之间重复的代码第三方库如何进行共享,这里要讲的就是webpack5的一个重大新特性模块联邦,看看它是如何来解决这些问题的。

首先,我们创建两个项目来模拟多个不同的业务组,一个是saas业务的项目,一个是paas业务的项目。两个项目都有自己的逻辑代码index,同时互相依赖对方的某个模块,两个项目都引用了第三方库jquery

渲染原理.gif

如图所示,我们如何通过模块联邦来实现这样的功能呢?注意:我们如果想使用对方暴露出的模块那么一定是通过网络传输请求对方的服务器来拿到想要的文件,所以一定要将想暴露出的模块想导入外界模块的模块放在一个异步环境中执行。比如我们可以使用import()来进行对模块的异步加载,让模块处于异步的环境中。

如何暴露和导入

暴露模块

webpack.config.js中增加模块联邦配置。其实就是一个plugins插件,它的位置在webpack/lib/container/ModuleFederationPlugin,我们只需要引入即可。

// paas项目的 webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
    mode: "development",
    plugins: [
        new ModuleFederationPlugin({
            name: "paas",
            filename: "paas-file-entry.js",
            exposes: {
                "./paasComp": "./src/paasComp.js"
            }
        })
    ],
}
  • name表示的是当前模块联邦的名称,它会生成一个全局变量,必须唯一,通过该变量可以获取当前模块联邦所有暴露的模块。比如当前微前端的项目有十个项目,这十个项目的模块联邦名字必须是唯一的。一般来说跟项目名称相统一。

  • filename表示通过模块联邦暴露出去文件的文件名。有了namefilename,到时候需要导入的项目远程请求的文件名字就是name/filename注意:不要跟下面设置具体某个模块的路径搞混了,一个是请求所有暴露出去文件的文件名,一个是具体引入哪个模块的路径。

  • exposes表示到底需要暴露出去哪些模块,里面的每一项的key代表的是相对于模块联邦的路径,决定了该模块到时候的访问路径是name/keyvalue代表的是具体文件的路径,这里有点绕,如果按照我们上面写的配置项的话,到时候引入路径就是paas/paasComp就可以找到paasComp.js文件了。注意:同样的,不要搞混了,这里跟filename无关。

引入模块

// saas项目的 webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
    mode: "development",
    plugins: [
        new ModuleFederationPlugin({
            remotes: {
                paas: "paas@http://localhost:8080/paas-file-entry.js"
            }
        })
    ],
}
  • remotes表示引入其他项目导出的模块联邦,一个属性代表引入一个其他项目的模块联邦,key值代表名称,可修改,到时候引入就是import paas from key/前面设置暴露出来模块的名称。属性值代表的意思是暴露出来文件的name@服务器地址/暴露出来的filename

WechatIMG65.jpeg

处理共享模块

WechatIMG68.jpeg

WechatIMG70.jpeg

如果saas业务和paas业务都使用了第三方模块jquery,那么两个项目都会引入一次,如何通过模块联邦处理共享模块呢?我们只需要在原有的基础上配置shared即可。配置过后,两个项目谁先启动,另一个就会引入对方服务器地址的jquery了。

//给两个项目都配置上shared
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            shared: {
                jquery: {
                     singleton: true,//整个微前端项目全局唯一
                }
            }
        })
    ],
}

【精】打包结果原理分析

到这里,webpack5的一些新特性就讲解完了,其实还有很多其他的新特性,但本文主要总结了一些可能用的上的,如果想了解更多请自行翻阅官网阅读,最后我们来分析一下经过webpack打包后的文件它的内部实现原理到底是怎样的。本文解析使用的是4.x版本的打包结果,我观察了5.x的打包结果,总体思想上大同小异。

现有两个文件,一个为入口文件src/index.js,一个为src/handler.jsindex依赖了handler。我们来看一下经过webpack打包出来的文件到底是啥样的。

//index.js
var handler = require("./handler");
console.log(handler);
//handler.js
console.log("handler module");
module.export = "handler";

渲染原理.gif

乍一看我们可能两眼一抹黑,一大堆东西理解起来非常困难,那么尝试换个角度,我们能不能先自己手写实现一下呢?如果让你设计一个webpack你会怎么处理多个文件最后合并到一个文件中呢。接下来,我们一步一步按照自己的理解手写出来,最后再对比一下正宗的打包结果看看。

手写实现打包结果

第一步:我们需要考虑的是如何将indexhandler合并到一个文件中,并且不能污染全局变量。我们都知道在最终的打包结果中,不存在任何CommonJs的语法和ES Module的语法。webpack打包过程是在node环境中运行的,所以它遵循的是CommonJs的规范。

CommonJS规范中,每一个模块运行都是将其放到一个函数环境中运行,所以我们运行某个模块实际上是在运行一个函数。我们首先可以将每个模块的代码放入到一个函数中运行。

//该对象保存了所有的模块和对应的代码
var modules = {
    "./src/index.js": function (module, exports, require) {
        var handler = require("./src/handler.js");
        console.log(handler);
    },
    "./src/handler.js": function (module, exports, require) {
        console.log("handler module");
        module.export = "handler";
    }
}

这不还是定义了一个modules污染全局变量了吗?伞兵作者?别着急,还没改造呢。我们来看一下这一步做了哪些操作,我们将模块的路径作为key,将对应的代码放到一个函数中,这样就不会污染全局变量了。但是模块里会用到moduleexportsrequire等关键字,这些都是CommonJs规范里的东西,在最终的打包文件中肯定没办法直接使用,所以我们可以通过函数的参数传递进去,那么究竟是谁传?先别着急下面都会讲到。

第二步:打包过程肯定要先运行入口文件,然后根据入口文件来寻找依赖。我们可以通过一个函数来处理modules对象,在函数中执行入口文件并得到模块导出结果,如何执行入口文件?还要实现一个require函数,此函数的功能就是根据模块id运行一个模块。

(function (modules) {
    //模块的缓存
    var moduleCache = {};  

    /**
     * 给我一个模块id,运行该模块对象的函数,并得到模块的导出结果
     * @param {string} moduleId 模块的路径
     */
    function __webpack_require__(moduleId){
        
        // 如果有缓存直接返回缓存结果
        if(moduleCache[moduleId]) return moduleCache[moduleId];

        //模块id对应的函数
        var handler = modules[moduleId];  

        var module = {
            exports: {}
        };

        //运行模块
        handler(module, module.exports, __webpack_require__);   

        // 运行完模块后缓存结果
        moduleCache[moduleId] = module.exports;

        //运行完模块之后,module.exports可能有值了,所以直接导出
        return module.exports;  
    }

    //首次进来要运行入口模块,得到模块返回结果去做一些事情
    return __webpack_require__("./src/index.js");  
})(
    {
        "./src/index.js": function (module, exports, __webpack_require__) {
            var handler = __webpack_require__("./src/handler.js");
            console.log(handler);
        },
        "./src/handler.js": function (module, exports, __webpack_require__) {
            console.log("handler module");
            module.export = "handler";
        }
    }
)

我们定义了一个立即执行函数,然后将前面的modules通过参数的形式直接传给匿名函数,这样就达到了不污染任何全局变量的目的。然后通过require运行了入口文件,为了防止跟node环境下的require重名,所以改用__webpack_require__命名,首先检查是否有缓存,如果有缓存则直接返回上一次的缓存结果。如果没有缓存则先找到moduleId对应的函数。

然后运行函数,将定义的module对象当参数传入,运行完后将module.exports当作缓存结果保存。这就是为什么在node中你如果使用了exports导出,但最后如果将modules.exports改变了引用,你的导出结果都将失效的原因。

此时我们可以再回过头来看下webpack的打包结果跟我们手写的打包结果有什么不同。先将其一些注释和适配其他模块化的代码删掉。

WechatIMG72.png

从图中可以看到实现的思路是一摸一样的,都是使用匿名函数将模块作为参数传递进去。然后实现自己的__webpack_require__函数,唯一不同的是它的缓存是用了三个key来表示,i代表模块的idl代表模块是否加载过。exports就跟我们写的作用一样。唯一不同的就是它将模块的代码放入到eval中执行。

为什么要使用eval?因为我们最终要运行的代码是打包出来的文件,如果我们写了一段错误的代码打包到最终结果中,那么是不方便我们调试的。比如调用了handler上不存在的一个方法。

渲染原理.gif

如果报错的话,点错误信息进去是打包后的文件,不利于我们调试,但是用了eval可以将代码放到单独的一个环境中执行,不会有其他代码进行干扰。同样的,还可以设置报错的文件位置sourceURL更加方便的利益我们调试。

最后

本文主要总结了一些可能用的上webpack5新特性,如想了解更多请自行查阅官方文档。对于打包结果分析希望大家可以跟着手写一遍加深印象。最后,谢谢大家的观看,对本文内容有异议或交流欢迎评论~

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿