js的模块化

260 阅读20分钟

前言

最开始的时候,js只是浏览器脚本语言;而前端,就是小小的切图仔.随着时代的进步,技术的发展,前端工程日益复杂化,于是就需要分工合作,需要将前端项目工程化.

其中一项就是模块化,将代码进行模块化拆分,这就需要js能够支持模块化.说到前端的模块化,我们肯定马上想到Commonjs、AMD、CMD、UMD、ESM,但是很多人还不清楚它们分别代表何种模块化方案.

本文将围绕模块化讲讲它们的演变过程,并简要模拟下它们的实现方法.

一,最初没有模块化时的处境

1.1,全局上堆叠变量

最开始的时候,网页的页面和样式都非常简单,极少有交互,也就是js也很少,于是人们写代码就轻松随意得很:

function foo(){
  console.log("foo")
}
function bar(){
  console.log("bar")
}

这样带来的问题,就是所有的变量都堆积在全局作用域上,代码混乱且容易命名冲突.作为一个程序员,起变量名困难已经是不争的事实了.

1.2,使用对象包裹变量

为了减少全局作用域上的变量数,人们想到使用一个对象进行包裹.于是人们写代码的方式变成:

var obj={
  a:1,
  foo:function(){
    console.log(a)
  },
  bar:function(){
    console.log('bar')
  }
}
obj.foo()

但是这种方式还存在一个弊端,obj.a应该是它的私有变量,如果我们将其改成obj.a=2,那么obj.foo()打印出来的就会变成2.

1.3,采用匿名函数IIFE的形式

为了让上文的obj.a能够更安全,人们想到了利用函数的块级作用域,于是开始改写成匿名函数自调用的形式.

var myModule = (function() {
  var name = '孙悟空'
  function getName() {
    console.log(name)
  }
  
  return { getName } 
})()
myModule.getName()
console.log(myModule.name)//undefined

这样我们就可以通过 myModule.getName() 来获取 name,并且实现 name 属性的私有化,即外部无法访问其内部的私有变量.

但是这样子做还存在一个问题:多模块js引入后,如何保证多模块之间依赖关系的维护?于是就有了更进一步的模块化.

二,早期模块化的演变

2,1,common.js

Commonjs 的提出,弥补 Javascript 对于模块化,没有统一标准的缺陷。

Commonjs有如下特点:

在 commonjs 中每一个 js 文件都是一个单独的模块,我们可以称之为 module;
该模块中,包含 CommonJS 规范的核心变量: exportsmodule.exports、require;
exportsmodule.exports 可以负责对模块中的内容进行导出;
require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;
而浏览器原生是不支持 CommonJS 的,因为 CommonJS 适用于加载本地模块,是一个同步加载的过程,比如 Node.js 中加载模块其实是一个读取本地文件并执行的同步过程,而在浏览器中要获取资源通常是需要异步请求获取的;

2.1.1,commonjs的使用案例

导出案例一(使用exports):

//index.js
exports.test=()=>{
    console.log("1")
}
exports.a="这是一个变量"

导入案例一(使用exports):

const lala=require('./index')
console.log("---",lala)//--- { test: f()], a: '这是一个变量' }

导出案例一(使用module.exports)

module.exports={
    a:'这是一个变量',
    test:()=>{
        copnsole.log(1)
    }
}

引入使用的效果和exports是一样的.

2.1.2,module是啥

我们先把module打印出来:

//./index.js
module.exports={
    a:'这是一个变量',
    test:()=>{
        console.log(module)
    }
}
//test.js
const lala=require('./index')
lala.test()

运行之后得到的结果:

1.png

可以看到,每一个文件,都是一个module实例,里面有以下几个属性:

module.id 模块的识别符,通常是带有绝对路径的模块文件名
module.path 模块的文件路径
module.exports 表示模块对外输出的值
module.filename 模块的文件名,带有绝对路径
module.loaded 返回一个布尔值,表示模块是否已经完成加载
module.parent 返回一个对象,表示调用该模块的模块
module.children 返回一个数组,表示该模块要用到的其他模块
module.paths 返回一个数组,查找node_modules的路径

2.1.3,commonjs为何能实现模块化

按照上文的说法,commonjs最重要的两个点是:能进行模块变量的隔离和引入导出的模块变量.

那么一定还是基于函数自调用的形式.于是可以写出最简单的commonjs方法:

//./index.js
module.exports={
    a:'这是一个变量',
    test:()=>{
        console.log(module)
    }
}
​
//common.js
let path = require('path');
let fs = require('fs');
let vm = require('vm');
​
// 构造函数Module
function Module(filename){
  this.filename = filename; // 文件的绝对路径
  this.exports = {}; // 模块对应的导出结果
}
// 拼凑成闭包的数组
Module.wrapper = ['(function(module){','\r\n})'];
// 加载模块本身
Module.prototype.load = function (module) {
  // 读文件
  let content = fs.readFileSync(module.filename, 'utf8');
  // 形成闭包函数字符串
  let script = Module.wrapper[0] + content + Module.wrapper[1];
  // 创建沙箱环境,运行并返回结果(把模块代码处理成字符串-然后利用vm.runInThisContext处理成可执行代码,这里是个函数包裹着module)
  let fn = vm.runInThisContext(script);
  // 执行闭包函数,将被闭包函数包裹的加载内容
  fn(module)
};
// 仿require方法, 实现加载模块
function req(p) {
  // 根据输入的路径 转换绝对路径
  let filename= path.join(__dirname, p);
  // 通过文件名创建一个Module实例
  let module = new Module(filename);
  // 加载文件,执行对应加载方法
  module.load(module);
  return module.exports
}
​
let str = req('./index.js');
console.log(str);//{ a: '这是一个变量', test: [Function: test] }

实际上原理非常简单:

1,引入每个文件的时候,执行require,会创建一个module实例,其内有个exports属性(用来存储后续要导出的对象)
2,require文件的时候,会根据绝对路径读取这个文件内容,并且用一个函数包裹成(function(module){module.exports={a:'这是一个变量',test:()=>{console.log(module)}}})的形式,然后使用vm.runInThisContext(script)转化为可执行代码后执行.
3,在执行该闭包函数的时候,因为引入的文件内容有module.exports=...的形式,即给module实例的exports属性赋值存储了要导出的对象.
4,requie方法最后导出module.exports这个存储的对象即可.

最核心的一点就是require方法会把文件用函数包裹起来,并且传入实参mudule,这才能在模块文件中修改exports的值,并且require方法最后将这个修改后的exports值返回.

2.1.4,精简版的commonjs

上文的仅仅是最简陋的commonjs,如下代码,这才是个精简的,基本的功能实现如下:

let path = require('path');
let fs = require('fs');
let vm = require('vm');
​
let n = 0
​
// 构造函数Module
function Module(filename){
  this.id = n++; // 唯一ID
  this.filename = filename; // 文件的绝对路径
  this.exports = {}; // 模块对应的导出结果
}
​
// 存放可解析的文件模块扩展名
Module._extensions = ['.js'];
// 缓存
Module._cache = {};
// 拼凑成闭包的数组
Module.wrapper = ['(function(exports,require,module){','\r\n})'];
​
// 没写扩展名,默认添加扩展名
Module._resolveFilename = function (p) {
  p = path.join(__dirname, p);
  if(!/.\w+$/.test(p)){
    //如果没写扩展名,尝试添加扩展名
    for(let i = 0; i < Module._extensions.length; i++){
      //拼接出一个路径
      let filePath = p + Module._extensions[i];
      // 判断文件是否存在
      try{
        fs.accessSync(filePath);
        return filePath;
      }catch (e) {
        throw new Error('module not found')
      }
    }
  }else {
    return p
  }
}
​
// 加载模块本身
Module.prototype.load = function () {
  // 解析文件后缀名 isboyjc.js -> .js
  let extname = path.extname(this.filename);
  // 调用对应后缀文件加载方法
  Module._extensions[extname](this);
};
​
// 后缀名为js的加载方法
Module._extensions['.js'] = function (module) {
  // 读文件
  let content = fs.readFileSync(module.filename, 'utf8');
  // 形成闭包函数字符串
  let script = Module.wrapper[0] + content + Module.wrapper[1];
  // 创建沙箱环境,运行并返回结果
  let fn = vm.runInThisContext(script);
  // 执行闭包函数,将被闭包函数包裹的加载内容
  fn.call(module, module.exports, req, module)
};
​
// 仿require方法, 实现加载模块
function req(path) {
  // 根据输入的路径 转换绝对路径
  let filename = Module._resolveFilename(path);
  // 查看缓存是否存在,存在直接返回缓存
  if(Module._cache[filename]){
      return Module._cache[filename].exports;
  }
  // 通过文件名创建一个Module实例
  let module = new Module(filename);
  // 加载文件,执行对应加载方法
  module.load();
  // 入缓存
  Module._cache[filename] = module;
  return module.exports
}

和之前最简陋版大差不差,只是增加了一些细节的处理.

值得注意的是,传入的参数变成了这三个:

fn.call(module, module.exports, req, module)

这里module.exports可以看到它对应的实参是exports,这就是模块中为啥既可以使用module.exports也可以使用exports的原因.因为他两实际上是都指向module实例的exports对象.

总结,简单点说,CommonJs 就是模块化的社区标准,而 Nodejs 就是 CommonJs 模块化规范的实现,它对模块的加载是同步的,也就是说,只有引入的模块加载完成,才会执行后面的操作,在 Node 服务端应用当中,模块一般存在本地,加载较快,同步问题不大,在浏览器中就不太合适了,太慢了,用户体验极,所以还需要异步模块化方案,所以 AMD规范 就此诞生。

这里我原本有个疑惑,为啥它实现的模块加载是同步的呢?直到我看到这个读取文件的代码:

 // 读文件
  let content = fs.readFileSync(module.filename, 'utf8');

是用的fs.readFileSync而不是fs.readFile!

2.1.5,commonjs总结

commonjs模块化的特点如下:

1,使用require、exports和module作为模块化组织的关键字;
2,每个模块只加载一次,作为单例存在于内存中,每次require时使用的是它的接口;
3,require是同步的,通俗地讲,就是node运行A模块,发现需要B模块,会停止运行A模块,把B模块加载好,获取的B的接口,才继续运行A模块。如果B模块已经加载到内存中了,当然require B可以直接使用B的接口,否则会通过fs模块化同步地将B文件内存,开启新的上下文解析B模块,获取B的API。

2.2,AMD规范

AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

它的重点是:等所有相关的依赖都加载并执行取得结果后,才执行这个回调函数.

使用案例:

<!DOCTYPE html>
<html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
  </head>
  <body>
    <script src="./requireJS.js"></script>
    <script>
        require(['a', 'b'], function (a, b) {
            console.log(b + a)
        });
    </script>
  </body>
</html>

相关模块:

//a.js
define([], function () {
    return 1
})
​
//b.js
define(['c'], function (c) {
    console.log('3333')
    return 2 + c
})
​
//c.js
define([], function () {
    return 2
})

那么我们需要三个属性

moudle://存储一个模块的信息:依赖、回调函数、返回值
cache:该对象用来存储每个模块的返回值,如a被bcd引用的话,只要执行一次,得到结果暂存结果,后续cd模块就能直接使用结果了.
tasks:该数组用来存储每个模块的具体信息:名字、依赖项目、回调函数.之所以存储这个,是为了在依赖项都加载完毕后执行对应的回调函数.
(function () {
    // 缓存
    let moudle = null//存储一个模块的信息:依赖、回调函数、返回值
    const cache = {}//存储每个模块的返回值
    const tasks = []//收集所有具备依赖项的模块的数据
    
    // 创建script标签,用来加载文件模块,浏览器中动态引入文件需要创建script标签来引入
    const createNode = function (depend) {
      let script = document.createElement("script");
      script.src = `./${depend}.js`;
      // 嵌入自定义 data-moduleName 属性,后可由dataset获取
      script.setAttribute("data-moduleName", depend);
      let fs = document.getElementsByTagName('script')[0];
      fs.parentNode.insertBefore(script, fs);
      return script;
    }
  
    // 校验所有依赖是否都已经解析完成-每个文件如果解析完成得到结果了,会存储在cache中,所以cache中有该模块的结果,则说明解析完成过
    const hasAlldependencies = function (dependencies) {
      let hasValue =dependencies.every(depd => {
        return cache.hasOwnProperty(depd)
      })
      return hasValue
    }
  
    // 递归执行callback
    const implementCallback = function (tasks) {
      if (tasks.length) {
        tasks.forEach((task, index) => {
          // 某个模块所有依赖解析都已完成,就执行它的回调函数,取得回调函数的结果存储在cache中
          if (hasAlldependencies(task.dependencies)) {
            //从cache中取出该模块需要的依赖的返回结果,传入该模块的回调函数执行
            const returnValue = task.callback(...task.dependencies.map(it => cache[it]))
            if (task.name) {
                cache[task.name] = returnValue
            }
            tasks.splice(index, 1)
            implementCallback(tasks)
          }
        })
      }
    }
     
    // 根据依赖项加载js文件
    const require = function (dependencies, callback) {
      if (!dependencies.length) { // 此require没有依赖项
        moudle = {
          value: callback()  //直接执行回调函数,得到该模块的结果
        }
      } else { //此require有依赖项
        moudle = {
          dependencies,
          callback
        }
        tasks.push(moudle)//对于存在依赖项的模块,需要收集到tasks中(因为后续需要每个依赖项加载完后,都判断下它的依赖项都加载完毕没)
        dependencies.forEach(function (item) {
          if (!cache[item]) {//还没有取得该模块的结果,则需要注册加载完毕后的回调.对于已取得结果的模块则无需处理
            createNode(item).onload = function () {// script加载文件结束(该模块的js执行完毕后会执行)
              // 获取嵌入属性值,即module名
              let modulename = this.dataset.modulename
              // 校验module中是否存在value属性,一个模块加载完成,如果执行完毕,则会有该模块的返回,存储在module.value中
              if (moudle.hasOwnProperty('value')) {
                // 例如a模块,它没有依赖项,a中代码require后,当前的module.value就存储着该模块的结果,因为该模块执行完毕,立马就会执行它的onload方法
                cache[modulename] = moudle.value//这时就直接拿到了a模块的结果,存储在缓存中,下次遇到了,会覆盖
              }else {
                // 该模块还没有取得结果,
                moudle.name = modulename
                if (hasAlldependencies(moudle.dependencies)) {
                  // 所有依赖解析都已完成,执行回调,抛出依赖返回(导出)值(这里是用于依赖项在其他地方已经得到结果了,这里就可以直接执行回调,不需要再引入并执行依赖了),如b模块,因为依赖了c,执行完毕后,就因为c模块未加载完毕,而无法走到这里
                  cache[modulename] = callback(...moudle.dependencies.map(v => cache[v]))
                }
              }
              implementCallback(tasks)//[i]
            }
          }
        })
      }
    }
    window.require = require
    window.define = require
  })(window)

对于浏览器中加载模块需要注意的是:

1,每个script是同步的代码先执行,也就是最外层的所有同步代码都执行完毕.
2,然后才执行到第一个引入的js内部的代码.
3,每个引入的js代码执行完毕后,才会执行该script的onload方法.

但又因为创建script引入代码是异步的,所以代码执行的顺序是:

1,index.html中的require,foreach两次createNode(引入ab代码)和绑定对应onload
2,执行a中的代码,define方法直接取得a模块结果存储在module.value
3,a模块执行完毕,执行a模块的onload方法,将a模块的结果存储在cache.a中.[1]
4,开始执行b模块内的代码,define方法,foreach一次createNode(引入c代码)和绑定onload
5,b模块执行完毕,执行b模块的onload方法,该模块还没有取得结果(module.value无值)
6,b模块有依赖没有获得结果,执行implementCallback(tasks)//[2],但是因为c模块没取得结果,所以b取不到结果,导致最外层也取不到结果.
7,开始执行c模块的代码,define方法直接取得c模块结果存储在module.value
8,c模块执行完毕,执行c模块的onload方法,将c模块的结果存储在cache.c中.//[3]
9,这时候执行implementCallback(tasks),因为b的依赖项都取得结果了(c),于是就可以执行b模块的回调,得到b的结果.然后就能执行最外层的回调了.

如上文所说,AMD规范的重点是:每个模块执行完毕后,绑定的onload事件最后都要递归遍历保存起来的tasks,让准备好的模块(相关依赖文件已取到结果)执行回调函数并缓存该模块结果.

而判断该模块是否准备好的依据就是:它的依赖项是否都取到结果了(cache数组中有它的结果值)

2.3,CMD规范

2.3.1,sea.js的使用

说到CMD,就不得不说玉伯大佬的Sea.js 。它的诞生在 RequireJS 之后,因为AMD 规范是异步的,模块的组织形式不够自然和直观。

例如说,a模块需要依赖bc两个模块的结果,那么使用AMD规范写出来的代码会是如下:

//a.js
define(['b','c'], function (b,c) {
    return b + c
})

而如果使用CMD规范,则会是:

define(function(require, exports,module) {
    var b = require('./b');
    var c = require('./c');
   exports.result=b+c 
});

就变成了同步的方式.看起来倒是和commonjs很像.

2.3.2,CMD在浏览器实现同步写法的原理

由于在浏览器端,采用与node同样的依赖加载方式是不可行的(commonjs是同步加载同步执行),因为依赖只有在执行期才能知道,但是此时在浏览器端,我们无法像node一样直接同步地读取一个依赖文件并执行!我们只能采用异步的方式。于是Sea.js的做法是,分成两个时期——加载期和执行期;

加载期:即在执行一个模块之前,将其直接或间接依赖的模块从服务器端同步到浏览器端;
执行期:在确认该模块直接或间接依赖的模块都加载完毕之后,执行该模块。

CMD全称Common Module Definition,和AMD最明显的差别时,CMD模块规范遵从就近原则,认为只有在需要的时候才进行模块加载。

接下来,我简单实现下一个CMD的代码(只考虑模块的引入,不考虑缓存模块之类的):

const modules = {};//缓存模块信息
sj = {};
//使用promise控制,resolve绑定在onload上,也就是引入的js执行完毕后执行后续的then回调
const __load = (mod) => {
  return new Promise((resolve, reject) => {
    const head = document.getElementsByTagName('head')[0];
    const node = document.createElement('script');
    node.type = 'text/javascript';
    node.src = `./${mod}.js`;;
    node.async = true;
    node.onload = resolve;
    node.onerror = reject;
    head.appendChild(node)
  })
}
//['b','c']所有加载的文件名加路径
const getDepsFromFn = (fn) => {
  let matches = [];
  let reg = /(?:require()(?:['"])([^'"]+)/g; 
  let r = null;
  while((r = reg.exec(fn.toString())) !== null) {
    matches.push(r[1])
  }
  return matches
}
//定义模块
define = (factory) => {
  const deps = getDepsFromFn(factory);//获取到该模块的依赖列表
  modules[modules.name] = { factory, deps ,exports:{}}//存储对应模块信息
}
// 这里面才是加载模块的地方
const __require = (id) => {
  modules.name=id//暂存当前加载模块名,在define中使用
  return new Promise((resolve)=>{
    __load(id).then(async() => {
      const { factory, deps } = modules[id];
      await factory(__require, modules[id]['exports'], modules[id])
      modules[id]['result']=modules[id]['exports']
      resolve(modules[id]['result'])
    })
  })
}
//加载所有依赖项取得结果,存储在缓存中,并返回,执行回调函数
sj.use = (mods, callback) => {
  mods = Array.isArray(mods) ? mods : [mods];
  return new Promise((resolve, reject) => {
    Promise.all(mods.map(mod => {
      modules.name=mod//暂存当前加载模块名,在define中使用
      return __load(mod).then(async () => {
        const { factory } = modules[mod];//这时候,该模块的代码已经执行了define,modules中有东西了
        await factory(__require, modules[mod]['exports'], modules[mod])
        return modules[mod]['exports']
      })
    })).then(resolve, reject)//所有依赖加载完毕
  }).then(instances => callback && callback(...instances))//结果透传给下一个promsie
}

使用的案例:

<!DOCTYPE html>
<html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
  </head>
  <body>
    <script src="./requireJS.js"></script>
    <script>
        // 加载一个模块,在加载完成时,执行回调
        sj.use('a', function(a) {
          console.log("---+++---",a.foo)
        });
    </script>
  </body>
</html>
​
//a.js
define(async function(require, exports,module) {
    // 获取模块b的接口
    var b = await require('b');
    b.doSomething();
    var c =await require('c');
    c.doSomething();
    exports.foo = c.name;
    // 对外提供 doSomething 方法
    exports.doSomething = function() {
        console.log("do something   aaaa")
    };
});
​
//b.js
define(async function(require, exports, module) {
  var d = await require('d');
  console.log("大大啊大大啊啊",d)
  // 对外提供接口
  module.exports = {
    name: d.foo,
    doSomething: function() {
      console.log("bbbbb",d)
    }
  };
});
​
//d.js
define(async function(require, exports, module) {
    // 对外提供接口
    module.exports = {
      foo: "d模块啦",
      doSomething: function() {
        console.log("dddd")
      }
    };
  
  });
  
//c.js
define(async function(require, exports, module) {
  // 对外提供接口
  module.exports = {
    name: 'c',
    doSomething: function() {
      console.log("ccccc")
    }
  };
});

实际上,CMD模块,就是commonjs和AMD模块的结合.

如在上文a模块中:

var b = await require('b');
b.doSomething();
var c =await require('c');
c.doSomething();

就是同步执行.

对于sj.use方法:

因为可能存在多个依赖,所以需要单独的方法,使用promise.all来保证所有依赖已经取得结果后,才执行回调函数.

对于require方法:

当a模块运行到var b = await require('b')的时候,如果b模块还有依赖,依赖内部还有更深层的依赖,则会逐层打开,逐层进入,就像洋葱一样,直到获得结果,再一层层返回,继续执行代码.

其实也没啥东西,主要就是利用两次闭包:

第一次:将define传入的回调函数存储在全局的module[id].factory中,等load完对应js后调用执行.
第二次:将暂存起来的模块的api传入factory中使用:factory(__require, modules[id]['exports'], modules[id])

2.3.3,CMD规范和commonJS、AMD规范的差别

三者的差异:

1,commonJS是给node环境使用的,AMD和CMD是给浏览器使用的.
2,CommonJS 模块是同步加载和同步执行.它读取文件采用的是fs.readFileSync同步读取.
3,AMD 模块是异步加载和异步执行.它是依赖前置,先提前声明会用到的所有依赖,然后每当一个依赖执行完毕:script加载文件结束(该模块的js执行完毕后会执行),onload方法都会递归执行所有存在依赖的模块,直到该模块依赖结果收集完毕,才执行对应回调函数.也就是异步执行(代码不会卡在某个模块等它结束,而是会执行后续代码)
4,CMD(Sea.js)模块是异步加载和同步执行.它是在define中字符串化后再收集相关依赖,然后像洋葱一样一层层返回结果后继续执行代码,也就是同步执行.

三者的原理差别

Commons:

commonjs最简单,他就是同步读取文件,然后用个函数把该文件包裹起来执行一遍,又因为传入这几个参数fn.call(module, module.exports, req, module),所以内部将导出的结果赋值给了module.exports对象,然后在require方法最后导出这个对象即可.

AMD:

因为是用在浏览器的,所以它需要创建script标签,不同的是,它采用const tasks = []来收集所有具备依赖项的模块信息.先是依赖前置,然后每当一个依赖执行完毕:script加载文件结束(该模块的js执行完毕后会执行),onload方法都会递归执行所有存在依赖的模块,直到该模块依赖结果收集完毕,才执行对应回调函数.

CMD:

它则是两者的结合,让使用者能采用同步的方式来书写模块代码.
use方法是依赖前置,和AMD一样,等所有依赖都获得结果后执行回调函数.
define方法则是在运行过程中收集依赖,然后采用洋葱方法一层层取得依赖结果,再返回上一层继续执行代码,它和commonjs一样,是将module.exports传入回调函数中进行赋值,然后在require方法的最后进行返回.

其实都是闭包的使用啦.

2.4,UMD规范

严格意义上来说,UMD并不是一个规范.它的出现是因为人们觉得node端需要写commonjs,浏览器端又要写AMD、CMD.

历史的发展从来都是分久必合,所有就有人提出使用一种规范,作为大一统的写法,能够将三者统一成一个写法.

如下代码:

((root, factory) => {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(factory);
  } else if (typeof exports === 'object') {
    // CommonJS
    module.exports = factory();
  } else if (typeof define === 'function' && define.cmd){
    // CMD
    define(function(require, exports, module) {
      module.exports = factory()
    })
  } else {
    // 都不是
    root.umdModule = factory();
  }
})(this, () => {
  console.log('UMD')
  // todo...
});

如上代码,他就是做大一统的,其实也没啥东西.内核还是commonjs、AMD、AMD三个.

三,成熟的模块化方案ES modu le

如上文所说,到目前为止我们说的 CommonJSAMDCMD 等都只是社区比较认可的统一模块化规范,并不是官方提供的,那接下来要说的就是 JS 的官方模块化规范了。

3.1,使用案例一:export的写法

// 写法一 
export var m = 1;
// 写法二 
var m = 1; 
export {m};
// 写法三 
var n = 1; 
export {n as m};

只有这三种形式的写法,才能正确地在文件对象上增加对应的属性。

export的作用,就是给文件对象增加一个属性.

3.2,使用案例二: export default

既然有了 export ,为什么还要有个 export default 呢?我们用它作为文件的默认导出接口(所以一个文件只能有一个)。

//./a.js
export const a = 1;
export const b = 2;
export const c = () => console.log("第一个");
const d = 3;
export { d };
export default {
  test: "aaa",
};
​
//使用
import * as all from "./a.js";//全量引入
console.log("----", all);

得到的结果:

---- [Module: null prototype] {
  a: 1,
  b: 2,
  c: [Function: c],
  d: 3,
  default: { test: 'aaa' }
}

可以看到,打印出来的文件对象中多了个default属性。

所以说,export default 的作用,是给文件对象,添加一个 default属性,default属性的值也是一个对象,且和export default导出的内容完全一致。

export default的使用方法:

//写法一
export default {
  test: "aaa",
};
//写法二
const test='aaaa'
export default {
  test,
};
//写法三
export default function () {
   console.log("aaaaa");
}

也就是export default仅支持对象和函数的导出。

3.3,使用案例三:import引入的使用

文件对象全量引入

这个引入的是整个文件对象,不仅包含export导出的内容(文件对象.属性名),还包含export default导出的内容(文件对象.defalut)

//./a.js
export const a = 1;
export const b = 2;
export const c = () => console.log("第一个");
const d = 3;
export { d };
export default {
  test: "aaa",
};
//导入
import * as all from "./a.js";
console.log("----", all);
//结果
---- [Module: null prototype] {
  a: 1,
  b: 2,
  c: [Function: c],
  d: 3,
  default: { test: 'aaa' }
}//可以看到文件对象包含export的属性,以及defaults属性存储着export default导出的对象

文件对象引入(仅包含default)

//./a.js
export const a = 1;
export const b = 2;
export const c = () => console.log("第一个");
const d = 3;
export { d };
export default {
  test: "aaa",
};
//导入
import test from "./a.js";
console.log("----", test);//---- { test: 'aaa' }
//结果
---- { test: 'aaa' }

可以看到,直接引入的话,是仅包含default的,而不会引入export导出的内容.

部分引入(无法引入default)

//./a.js
export const a = 1;
export const b = 2;
export const c = () => console.log("第一个");
const d = 3;
export { d };
export default {
  test: "aaa",
};
//导入
import {a,b,d} from "./a.js";
console.log("----", a,b,d);//---- 1 2 3

可以看到这种方式,仅限于获取文件对象的正常属性,default属性是获取不到的,原因有两个:

  • 未解构的对象全部进行了丢弃
  • default是关键字,不能再解构中当做变量进行使用

混合引入(引入default和export的内容)

结合上文几点,很明显,可以用如下方式导出想要的部分内容:

//./a.js
export const a = 1;
export const b = 2;
export const c = () => console.log("第一个");
const d = 3;
export { d };
export default {
  test: "aaa",
};
//导入
import defaultObj,{a,b,d} from "./a.js";
console.log("----",defaultObj,a,b,d);//---- { test: 'aaa' } 1 2 3

四,总结

实际上,JS 模块化,上述这些方案都在解决这两个问题:

1,全局变量污染,命名冲突.
2,文件依赖问题

因为node环境衍生了commonJs方案,采用函数包裹模块的方式,同步引入同步执行.

因为浏览器环境衍生了AMD规范,采用动态创建script的方式引入模块,异步引入,异步执行.

而又嫌弃AMD写起来麻烦,看起来难看,于是就有了CMD规范,结合commonjs和AMD规范的优点,实现了异步加载同步执行的效果.

紧接着,因为各种模块方案的存在,你写你的,我写我的,太乱了.于是就有人想要把这些模块统一成一种写法,可以通过运行时或者编译时让同一个代码模块在使用 CommonJs、CMD 甚至是 AMD 的项目中运行,也就是说同一个 JavaScript 包运行在浏览器端、服务区端甚至是 APP 端都只需要遵守同一个写法就行了,这就是UMD的诞生背景.

而后,随着社区的发展,官方终于提供了模块化的解决方案,这就是ES module.

至此,js才真正有了模块化.

五,参考文章

前端科普系列-CommonJS:不是前端却革命了前端 - 知乎 (zhihu.com)

「前端工程四部曲」模块化的前世今生(上) - 掘金 (juejin.cn)

今天要讲的几个模块化规范CMJ、AMD、CMD、ESM,以及简单实现AMD、CMD - 掘金 (juejin.cn)

Sea.js是如何工作的? | Hello Sea.js (island205.github.io)

JS模块加载器加载原理是怎么样的? - 知乎 (zhihu.com)

前端模块化详解(完整版) - 掘金 (juejin.cn)

CMD 模块定义规范 · Issue #242 · seajs/seajs · GitHub

「万字进阶」深入浅出 Commonjs 和 Es Module - 掘金 (juejin.cn)

六,系列文章

本文是我整理的js基础文章中的一篇,下面是已经完成的文章:

js从编译到执行过程 - 掘金 (juejin.cn)

从异步到promise - 掘金 (juejin.cn)

从promise到await - 掘金 (juejin.cn)

浅谈异步编程中错误的捕获 - 掘金 (juejin.cn)

作用域和作用域链 - 掘金 (juejin.cn)

原型链和原型对象 - 掘金 (juejin.cn)

this的指向原理浅谈 - 掘金 (juejin.cn)

js的函数传参之值传递 - 掘金 (juejin.cn)

js的事件循环机制 - 掘金 (juejin.cn)

从作用域链和内存角度重新理解闭包 - 掘金 (juejin.cn)

js的垃圾回收机制 - 掘金 (juejin.cn)