【我只看到了第三层】重新聊聊webpack

3,037 阅读17分钟

前言

针对技术而言,越学越感觉不会的太多。可能是没到达一定的境界。在网上冲浪学习的时候,看到有些大佬文笔从容,思路清晰。对某个技术点的深度刨析。真产生了一种发自内心的尊重和佩服和尊重和佩服。有些时候,一段文字就能点醒你,原来是这样!!!有时候感觉到这东西被嚼碎了往你嘴里塞你都嚼不动的感觉(无奈)。本人对于webpack的学习也有一段时间了。想借这篇博文梳理webpack相关的知识体系。也是对于自己学习的一段总结。

webpack

# 像解析Tapable事件流和实现,分析webpack-cli源码,解析构建流程,实现xx带有难度的loader或plugin均都不在本文章之列(都不会)

image-20200910133615897

  • webpack 只是一个模块打包器

::通过webpack将零散的模块代码打包到同一个JavaScript文件中,对于代码中有环境兼容的问题,通过模块加载器(loader)将其转化,webpack还可以进行代码拆分。对应用中的代码根据需要打包(按需打包),实现了渐进式加载。这样就解决了文件太碎或太大的问题。webpack会以模块化的方式去载入任意类型的文件(import './index.css')。

webpack解决了前端整体的模块化,而不是单指JavaScript的模块化。所有的资源都可以看做一个模块。

import j1 from './index.js'
import c1 from './index.css'
import h1 from './index.html'
import p1 from './index.png'
....

webpack的打包流程

  1. webpack-cli 会解析cli参数,与你配置的参数进行合并,获取到最终的配置项。
  2. 通过配置项创建Compiler对象,添加构建过程需要的方法,完成注册插件等功能,这个对象将贯穿整个构建过程。
  3. 通过AST引擎库(ACORN) + entry所对应的入口文件找到所有依赖,生成ast抽象语法树,在这里也可以看作依赖关系图。
  4. 解析ast语法树,对每个模块进行根据配置项进行不同的loader处理。
  5. 构建完所有模块进行写入,写入到output对应的输出目录中。

下面来看一下

webpack是如何知道这是一个模块,我要对他进行打包的呢?

webpack触发打包

webpack并不会对入口文件中所有的数据进行无脑打包,而是需要触发方式。

  • 众所周知,ESModule 的 import 语法会被webpack当作一个模块进行打包。那么还有啥方式?

    • javaScript代码:Commonjs规范的require语法、AMD的require语法

    • 非javaScript代码:众所周知,非javaScript代码是需要通过loader处理的

      • css: 在loader处理css的过程中,像样式代码中的@import/url 也会触发打包机制。

        处理css文件时,我们使用css-loader,css-loader如果发现了@import语法或者url语法会将其引入的路径作为一个新的模块进行打包。比如:background-image: url(background.png); webpack发现了.png文件,会将该文件交给url-loader进行处理。比如@import url(reset.css); webpack发现了.css文件,会出发资源加载,然后将文件交给css-loader处理。

  • html: 在loader处理html的过程中,像src也会触发打包机制。如果需要更多的触发机制,需要看loader有没有暴露接口,如果提供,需要自己配置。

webpack 与 gulp对比

只讨论一点:关于对import/require语法的支持。

浏览器是不支持import/require语法的,那么这些语法是怎么被转换了呢?因为webpack提供了基础代码"替换了"import这些语法。 而gulp就是一个纯粹的自动化构建工具流。没有提供这些基础代码让用户轻松的使用模块化语法。

下面来看一下

webpack的引导代码

基础代码或引导代码webpack是如何实现的? 或者说webpack是对import/require等语法的实现?

ps: 以下是在mode: none的时候的打包方式。在mode: development的时候有所不同。

/******/ (function (modules) { // webpackBootstrap
    .....
})
    ([
/* 0 - 入口文件*/
/***/ (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            __webpack_require__.r(__webpack_exports__);
/* harmony import */ var _testA_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); // 也就是import testA from './testA.js'
/* harmony import */ var _testB_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2); // 也就是import testB from './testB.js'
            console.log(_testA_js__WEBPACK_IMPORTED_MODULE_0__["default"], _testB_js__WEBPACK_IMPORTED_MODULE_1__["default"]);
        }),
/* 1 第一个引入的模块testA*/
/***/ (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            __webpack_require__.r(__webpack_exports__);
            var t1 = 'hello1';
/* harmony default export */ __webpack_exports__["default"] = (t1);
        }),
/* 2 第二个引入的模块testB*/
/***/ (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            __webpack_require__.r(__webpack_exports__);
            var t2 = 'hello2';
/* harmony default export */ __webpack_exports__["default"] = (t2);
        })
    ]);

从上面可以看到webpack是通过__webpack_require__方法实现了 import x from './x.xx' 的语法。default的意思是默认导出。这个匿名函数的参数是一个数组,每一个模块都作为了这个数组中的成员。模块被解析成了一个个函数,从而产生了独立的作用域, 模块与模块之间不会产生变量冲突的问题。 在mode: development的时候,会有一些差异,但大体上是相同的。webpack打包过后提供的引导代码让模块与模块之间的关系清晰独立,而且更容易进行拆分和组合。

总结:

把所有的模块放到同一个文件中,提供基础代码让模块与模块之间的关系保持原有的关系。 将所有的模块都作为巨大匿名函数的数组参数中的成员,数组中的每个成员都是一个函数,也就是说webpack将每一个模块都作为一个函数。以维持模块的私有化。从第一个入口参数开始执行, 只会先执行下标为0的函数。 每一个模块都对应一个下标,webpack将模块与模块之间的关系在编译阶段就做好了处理。比如a.js import b.js。 b.js的下标为3, 那么就会在a.js这个函数中,webpack.require(3) , 这样编译好,webpack就是这样维护模块与模块之间的关系的。

loader的工作原理

  • webpack可以零配置,默认 是src/index.js --> dist/main.js

  • webpack会将遇到的所有模块都当作JavaScript去处理

    • 每个loader 都需要导出一个函数
      • 函数的返回结果会作为JavaScript代码(这些代码放到了每个模块所代表函数中作为函数体)去执行,所以要求这个函数返回的必须是一个标准的可执行的JavaScript代码。
    • 输入就是需要解析的内容
    • 输出就是处理后的结果
    1. 对于同一个资源可以依次使用多个loader
    2. 在模块加载的时候工作
    

plugin的工作原理

Loader 专注实现资源模块加载,去实现整体模块的打包

webpack 增强了webpack自动化能力

e g. plugin 可以清除打包的目录、可以拷贝一些资源文件、压缩输出的代码等等等的能力。

webpack的插件机制是由钩子机制实现的。类似于web中的事件,插件的工作过程中会有很多的环节,为了便于插件的扩展,webpack给每一个环节挂载钩子,这样插件的运行和开发就是在钩子中扩展能力。

plugin必须是一个函数或者是一个包含apply方法的对象。

实现plugin是通过在生命周期的钩子中关在函数实现扩展,达到插件的目的。

SoureMap

  • sourcemap解决了运行时代码和开发时代码不一致, 导致无法调试和错误信息无法定位的问题。

线上文件引入jquery.min.js, 如果需要需要调试jquery.js的话, 则需要在引入的jquery.min.js最后一行加上

//# sourceMappingURL=jquery-3.4.1.min.map

告诉此文件去寻找jquery-3.4.1.min.map。该文件记录了转换之后的代码和转换之前的代码的映射关系。

目前,webpack对sourcemap的风格支持 有 12种实现方式,每种方式的效率和效果各不相同。

  • eval模式

    eval('console.log(123)')  //VM122:1 运行在一个临时的虚拟机上。
    eval('console.log(123) //# sourceURL=./foo/bar.js') // ./foo/bar.js:1  运行环境就是./foo/bar.js
    // 通过sourceURL 就可以指定该运行环境名称或者说是路径。
    

    devtools: eval , 设置成eval模式,可以看到打包后的模块化代码,在打包后的bundle.js中,每个模块的执行都使用eval包裹执行,在eval的最后可以看到//# sourceURL=webpack:///./src/main.js? 这样的信息来标注文件路径。代表该模块只想源文件的路径。构建的速度最快,但是效果也很一般。只能定位到是哪个文件有问题。不会生成source map

    devtools: eval-source-map, 同样也是使用eval函数执行模块代码,除了可以帮我们定位到出现问题的文件,还可以确定行列信息。这种相比较eval,在生成的js内部去生成了以dataURL的形式引入生成的source map。这个sourcemap是经过babel转换的,而不是最原始的。

    devtools: cheap-eval-source-map , 在上一个eval-source-map的基础上加了一个cheap,就是廉价的,便宜的,用计算机术语来说就是阉割版。相比较上一个只能定位到行,但是不能定位到列的信息。但是构建的速度加快了。source map原理同上

    cheap-module-eval-source-map, 相比较cheap-eval-source-map, source map映射的真正的源代码,而不是编译后的。会产生 xxx.js.map文件。其他的痛cheap-eval-source-map

    inline-source-map, 和 source map 是效果是相同的,但是的.js.map文件是以dataURL的形式放到编译后文件的最后一行。用#sourceMappingURL引入。普通的就是生成 .map.js文件

    hidden-source-map, 构建过程中生成了source map文件, 在代码中没有使用注释的方式引入sourcemap,一般我们在开发第三方包的时候会使用这个sourcemap风格。

    nosources-source-map, 能看到错误出现的位置,但是看不到源代码,只提供错误出现的位置,但是不给你显示,这是在生产环境中防止暴露源代码的一种方案。

  • 选择最佳实践的sourcemap

    • 开发环境下:cheap-module-eval-source-map。

    • 生产环境下:none

    • 生产环境下: nosources-source-map

      • 这是对代码本身没多少信息的前提下,选择使用nosource-source-map能够定位错误,而且不会暴露源代码。

实际开发中关于路径问题

  • 在搭建脚手架的过程中,路径的问题很是头疼,一直也无法找到一个好的解决方案,主要还是对webpack中可配置路径的一些属性不够了解。比如publicPath,filename, html打包到的位置。

publicPath

webpack打包的模块会默认放到output的目录中。

  • publicPath是在运行时浏览器中所引用的资源的url将publicPath作为其前缀。
  • 默认值是空字符串"", 表示网站的根目录
  • publicPath的值 最后面有一个 / ,该 / 不能省略, 因为是路径拼接的形式
import icon from './icon.png'
// 如果没有publicPath,则icon为 './[hash].png'
// 如果有publicPath,则icon为  publicPath+ './[hahs].png'
  • webpack-dev-server 也会默认从 publicPath 为基准,使用它来决定在哪个目录下启用服务,来访问 webpack 输出的文件。
html页面中资源路径,被自动注入了output中filename的值。
output中filename的值。/backend/js/app.11c8b942e5dd4b3a51b5.js?2c61c09c3e5bff69e658, 自动注入到index.html中作为src/href,但是打包的位置是由path和filename一起决定的,也就是需要为filename的结果设置为服务器上作为此项目的根路径。因为只有根路径才可以找到。  
如果设置了publicPath,那么所有的静态资源的路径前面都会加上公共路径 publicPath的值。(不会产生目录,只是在引入的路径前加上publicPath的值)
// 设置publicPath: '/abc'
<script src=/abc/backend/js/app.57e28a966ab9582ff286.js?1dcc397cc95f5934ef81></script>

index.html的路径是由filename决定的
也就是说实际在磁盘上产生的路径是由path+filename决定的,但是 代码中的资源地址的路径是filename+publicPath决定的。

对于loader而言,有些loader有自己的publiPath,但是也可以通过设置filename来替换掉publicPath,一劳永逸
HMR

webpack中最强大的功能之一

  • 模块热替换

    应用运行过程中实时替换某个模块,应用的运行状态不受影响。热替换只将修改的模块实时替换至应用中,不必去完全刷新应用。

    • 热更新不仅可以热更新文本文件,而且还可以更新其他类型的文件。
  • 开启HMR

  • HMR已经集成到了webpack-dev-Serve了,使用 webpack-dev-Serve --hot 开启热更新。

    • ~~我们发现开启热更新之后,只有css是开箱即用的,而js改变还是会刷新页面。这是因为 不同的模块具有不同的逻辑,不同的逻辑又导致处理过程也是不同的。每个js文件都需要单独为这个js文件进行处理这个js文件的热更新(根据这个页面的逻辑)。 webpack没有办法提供一个通用的替换方案,但是vue-cli或者create-react-app脚手架是可以进行HMR的,因为他们是框架,他们每个文件都满足一定的规律,框架内部继承了HMR热模替换。
  • 那么怎样为每个js单独提供hmr呢? 使用 webpack提供的module.hot.accept(文件路径, () => { // 热替换逻辑 }) , 下面简单的展示下图片的HMR

if (module.hot) {
	// 图片的hmr
  module.hot.accept('./test.png', () => {
    img.src = background
    console.log(background)
  })
}
  • 问题: 如果我们使用hot: true 开启热模替换的话,如果替换失败,比如代码出现错误,那么就会回退到使用自动刷新的功能进行hmr,设置hotOnly: true // 只使用 HMR,不会 fallback 到 live reloading.

webpack.DefinePlugin

  • 这个是为项目注入全局变量。key: value 这个value必须是一个js代码片段。
new webpack.DefinePlugin({
  // 值要求的是一个代码片段
  API_BASE_URL: JSON.stringify('https://api.example.com')
})

tree-shaking

摇树 , 就是摇掉代码中未被引用的部分(dead-code)。mode:production。会自动开启摇树优化。

tree-shaking的实现:

optimization: {
  usedExports: true, // 标记枯树叶
  minimize: true	// 负责摇掉标记的枯树叶
}
  • 测试tree-shaking
let foo = () => {
    let x = 1
    if (false) {
        console.log('never readched')
    }
    let a = 3
    return a
}

let baz = () => {
    var x = 'baz is running'
    console.log(x)
    function unused () {
        return 5
    }
    return x
    let c = x + 3
    return c
}

module.exports = {  // commonjs模块规范导出
    baz
}
export { // ESM 模块规范导出
	baz 
} 
  • 打包后的结果
esModule 规范 打包后的模块
([
    function (e, n, r) {
        "use strict";
        r.r(n);
        var t;
        t = "baz is running",
            console.log(t),
            console.log("main.js running")
    }
]);

commonjs 规范 打包后的模块
([
    function (e, n, t) {
        (0, t(1).baz)(), console.log("main.js running")
    },
    function (e, n, t) {
        e.exports = {
            baz: function () {
                var e = "baz is running";
                return console.log(e),
                    e
            }
        }
    }
]);
  • 为什么需要esModule规范才能进行tree-shaking
1. ES6的模块引入是静态分析的,故而可以在编译时正确判断到底加载了什么代码。
2. 分析程序流,判断哪些变量未被使用、引用,进而删除此代码。

Scope Hoisting 作用域提升

合并模块函数

  • 开启了合并模块函数,不再是一个模块一个函数了,而是将所有的模块放到了一个函数中。
    • 尽可能的将所有模块合并输出到一个函数中。
    • 既提升了运行效率,又减少了输出代码的体积。
optimization: {
  usedExports: true, // 标记枯树叶
  minimize: true,	// 负责摇掉标记的枯树叶
  concatenateModules: true. // 开启 Scope Hoisting
}

tree-shaking 和 babel

  • 很多资料上说,如果我们使用了babel-loader就会导致tree-shaking失效。
  • Tree-shaking的前提是模块必须是ESModule标准。由webpack打包的代码必须使用ESM标准。webpack在打包之前,将模块交给配置交由不同的loader处理,将loader处理后的结果打包到一起。babel-loader处理js的过程中,可能处理掉esModule转换为commonjs规范。但是经过我们实验,即时使用了babel-loader处理过后,还是会进行摇树优化,是因为在新版本的babel-loader中已经帮我们自动关闭了esModule的转换。
loader: 'babel-loader',
options: {
  presets: [
    ['@babel/preset-env', { modules: 'commonjs' }] // 开启esModule的转换为commonjs, 这样会导致tree-shaking失效
  ]
}
  • 仍需探索~~~~~~~~~~~~~~~~~~~~~~

sideEffects

副作用

  • 允许我们通过配置的方式去标识我们的代码是否有副作用,从而为tree-shaking提供更大的压缩空间。

  • 副作用:模块执行时除了导出成员之外所做的事情。比如给xxx原型上添加了原型方法,别人引入的时候会污染到xxx原型。

  • 一般用于npm包标记是否有副作用

  • 如何使用:

    • 在webpack.config.js中 使用sideEffects开启这个功能
    • 在packjson中配置sideEffects: false 标识 这个项目中的代码没有任何副作用。
  • 使用之前请确保你的代码没有副作用。否则会误删掉副作用代码。

  • 标识某个文件有副作用

    sideEffects: [
        './src/extends.js',
        './src/global.js'
    ]
    

webpack代码分包/割

问题: 所有的javaScript代码都会被打包到一起

  1. bundle的体积过大,需要解决。
  2. 并不是每个模块在启动时都是必要的。

解决:分包、按需加载

  • 多入口打包
  • 动态导入、按需加载

多入口打包

  • 一般适用于多页应用程序
  • 一个页面对应一个打包入口
  • 公共部分单独提取
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  optimization: {
    splitChunks: {
      // 自动提取所有公共模块到单独 bundle
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index'] // 指定打到html文件中的bundle.js,不指定的话则将所有的打包后的文件都打到html文件中。
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}

动态导入、按需加载

  • 所有动态导入的模块会被自动分包

  • 通过代码的逻辑控制什么时候需要动态导入,或者说什么时候加载这个模块。

  • 魔法注释:可以对动态打包出来的文件进行重新命名, 而且可以对文件进行灵活组合。

  • import('路径').then(data => {}) data就是模块对象 import() 是ESModule规范的语法,而这个方法返回的是一个promise。

  • 按需加载webpack是如何打包的呢?这是webpack为import() 语法提供的引导代码。

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    var installedChunkData = installedChunks[chunkId];
    if(installedChunkData !== 0) { // 0 means "already installed".
        // a Promise means "currently loading".
        if(installedChunkData) {
            promises.push(installedChunkData[2]);
        } else {
            // setup Promise in chunk cache
            var promise = new Promise(function(resolve, reject) {
                installedChunkData = installedChunks[chunkId] = [resolve, reject];
            });
            promises.push(installedChunkData[2] = promise);

            // start chunk loading
            var script = document.createElement('script');  // 创建一个script标签
            var onScriptComplete;
            script.charset = 'utf-8';
            script.timeout = 120; // 设置script的超时时间
           
            script.src = jsonpScriptSrc(chunkId);  // 设置src
            // create error before stack unwound to get useful stacktrace later
           
            var timeout = setTimeout(function(){
                onScriptComplete({ type: 'timeout', target: script }); // 完成后的逻辑
            }, 120000);
            script.onerror = script.onload = onScriptComplete;
            document.head.appendChild(script);  // 插入到页面上
        }
    }
    return Promise.all(promises);  // 返回一个promise
};

可以看到,动态引入就是创建script,然后得到到script标签的src,将创建好的script标签插入到head里面。

  • 实现一个hash路由的按需加载
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)

输出文件名hash

  • 生产环境下,文件名使用hash,对客户端而言,全新的文件名,就是全新的请求,就不会有缓存的问题。 hash是在output/plugin中的filename配置
  • hash
    • 整个项目的级别的,只要项目中任何一个地方发生改动,那么只要文件名配置了[hash], 那么该hash值就会发生变化,
  • chunkhash
    • chunk级别的,同一路(同一个chunk,比如魔法注释合并的chunk文件)的文件hash值相同,而同一路中的文件发生了变化,这路的文件的hash值都会变化。
    • 比如a.js引入了a.css, a.js和a.css就是一路的。
  • contenthash
    • 文件级别的hash,根据文件生成的hash,文件发生修改,hash就会改变。

webpack是大前端发展到现在不可否认的居功至伟的功臣,现在框架开发一般情况都会使用高度开箱即用的脚手架工具,但是对于webpack的了解也是很必要的。理解我们的程序是如何一步步的从一只满身鸡毛的鸡变成一只香喷喷的奥尔良口味的乾坤烤鸡(drf烤鸡名)