目录

Webpack理解

目录

webpack是一个模块打包器,老一点的还有gulpgrunt等等, 他最显著的特点就是将文件视为一个个模块,通过设置入口文件entry,加载不同类型的文件用不同loader转换文件, 然后使用不同plugin对文件处理,最后输出多个打包、分割后的文件

首先有几个概念 1.entry:即webpack打包的入口,告诉它应该从那个文件开始进行构建 2.output:设置打包后文件的输出路径以及如何命名这些文件 3.loader:处理那些非javaScript文件,通过指定对应文件所需的对应loader的处理,将文件转换为webpack能够处理的有效模块 4.plugins:转换某些类型的模块,功能强大,可以做到打包优化,压缩以及各种各样的其它任务

bundle:视为webpack打包提取的模块生成的js文件,将其它具体模块的代码传入其中执行,原本独立的模块文件,通过调用__webpack_require__,合并到了bundle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
(function(modules) {
/******/    var installedModules = {};
/******/
/******/    var installedChunks = {
/******/        2: 0
/******/    };
/******/
/******/    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;
/******/    }
/******/ })
/************************************************************************/
/******/ ({
	// 传入的模块
	......
});

打包后的bundle是一个立即执行的匿名函数,其参数就是打包后的模块,上面__webpack_require__方法会首先判断当前模块moduleId是否已经存在缓存installedModules中,若是存在则直接返回。若是不存在,则会构造一个对象并将其同时存到installedModules中和module

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__) 这段代码首先执行当前模块的具体代码,传入modulemodule.exports__webpack_require__,递归调用__webpack_require__处理每个模块种引入的其它模块 如以下形式

1
2
3
4
5
6
7
8
/***/ 0:
/***/ (function(module, exports, __webpack_require__) {
	......
	__webpack_require__(1);
	module.exports = __webpack_require__(2);
	......

/***/ })

一个简单的bundle构建过程:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// 引入相关依赖,对文件进行编译转换输出处理
const fs = require('fs');
const path = require('path');
const parse = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');


let ID = 0;
// 读取文件信息,并获得当前js文件的依赖关系
function createAsset(filename) {
  // 获取文件,返回值是字符串
  const content = fs.readFileSync(filename, 'utf-8');

  // 将字符串转为ast
  const ast = parse.parse(content, {
    sourceType: 'module'
  });

  //用来存储 文件所依赖的模块,简单来说就是,当前js文件 import 了哪些文件,都会保存在这个数组里
  const dependencies = [];

  //遍历当前ast(抽象语法树)
  traverse(ast, {
    //找到有 import语法 的对应节点
    ImportDeclaration: ({ node }) => {
      //把当前依赖的模块加入到数组中
      dependencies.push(node.source.value);
    }
  });

  //模块的id 从0开始, 相当一个js文件 可以看成一个模块
  const id = ID++;

  //这边主要把ES6 的代码转成 ES5
  const { code } = babel.transformFromAstSync(ast, null, {
    presets: ['@babel/preset-env']
  });

  return {
    id,
    filename,
    dependencies,
    code
  };
}

// 从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {
  const mainAsset = createAsset(entry);
    
  const queue = [mainAsset];

  for (const asset of queue) {
    const dirname = path.dirname(asset.filename);
    // 新增一个属性来保存子依赖项的数据
    asset.mapping = {};
    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath);
      //获得子依赖(子模块)的依赖项、代码、模块id,文件名
      const child = createAsset(absolutePath);
      //给子依赖项赋值,
      asset.mapping[relativePath] = child.id;
      //将子依赖也加入队列中,广度遍历
      queue.push(child);
    });
  }
  return queue;
}

//根据生成的依赖关系图,生成对应环境能执行的代码,目前是生产浏览器可以执行的
function bundle(graph) {
  let modules = '';

  //循环依赖关系,并把每个模块中的代码存在function作用域里
  graph.forEach(mod => {
    modules += `${mod.id}:[
      function (require, module, exports){
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });

  //require, module, exports 是 cjs的标准不能再浏览器中直接使用,所以这里模拟cjs模块加载,执行,导出操作。
  const result = `
    (function(modules){
      //创建require函数, 它接受一个模块ID(这个模块id是数字0,1,2) ,它会在我们上面定义 modules 中找到对应是模块.
      function __webpack_require__(id){
        const [fn, mapping] = modules[id];
        function localRequire(relativePath){
          //根据模块的路径在mapping中找到对应的模块id
          return __webpack_require__(mapping[relativePath]);
        }
        const module = {exports:{}};
        //执行每个模块的代码。
        fn(localRequire,module,module.exports);
        return module.exports;
      }
      //执行入口文件,
      __webpack_require__(0);
    })({${modules}})
  `;

  return result;
}

// 入口文件
const graph = createGraph('./entry.js');
// 文件内容
const ret = bundle(graph);

// 打包生成文件
fs.writeFileSync('./bundle.js', ret);

loader,通过设置对应的rule,使用不同的loader来转换、处理不同的组件,比如css文件使用css-loader以及style-loader来处理

1
2
3
4
{
  test: /\.css$/,
  loader: 'style-loader!css-loader'
}

除此之外,还有一种内联loader的形式,即import loader from '......'

两种方式处理的顺序也不同,大致流程为 1.webpack启动后,创建新的compilation 2.实例化rules 3.解析inline loaders 4.解析config配置里面的loaders 5.组合这两种形式的loader,最终输出上诉第一种形式的配置 6.使用Loader-runner按配置执行loader

以下简单写一个loader,首先写一个方法用于加载loader,并处理传入的模块,然后返回处理完了之后的代码

1
2
3
4
5
6
7
let source = ...... // source为获取到的模块的代码
function loaderModule(loaderName) {
  // 获取loader路径
  const loaderPath = path.join(process.cwd(), loaderName)
  const loader = require(loaderPath)
  source = loader.call(_this, source)
}

定义一个规则

1
2
3
4
5
6
rules: [{
  test:/\.js/,
  use:[
    './loaderModule.js', // loader的路径
  ]
}]

执行的时候遍历rules

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
for (let i = rules.length - 1; i >= 0; i--) {
  const { test, use } = rules[i]
  if (test.test(modulePath)) {
    // 使用多个loader
    if (Array.isArray(use)) {
      for (let j = use.length - 1; j >= 0; j--) {
        loaderModule(use[j])
      }
    } else if (typeof use === 'string') {
      loaderModule(use)
      // 带参数型的loader
    } else if (use instanceof Object) {
      loaderModule(use.loader, {
        query: use.options
      })
    }
  }
}

// loaderModule.js内容
// loader-utils是webpack一个工具类,用于解析loader,获取配置的一些loader参数
const loaderUtils = require('loader-utils')

// 这个简单的loader会将js文件里面的hello字符串替换成word
module.exports = function (source) {
  const optionsName = loaderUtils.getOptions(this) && loaderUtils.getOptions(this).name || 'world'
  return source.replace(/hello/g, optionsName)
}

前面是一个同步的loader,如果想写一个异步的loader,可以在loader内部调用async方法,然后在处理之后调用对应的回调方法处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module.exports = function (source) {
  const optionsName = loaderUtils.getOptions(this) && loaderUtils.getOptions(this).name || 'world'
  cosnt callback = this.async()

  // 操作完了之后,调用callback返回结果进入下一个loader
  asyncOperation(source, optionsName, function(err, result) {
    iferrreturn callback(err)
    callback(err, result)
  })
}

如果不用上面的方式,返回一个promise也是可以的

1
2
3
4
5
6
7
8
9
module.exports = function (source) {
  const optionsName = loaderUtils.getOptions(this) && loaderUtils.getOptions(this).name || 'world'
  return new Promise(resolve => {
    asyncOperation(source, function(err, result) {
      if (err) resolve(err)
      resolve(err, result)
    })
  })
}

plugin plugin进一步拓展了webpack的功能,比如打包优化和压缩,清空当前项目的目录,重新定义环境变量,将代码输出到某个文件,提取功能模块等等

定义一个plugin的时候,首先要提供一个apply方法,接受一个compiler对象,然后注册对应的钩子函数,在回调里面拿到对应参数,然后处理 以下定义一个plugin

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const path = require('path')
const fs = require('fs')
const cheerio = require('cheerio')

class BasePlugin {
  constructor(options){
    // 插件的参数,filename、template等
    this.options = options
  }
  apply(compiler) {
    // 注册afterEmit钩子函数
    compiler.hooks.afterEmit.tap('BasePlugin', (compilation) => {
      // 2. 根据模板读取html文件内容
      const result = fs.readFileSync(this.options.template, 'utf-8')
      
      // 3. 使用 cheerio 来分析 HTML
      let $ = cheerio.load(result)
    
      // 4. 创建 script 标签后插入HTML中
      // compilation.assets代表所有输出的资源文件
      Object.keys(compilation.assets).forEach(item => {
        $(`<script src="/${item}"></script>`).appendTo('body')
      })
    
      // 5. 转换成新的HTML并写入到 dist 目录中
      fs.writeFileSync(path.join(process.cwd(), 'dist', this.options.filename), $.html())
    })
  } 
}

compiler对象包含了webpack环境所有的的配置信息,包含optionsloadersplugins这些信息,这个对象在webpack启动时候被实例化,它是全局唯一的,可以简单地把它理解为webpack实例,compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当webpack以开发模式运行时,每当检测到一个文件变化,compilation就会被重新构建,其它的一些钩子函数

顺便总结一下,实现一个简单的webpack的步骤 1.定一个基础对象,构造方法里面传入entryoutputrulesplugins等属性 2.然后定义一系列钩子函数,在webpack执行中可以调用这些钩子函数做处理, 3.开始执行,初始化对应的钩子函数,传入entry等相关信息 4.拿到文件源码信息,使用loader处理,将代码转换成ast形式 5.traverseast代码中的require替换为__webpack_require__,添加新的module信息 6.递归处理每一个依赖,重复上面的步骤 7.初始化plugin,并对文件做处理 8.输入到指定目录 9.客户端/浏览器运行时执行

参考链接

1.webpack原理

2.webpack官方文档