liangbus / blogging

Blog to go
10 stars 0 forks source link

简单了解 webpack 打包原理 #36

Open liangbus opened 4 years ago

liangbus commented 4 years ago

每次提起 webpack,都会被它一大堆配置搞得头都大了,本身就不算很熟,如果面试被问起来,就更难了。

相信 webpack 大家都有使用过,但是多少人知道它的原理呢?有没有去看过它打包后的代码是怎么样的?本文讨论的是 webpack 4,也会忽略掉 webpack 的一些基本的配置(当作你是会用的啦~),这次就通过打包一些简单的文件,看看 webpack 的打包过程是怎么样的。

下面是我们当前的目录结构(tree 生成)

├── dist
├── package.json
├── src
│   ├── index.js
├── webpack.config.js
└── yarn.lock

我在 src 新建一个叫 index.js 的文件,并且将其作为 webpack 的打包入口,index.js 我只写了一行代码,主要是看

// webpack.config.js
module.exports = {
  // mode: 'production', // 默认是 production
  mode: 'development', // 这里要注意改成 development,否则打包出来的是经压缩后的代码
  entry: {
    bundle: './src/index.js'
  },
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: '[name].js'
  }
}
// index.js

console.log('hahaha!!!')

此时,我们来 webpack 一下,然后我们的 dist 就会有我们的 bundle.js 文件了( bundle 这个名字是来自 entry 的 key,因为我们写了 [name] )

大概会有 100 行代码,这里我省去一些暂时不需要了解的代码(一些对 __webpack_require__ 函数拓展的方法),把一些关键的代码贴出来

(function(modules) { // webpackBootstrap
    // The module cache
    var installedModules = {};

    // The require function
    function __webpack_require__(moduleId) {

        // Check if module is in cache
        if(installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }
        // Create a new module (and put it into the cache)
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };

        // Execute the module function
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

        // Flag the module as loaded
        module.l = true;

        // Return the exports of the module
        return module.exports;
    }

    // Load entry module and return exports
    return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
 ({

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("console.log('hahaha!!!')\n\n\n//# sourceURL=webpack:///./src/index.js?");
  })

  });

删完之后,大概就剩下40多行了。这时看就很方便了

先看整体,整个 js 文件是一个自执行函数,Like this

((options) => { /**do something **/ } )({ foo: 'foo' })

再来看下入参,入参是一个对象,里面的 key 是文件路径,就是我们在 webpack.config.js 里 entry 配置的文件入口,上面我只配置了一个 index.js 的入口,所以这里入参对象只有一项,而对应的 value 是一个函数对象,函数体是执行一个 eval 函数,然后其执行的内容,就是这个文件的内容!

然后再来看下自执行函数里面是做了什么

一个对象,表面上去像是缓存已经加载过的 module(事实也是如此)

// The module cache
 var installedModules = {};

紧接着是,可以说 webpack 最关键的一个的一个函数 __webpack_require__ ,它正是我们平常 require 文件的关键

// The require function
 function __webpack_require__(moduleId) {

    // Check if module is in cache
    if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    // Flag the module as loaded
    module.l = true;

    // Return the exports of the module
    return module.exports;
 }

函数体做的事情不复杂,首先是检查之前外部声明的 installedModules 对象(没错吧,就是用来缓存模块的,一个文件一个模块,也就是一个 k-v 对),假如存在,直接读取其 export 出来的值。

否则在 installedModules 以其当前模块的 id (也就是文件的路径)创建一个值。

接着,会将该文件 export 出的内容通过 call 方法进行调用,调用完毕设置其标志位,并将其 export 值返回,假如是当前文件中有 require, __webpack_require__ 将会递归地调用,Like this,index.js 及自执行函数参数部分如下

var search = require('./search.js')
console.log('hahaha!!!')
console.log(search)
// bundle.js
({
 "./src/index.js":
 (function(module, exports, __webpack_require__) {

eval("\nvar search = __webpack_require__(/*! ./search.js */ \"./src/search.js\")\n\n\nconsole.log('hahaha!!!')\n\nconsole.log(search)\n\n//# sourceURL=webpack:///./src/index.js?");

 }),

 "./src/search.js":
 (function(module, exports) {
eval("module.exports = {\n  moduleName: 'searchModule',\n  foo: function() {\n    console.log('I am foo!!!')\n  }\n}\n\n//# sourceURL=webpack:///./src/search.js?");
 })

require 被编译成 __webpack_require__ 方法,同时匿名函数多了个参数,See? 我们的文件就是这样递归地进行调用,然后被打包成一个文件。

除了 require 方法,对于 import,webpack 又是怎么打包的呢? 我来新建一个 util 文件,并引用其中的一个方法

import { isObject } from './utils/util'

var arr = [1,2,3,4]
console.log('isObject -> ', isObject(arr))

这时我们再打包一下,会发现我们原本 "./src/index.js" 这个模块的 eval 内容有点不一样了,最顶处多了这些代码

__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _utils_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/util */ "./src/utils/util.js");

这里使用了 __webpack_require__.r

        // define __esModule on exports
    __webpack_require__.r = function(exports) {
        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
        }
        Object.defineProperty(exports, '__esModule', { value: true });
    };

检查当前环境是否支持 Symbol,若支持则将其[[Class]]值设为 Module(这个值可以通过Object.prototype.toString 方法获得),然后设置一个 __esModule 属性,值为 true.

这个函数看起来就是给 export 增加一个标记

再来看下我们的 utils 文件打包后的样子

__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isObject", function() { return isObject; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "generateRandomArray", function() { return generateRandomArray; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "shuffleArray", function() { return shuffleArray; });
// 检查对象类型
function isObject(target) {
    const t = typeof target
  return target !== null && (t === 'object' || t === 'function')
}

// 随机不重复数组(排序后是连续的)
function generateRandomArray (len){
  let i = 0;
  const arr = []
  while(i++ < len) {
    arr.push(i)
  }
  return arr.sort(() => 0.5 - Math.random())
}

/**
 * 打乱数组顺序
  * @param arr
  */
function shuffleArray(arr) {
    let i = arr.length;
    while(i) {
      let r = Math.floor(Math.random() * i);
      [arr[i - 1], arr[r]] = [arr[r], arr[i - 1]]
      i--
  }
  return arr
}

//# sourceURL=webpack:///./src/utils/util.js?

这里主要是用到了 __webpack_require__.d 这个函数

         // define getter function for harmony exports
    __webpack_require__.d = function(exports, name, getter) {
        if(!__webpack_require__.o(exports, name)) {
            Object.defineProperty(exports, name, { enumerable: true, get: getter });
        }
    };

可以看到,这里主要是将 utils 定义的方法定义到 exports 里面,getter 就是返回相关函数的引用,然后引用的页面就读到这些 export 出来的引用了,这个 export 就是开始定义的

// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
    i: moduleId,
    l: false,
    exports: {}
 };

大致的流程就是这样子,是不是还挺容易理解的?

延伸

有时候我们也可以在 JS 引入 css,由于 webpack 只认识 js 文件,如果需要打包 css 文件,那就需要额外加入能让webpack 认识的 loader,css-loader(如果是 sass, less 那些,还需要特定的 loader),尝试一下

({

 "./node_modules/css-loader/dist/runtime/api.js":
 (function(module, exports, __webpack_require__) { /****/}),

 "./src/css/index.css":
 (function(module, exports, __webpack_require__) {

eval("// Imports\nvar ___CSS_LOADER_API_IMPORT___ = __webpack_require__(/*! ../../node_modules/css-loader/dist/runtime/api.js */ \"./node_modules/css-loader/dist/runtime/api.js\");\nexports = ___CSS_LOADER_API_IMPORT___(false);\n// Module\nexports.push([module.i, \".box { width: 100px; height: 100px; background-color: aqua;}\", \"\"]);\n// Exports\nmodule.exports = exports;\n\n\n//# sourceURL=webpack:///./src/css/index.css?");
 }),

 "./src/index.js":
 (function(module, exports, __webpack_require__) {
eval("\nvar search = __webpack_require__(/*! ./search.js */ \"./src/search.js\")\n__webpack_require__(/*! ./css/index.css */ \"./src/css/index.css\")\n\nconsole.log('hahaha!!!')\n\nlet a = 'a'\nlet b = 'b'\nlet c = 'c'\n\nconsole.log(a + b + c)\n\nlet n1 = 2\nlet n2 = 4\nlet n3 = 6\n\nconsole.log(n1+n2+n3)\n\n//# sourceURL=webpack:///./src/index.js?");
 }),

 "./src/search.js":
 (function(module, exports) {
eval("module.exports = {\n  moduleName: 'searchModule',\n  foo: function() {\n    console.log('I am foo!!!')\n  }\n}\n\n//# sourceURL=webpack:///./src/search.js?");
 })

可以看到,入参多了两个 k-v 对,其中一个是我们之前理解的以我们的文件路径为 key 的值,另外一个则是一个 api

./node_modules/css-loader/dist/runtime/api.js

看到 css 文件执行内容如下

// Imports
var ___CSS_LOADER_API_IMPORT___ = __webpack_require__(
  /*! ../../node_modules/css-loader/dist/runtime/api.js */
  "./node_modules/css-loader/dist/runtime/api.js"
  );
exports = ___CSS_LOADER_API_IMPORT___(false);
// Module
exports.push([module.i, ".box { width: 100px; height: 100px; background-color: aqua;}", ""]);
// Exports
module.exports = exports;
//# sourceURL=webpack:///./src/css/index.css?

通过上面可以看到,我们会先通过加载一个 loader ,然后再通过 loader 去获取我们的 css 内容,具体实现可以查看 css-loader/dist/runtime/api.js 的源码,这里就不再展开

要注意的是,这里 css-loader 只是获取到了样式内容,并没有在文档上应用里面的样式,如果需要把样式应用到文档中,可以使用 style-loader,这个 loader 会把样式内容以 style 标签插入到 head 标签末尾,下面是 style-loader 的一些关键代码

// node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
// 避免贴过多代码,以下有删减
function insertStyleElement(options) {
  var style = document.createElement('style');

  if (typeof options.insert === 'function') {
    options.insert(style);
  } else {
    var target = getTarget(options.insert || 'head');

    if (!target) {
      throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");
    }

    target.appendChild(style);
  }

  return style;
}
function applyToTag(style, options, obj) {
  var css = obj.css;
  var media = obj.media;

  /* istanbul ignore if  */

  if (style.styleSheet) {
    style.styleSheet.cssText = css;
  } else {
    while (style.firstChild) {
      style.removeChild(style.firstChild);
    }

    style.appendChild(document.createTextNode(css));
  }
}

webpack 中,loader 的加载顺序是从右到左

所以正确使用 style-loader 和 css-loader 的姿势是

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

这里会先使用 css-loader 处理,然后将处理完的结果递交给 style-loader 处理,以此类推

优化

webpack 有一些自带的优化项,比如

// src/index.js
var a = 1
var b = 2
var c = 3

console.log(a+b+c)

打包之后,生成的文件如下(我们的代码部分)

console.log(6)

在编译阶段 webpack 会自动把一些常量计算结果,以结果的形式输出

DllPlugin 和 DllReferencePlugin

这两个是 webpack 提供的插件,可以用来拆分业务代码,提取一些外部库,使其不会打包进我们的业务代码中,利用 CDN 缓存起来,同时也降低业务代码包的体积,加快构建速度 使用:

需新建一个 webpack 配置文件,专门用于生成抽取出来的独立文件,以及映射表 manifest.json

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

module.exports = {
  mode: 'production',
  // mode: 'development',
  entry: {
    react: ['react', 'react-dom']
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].dll.js',
    libraryTarget: 'var', // (默认值)当 library 加载完成,入口起点的返回值将分配给一个变量:
    library: '_dll_[name]_[hash]' // libraryTarget 输出后赋值的变量名
  },
  plugins:[
    new webpack.DllPlugin({
      path: path.join(__dirname, '../dist/dll', '[name].manifest.json'),
      name: '_dll_[name]_[hash]',
      context: __dirname // 必填 且与 webpack.config.js 中的 DllReferencePlugin context 保持一致
    })
  ]
}
// webpack.config.js 主配置文件中(关键代码)
new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require(
        path.join(__dirname, '../dist/dll/react.manifest.json')
      )
    }),

以我自己个人的项目来说,优化前 app.js 为 153kb,使用 dll 技术后 app.js 仅剩 23kb