JS中的模块化

要明白我们的打包工具究竟做了什么,首先必须明白的一点就是JS中的模块化, 在ES6规范之前,我们有commonjs, amd等主流的模块化规范。

CommonJS

JS在早期时原生时没有模块化规范的。Node.js就是一个基于V8引擎,事件驱动I/O的服务端运行环境,在09年推出时它就实现了一套名为CommonJS的模块化规范。

对于死循环截断操作

基于文件同步加载

require函数加载的时候去访问文件里的模块代码进行解析。

单例加载只执行一次

多次require相同的module,最后加载的结果都是相同的,并不是每加载一次就去执行一次代码。每个模块都是单例的。

关键词require和module.exports

在CommonJS规范里,每个JS文件就是一个模块(module),每个模块内部可以使用require函数和module.exports对象来对模块进行导入和导出。

  1. //moduleB.js
  2. module.exports = new Date().toLocaleTimeString();
  3. //moduleA.js
  4. const time = require("./moduleB")
  5. setTimeout(()=>{
  6. console.log('moduleA',time)
  7. },3000)
  8. //index.js
  9. require("./moduleA")
  10. const str = require("./moduleB")
  11. console.log(str)
  • index.js 代表的模块通过执行 require 函数,分别加载了相对路径为。./moduleA ./moduleB 两个模块, 同时输出 moduleB 模块的结果。
  • moduleA.js 文件内也通过 require 函数加载了 moduleB.js 模块,在3s后页输出了加载进来的结果。
  • moduleB.js 文件内部相对来说就简单得多,仅仅定义了一个时间戳,然后通过 module.exports 导出。

AMD

另一个为WEB开发者所熟知的JS运行环境就是浏览器了。浏览器并没有提供像 Node.js 里一样的require 方法。不过,收到CommonJS模块化规范的启发,WEB端还是逐渐发展起来了AMD,SystermJS规范等适合浏览器端运行的JS模块化规范。

异步获取

AMD 全称是 Asynchronous module definition,意为 异步的模块定义, 不同于CommonJS规范的同步加载, AMD正如其名所有模块默认都是异步加载,这也是早期为了满足web开发的需要,因为如果在web端也使用同步加载,那么页面在解析脚本文件的过程中可能会造成页面暂停响应。

基于参数和回调形式来确定模块被完全加载

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. <title>requirejs</title>
  8. </head>
  9. <body>
  10. <script src="./require.js"></script>
  11. <script src="./index.js"></script>
  12. </body>
  13. </html>

image.png

关键词通过define和require定义和加载模块

  1. //moduleB.js
  2. define(function(){
  3. return new Date().toLocaleTimeString()
  4. });
  5. //moduleA.js
  6. define(function(require){
  7. var time = require("moduleB")
  8. setTimeout(()=>{
  9. console.log(time)
  10. },3000)
  11. })
  12. //index.js
  13. require(["moduleA", "moduleB"], function (moduleA, moduleB) {
  14. console.log(moduleB);
  15. });

单例-同一个文件不管被加载多少次,结果也是只执行一次,后面加载的都是缓存

image.png
image.png

RequireJS

RequireJS文档

https://requirejs.org/

下载RequireJS

https://requirejs.org/docs/release/2.3.6/comments/require.js

如果想要使用 AMD 规范, 我们还需要在页面中添加一个符合AMD规范的加载器脚本,符合AMD规范实现的库有很多,比较有名的就是 require.js

ESModule

前面我们说到的 CommonJS 规范和 AMD 规范有这么几个特点?

  1. 语言上层的运行环境中实现的模块化规范,模块化规范是由环境自己定义。
  2. 相互之间不能共用模块。例如不能在Node.js运行 AMD 模块, 不能直接在浏览器运行 CommonJS 模块

在ES6之后,JS有了语言层面的模块化导入导出关键词与语法以及与之匹配的ESModule规范。使用ESModule规范,我们可以通过 import 和 export 两个关键词来对模块进行导入和导出。

JavaScript modules 模块 - JavaScript | MDNhttps://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules

模块化的背景

Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。
因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 Javascript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 WebpackBabel)。
好消息是,最新的浏览器开始原生支持模块功能了,这是本文要重点讲述的。这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。

浏览器支持

使用JavaScript 模块依赖于import和 export,浏览器兼容性如下(绿色方框中的数字对应相应平台上支持该功能的发布版本):
IE全线不支持

代码

  1. //moduleB.js
  2. let time = new Date().toLocaleTimeString;
  3. export default time;
  4. //moduleA.js
  5. import m from "./moduleB";
  6. setTimeout(() => {
  7. console.log(m);
  8. }, 3000);
  9. //index.js
  10. import "./moduleA";
  11. import m from "./moduleB";
  12. console.log(m);

每个JS的运行环境都有一个解析器,否则这个环境也不会认识JS语法。
大部分环境都不直接支持ESModule,所以需要进行编译。
使用webpack打包,babel转换

  1. yarn add webpack webpack-cli @babel/core babel-loader @babel/preset-env

webpack.config.js
webpack基本使用系列-js兼容性处理 babel-loader @babel/core @babel/preset-env - SegmentFault 思否
https://segmentfault.com/a/1190000022413068

  1. const path = require("path");
  2. module.exports = {
  3. mode: "none",
  4. entry: "./index.js",
  5. output: {
  6. path: path.resolve(__dirname, "dist"), //绝对路径
  7. filename: "index.bundle.js",
  8. // publicPath: "dist/",
  9. },
  10. module: {
  11. rules: [
  12. {
  13. test: /\/js$/,
  14. exclude: /node_modules/,
  15. loader: "babel-loader",
  16. options: {
  17. // 预设:指示babel做怎么样的兼容性处理
  18. presets: [["@babel/preset-env"]],
  19. },
  20. },
  21. ],
  22. },
  23. };
npx webpack

@babel/preset-env 只能对基本js兼容处理,也就是只能转换基本语法,遇到promise高级语法不能转换
解决: 可以全部js兼容处理 安装 @babel/polyfill ,支持全部高级语法兼容转换,但是问题是我只要解决部分兼容性问题,但是将所有兼容性代码全部引入,体积太大了~
最终解决方案:兼容处理只需:按需加载 ,需要安装core-js

解析器的作用就是用ECMAScript的规范去解释JS语法,也就是处理和执行语言本身的内容,例如按照逻辑正确执行 var a = "123";function func(){} 之类的内容.

在解析器的上层,每个运行环境都会在解释器的基础上封装一些环境相关的 API。例如Node.js 中的global对象、process对象,浏览器中的window对象,document对象等等。
这些运行环境的API收到各自规范的影响。例如浏览器端的W3C规范,他们规定了window对象和document对象上的API内容,以使得我们能够让这些API正常运行。
image.png

ESModule就属于JS Core层面的规范,而AMD,commonjs是运行环境的规范,所以想要是的运行环境支持超ESModule其实是比较简单的,只需要升级自己环境中的JS Core解释引擎导入够的版本,引擎层面就能认识这种语法,从而不认为这是一个 语法错误(syntax error),运行环境只需要做一些兼容工作即可。

Node.js在V12版本之后才可以使用ESModule,需要升级Node到高版本。

Node.js 如何处理 ES6 模块 - 阮一峰的网络日志

http://www.ruanyifeng.com/blog/2020/08/how-nodejs-use-es6-module.html
ES6 模块和 CommonJS 模块有很大的差异。

语法上面,CommonJS 模块使用require()加载和module.exports输出,ES6 模块使用importexport

用法上面,require()是同步加载,后面的代码必须等待这个命令执行完,才会执行。import命令则是异步加载,或者更准确地说,ES6 模块有一个独立的静态解析阶段,依赖关系的分析是在那个阶段完成的,最底层的模块第一个执行。

Node.js 要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。Node.js 遇到.mjs文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"

如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为module

{
   "type": "module"
}

一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。

# 解释成 ES6 模块
$ node my-app.js

如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

总结为一句话:
**.mjs**文件总是以 ES6 模块加载,**.cjs**文件总是以 CommonJS 模块加载,**.js**文件的加载取决于**package.json**里面**type**字段的设置

注意,ES6 模块与 CommonJS 模块尽量不要混用。
require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。
反过来,.mjs文件里面也不能使用require命令,必须使用import

后模块化的编译时代

通过前面分析,使用ESModule的模块明显更符合JS开发的历史进程,因为任何一个支持JS的环境,随着应用解释器的升级,最终一定会支持ESM的标准。

但是web端受制于用户使用的浏览器版本,我们并不能随心所欲随时使用JS最新特性,为了是我们的代码能够运行在老旧的浏览器中,需要能够静态把高版本规范的代码编译为低版本规范的代码的工具,例如熟知的babel。

然而,不幸的是,对于模块化相关的import和export关键字,babel最终会把它编译为包含require和exports的CommonJS规范

babel编译无法解决模块化通用的问题

Babel 中文网 · Babel - 下一代 JavaScript 语法的编译器
https://www.babeljs.cn/repl
image.png

这就产生了问题,这样带有模块化关键词的模块,编译之后还是无法直接运行在浏览器中,因为浏览器并不能直接运行CommonJs规范的模块,除了编译之外,我们还需要一个步骤叫做 打包(Module)。

打包可以将模块化内部实现的细节抹平

打包工具的作用,就是将模块化内部实现的细节抹平。无论是AMD还是CommonJs模块化规范的模块,经过打包处理后能够变成直接运行在浏览器、Node中的代码。

如何处理打包

NodeJS如何进行commonjs模块化

const str = `require("./moduleA");
const str = require("./moduleB");
console.log(str);`;

const functionWrapper = ["function a(require,module,exports){", "}"];

//1.将文件进行包裹 成为一个字符串函数

const result = functionWrapper[0] + str + functionWrapper[1];


console.log(result)
const vm = require('vm');
//eval new Function
//把字符串变成可执行函数 注入require,module,exports就可以进行导入导出操作
vm.runInNewContext(result)

vm_runinnewcontext | Node.js API 文档

const vm = require('vm');

const contextObject = {
  animal: 'cat',
  count: 2
};

vm.runInNewContext('count += 1; name = "kitty"', contextObject);
console.log(contextObject);
// 打印: { animal: 'cat', count: 3, name: 'kitty' }

http://nodejs.cn/api/vm.html#vm_vm_runinnewcontext_code_contextobject_options
image.png

参考Node.js源码来熟悉CommonJS的处理方式

(function () {
  var moduleA = function (require, module, exports) {
    console.log("hello bundler");
    module.exports = "hello world";
  };
  var module = { exports: {} };
  moduleA(null,module);
})();

浏览器中对CommonJS处理

只有导出的CommonJS的简单脚本

index.js

console.log("hello bundler");
module.exports = "hello world";

index.module.js

(function () {
  //为了不用起名字 放到数组里面 数组里面的每一项都是匿名的模块
  var moduleList = [
    //index.js
    function (require, module, exports) {
      console.log("hello bundler");
      module.exports = "hello world";
    },
    //上面这部分都是通用的
  ];

  var module = { exports: {} };
  moduleList[0](null, module,module.exports);
})();

bundle.js
读取样本然后replace替换然后写入新文件

const path = require("path");
const fs = require("fs");

const boiler = fs.readFileSync(
  path.resolve(__dirname, "index.bundle.boilerplate"),
  "utf-8"
);

const target = fs.readFileSync(
  path.resolve(__dirname, "..", "index.js"),
  "utf-8"
);

const content = boiler.replace("/* template */", target);

fs.writeFileSync(path.resolve(__dirname, "dist/index.bundle.js"), content, "utf-8");

index.bundle.boilerplate

(function () {
  var moduleList = [
    function (require, module, exports) {
        /* template */
    },
  ];

  var module = { exports: {} };
  moduleList[0](null, module,module.exports);
})();

打包结果

(function () {
  var moduleList = [
    function (require, module, exports) {
      console.log("hello bundler");
      module.exports = "hello world";
    },
  ];

  var module = { exports: {} };
  moduleList[0](null, module);
})();

针对导入的脚本

需要找依赖关系然后同步处理导入导出依赖

index.bundle-require.js

(function () {
  //为了不用起名字 放到数组里面 数组里面的每一项都是匿名的模块
  var moduleList = [
    //index.js
    function (require, module, exports) {
      const moduleA = require("./moduleA");
      console.log("moduleA", moduleA);
      console.log("hello bundler");
      module.exports = "hello world";
    },
    // moduleA.js
    function (require, module, exports) {
      module.exports = new Date().toLocaleTimeString();
    },
  ];

  // 模块依赖数组
  var moduleDepIdList = [{ "./moduleA": 1 }, {}];

  function require(id, parentId) {
    var currentModuleId =
      parentId !== undefined ? moduleDepIdList[parentId][id] : id;

    var module = { exports: {} };
    var moduleFunc = moduleList[currentModuleId];
    moduleFunc((id) => require(id, currentModuleId), module, module.exports);
    return module.exports;
  }
  require(0);
  // var module = { exports: {} };
  // moduleList[0](null, module);
})();

替换模板 index.bundle.boilerplate

(function () {
  var moduleList = [
    /* template-module-list */
  ];

  var moduleDepIdList = [
    /* template-module-dep-id-list */
  ];

  function require(id, parentId) {
    var currentModuleId =
      parentId !== undefined ? moduleDepIdList[parentId][id] : id;
    var module = { exports: {} };
    var moduleFunc = moduleList[currentModuleId];
    moduleFunc((id) => require(id, currentModuleId), module, module.exports);
    return module.exports;
  }
  require(0);
})();

异步组件打包

动态import导入在webpack中是如何实现的?

index.js

setTimeout(() => {
  import("./moduleA").then((content) => {
    console.log(content);
  });
}, 5000);

moduleA.js

import m from "./moduleB";

setTimeout(() => {
  console.log(m);
}, 3000);

moduleB.js

 let time = new Date().toLocaleTimeString();
 export default time;

针对异步加载的,webpack把他们打成多个包,然后异步加载,通过JSONP实现

image.png

image.png

require.ensure

(function () {
  var moduleList = [
    function (require, exports, module) {
      require.ensure("1").then((res) => {
        console.log(res);
      });
    },
  ];

  var moduleDepIdList = [];

  var cache = {}; //异步

  function require(id, parentId) {
    var currentModuleId =
      parentId !== undefined ? moduleDepIdList[parentId][id] : id;

    var module = { exports: {} };
    var moduleFunc = moduleList[currentModuleId];
    moduleFunc((id) => require(id, currentModuleId), module, module.exports);
    return module.exports;
  }

  //JSONP作用就是从全局cache里面读取到刚才存储的resolve方法
  //在resolve里面 通过函数执行把模块导出的对象resolve出去
  //就可以在then里面拿到模块
  window.__JSONP = function (chunkId, moduleFunc) {
    var currentChunkStatus = cache[chunkId];
    var resolve = currentChunkStatus[0];
    var module = { exports: {} };
    moduleFunc(require, module, module.exports);
    resolve(module.exports);
  };

  //对于import() 遇到require.ensure 通过jsonp进行异步加载
  //通过全局的对象对当前模块的状态进行缓存
  //没有就创建一个script标签通过jsonp形式异步通过trunkId拿到打包结果
  //最终返回一个promise
  //把两个状态添加到全局的cache
  //第一个是resolve状态 记录当前status 为true就是还在加载

  require.ensure = function (chunkId, parentId) {
    var currentModuleId =
      parentId !== undefined ? moduleDepIdList[parentId][chunkId] : chunkId;
    var currentChunkStatus = cache[currentModuleId];

    if (currentChunkStatus === undefined) {
      //没有
      var $script = document.createElement("script");
      // $script.src = chunkId + currentChunkStatus + ".js";
      $script.src = "chunk_" + chunkId + ".js";
      document.body.appendChild($script);

      var promise = new Promise(function (resolve) {
        var chunkCache = [resolve];
        chunkCache.status = true;
        cache[currentModuleId] = chunkCache;
      });
      cache[currentModuleId].push(promise);

      return promise;
    }

    if (currentChunkStatus.status) {
      return currentChunkStatus[1];
    }

    return chunkCache;
  };

  // require(0);
  moduleList[0](require, null, null);
})();

HMR原理 热重载

面向切面的插件设计