nodejs模块化cjs/esm区别与双模块机制详解

979 阅读7分钟

nodejs模块化cjs/esm区别与双模块机制详解

知识点

  • 适合人群:对模块化规范已有一定对了解

    • 对nodejs双模块感兴趣
    • 对双包机制感兴趣
    • 希望开发第三方库
  • nodejs模块化,ejs(commonJS模块化规范)和esm(ES Module模块化规范)的区别对比

    • 这两种模块化规范网上充斥着大量教程,本文只做简单的使用
    • 两种模块化规范对比
  • nodejs作为第三方包时,同时包含双模块

    • 双模块使用
    • 双模块的问题
    • 双模块的解决方案

cjs(CommonJS)

  • commonjs模块化规范:运行时加载,简单来说就是在js运行阶段(v8执行js时)通过同步的方法进行加载
  • 1-cjs.cjs
module.exports = {
  title: "博客",
  author: {
    uname: "xqv",
  },
};
​
// 等价于上面的exports
/* exports.title = "博客";
exports.author = {
  uname: "xqv",
}; */
  • 1-main.cjs
const cjs = require("./1-cjs.cjs");
console.log(cjs); // { title: '博客', author: { uname: 'xqv' } }
// console.log(module.exports === exports); // true
// console.log(module.exports === this); // true

这里大家需要清楚module.exportexports以及this实际上是一个引用,在nodejs require()源码中使用vm模块对引入的文件(1-cjs.cjs)进行同步加载后,根据导出内容作为返回结果,并将该内容放入缓存中;

注意要点:

  • require是同步操作
  • require内部源码利用vm原生模块的runInThisContext()方法执行(类似eval,只是具有明确上下文,不污染全局)并最终会拿到导出的内容
  • require导入过的内容都会进行缓存
  • 缓存作用一:便于二次加载
  • 缓存作用二:解决cjs循环引用问题

esm(ES Module)

  • ECMA TC39模块化官方规范

  • 基本使用

    • 2-mjs.mjs
    // 命名导出
    export default {
      title: "标题",
      author: {
        uname: "xqv",
      },
    };
    ​
    // 具名导出
    /* export const title = "标题";
    export const author = {
      author: {
        uname: "xqv",
      },
    }; */
    
    • 2-main.mjs
    // 命名导出
    import mjs from "./2-mjs.mjs";
    console.log(mjs); // { title: '标题', author: { uname: 'xqv' } }
    ​
    // 具名导出
    // import { title, author } from "./2-mjs.mjs";
    // console.log(title, author); // 标题 { author: { uname: 'xqv' } }
    

cjs与esm区别

结论

  • esm可以引入cjs模块,但cjs模块无法直接通过require esm模块(直接导入会报错)
  • esm下的export才是引用,而cjs和esm的export default都是值拷贝
  • esm和cjs运行时期不一致,esm在编译期(当然也有import()运行时导入,这里不做讨论),cjs在运行时且同步
  • esm的this是undefined,cjs的this是exports是module.exports(默认{})
  • esm编译器执行所以import导入有置顶效果
  • esm可以做到tree sharking(清理未使用的多余代码,特别是对前端优化很有用,基本上webpack等打包工具利用了该特性)

在nodejs中还有更多的差异,这里只做主要的区别讲解,详细参考官方文档:nodejs官方文档:esm与cjs在nodejs中差异

cjs与esm之间互导

  • esm导入cjs
  • 1-module.cjs
let title = "博客";
const author = {
  uname: "xiaoqinvar.",
};
function update() {
  title = "update title.";
  author.uname = "aka. xqv yo.";
}
module.exports = {
  title,
  author,
  update,
};
  • 1-main.mjs
import m from "./1-module.cjs";
console.log(m);
/*
{
  title: '博客',
  author: { uname: 'xiaoqinvar.' },
  update: [Function: update]
}
*/
  • 其中官网有一句话要注意:

    • When importing CommonJS modules, the module.exports object is provided as the default export
    • 当导入cjs模块时,module.exports是以export default形式导出的
    • 🥕这正是export default是值拷贝,export是值引用的原因

esm可以直接导入cjs模块,但是是作为export default导出

代码:Github代码片段

  • cjs导入esm
  • 2-module.mjs
let title = "博客";
const author = {
  uname: "xiaoqinvar.",
};
function update() {
  title = "update title.";
  author.uname = "aka. xqv yo.";
}
export default {
  title,
  author,
  update,
};
  • 2-main.cjs
// ❌ ERR_REQUIRE_ESM
// const m = require("./2-module.mjs");// ✅
import("./2-module.mjs").then((m) => {
  console.log(m);
  /*
    [Module: null prototype] {
      default: {
        title: '博客',
        author: { uname: 'xiaoqinvar.' },
        update: [Function: update]
      }
    }
  */
});

无法使用默认require()导入,可以使用import()动态导入

代码:Github代码片段

cjs值拷贝与esm值引用

  • esm的export是值引用(注意是export)
  • 3-export.mjs
let title = "博客";
const author = {
  uname: "xiaoqinvar.",
};
function update() {
  title = "update title.";
  author.uname = "aka. xqv yo.";
}
export { title, author, update };
  • 3-import.mjs
import { title, author, update } from "./3-export.mjs";
console.log(title, author); // 博客 { uname: 'xiaoqinvar.' }
update();
console.log(title, author); // update title. { uname: 'aka. xqv yo.' }
  • cjs值拷贝
  • 3-module.export.cjs
let title = "博客";
const author = {
  uname: "xiaoqinvar.",
};
function update() {
  title = "update title.";
  author.uname = "aka. xqv yo.";
}
module.exports = { title, author, update };
  • 3-require.cjs
const m = require("./3-module.export.cjs");
console.log(m.title, m.author); // 博客 { uname: 'xiaoqinvar.' }
m.update();
console.log(m.title, m.author); // 博客 { uname: 'xiaoqinvar.' }
  • cjs值拷贝内存图分析

cjs导出为值拷贝内存图.excalidraw

  • esm的export default是值的拷贝等价于module.export效果
  • 3-export-default.mjs
let title = "博客";
const author = {
  uname: "xiaoqinvar.",
};
function update() {
  title = "update title.";
  author.uname = "aka. xqv yo.";
}
export default { title, author, update };
  • 3-import.mjs
import m from "./3-export-default.mjs";
const { title, author, update } = m;
console.log(title, author); // 博客 { uname: 'xiaoqinvar.' }
update();
console.log(title, author); // 博客 { uname: 'aka. xqv yo.' }

注意这里title字段是值拷贝,所以没有变,走的cjs那一套

js后缀mjs和cjs的含义

  • 以文件名后缀确定模块环境(权重最高🚀)

    • cjs:遵循cjs模块化规范
    • mjs:遵循esm模块化规范
  • 项目package.json确定模块环境:最近的package.json文件包含值为"module": "type"字段时,扩展名为.js的文件为mjs规范,反之cjs规范

{
  "type": "module"
}

确定为一种模块化后,对于另一种模块化就不可以乱用了;比如:main.cjs你就不能import x from "..."

双包机制: "exports"字段

  • 在node 12-,之前都是"main"字段决定了唯一的入口,如果是一个前后端都可以的运行时,如loadsh库,就需要解决cjs规范和esm的双包导出问题;之前采用的是"main"作为cjs入口,"module"作为esm入口,因为package.json根本没有"module"字段,所以该字段会被node忽略,而打包工具如webpack会依赖该字段作为项目的入口

参考:nodejs官方文档:cjs/esm双包机制

  • 问题点:当时nodejs没办法一个包同时提供多个模块规范的入口,这正是"exports"要解决的问题,双包问题!
  • 基本使用:这里只做简单讲解,这玩意儿规则还是蛮多的,有兴趣或者碰到了查看官方文档即可
// package.json
{
  "type": "module", // 该项目默认js走esm规范
  "exports": {
    "import": "./index-module.js", // esm导入这个入口文件
    "require": "./index-require.cjs", // cjs...
    "default": "./index-require.cjs", // 兜底用
    // "node": "..." 匹配任何 Node.js 环境
    // 规则还是比较多的,有兴趣可以查看官方文档
  }
}

双包机制的危害与注意事项

双包危害:引入cjs规范和esm规范返回的是不同的东西,简单来讲如果我们写的包会创建一个对象的话,cjs创建了一个对象,esm也会创建一个对象,这样就是多例问题了

官方文档:nodejs官方文档:双包危害

  • 场景一:此包使用cjs编写,但是不想用esm重构,但希望能提供给esm用户使用
  • 优点:简单、兼容、单例
  • 缺点:cjs规则值拷贝问题(这里可以导出一个构造函数,通过创建堆内存对象来解决值拷贝的问题)
  • 4-core.cjs
let title = "博客";
const author = {
  uname: "xiaoqinvar.",
};
function update() {
  title = "update title.";
  author.uname = "aka. xqv yo.";
}
module.exports = { title, author, update };
  • 4-esm.mjs
import core from "./4-core.cjs"; // esm拿cjs走
export default core;
  • 4-main.mjs
import cjs from "./4-core.cjs";
import esm from "./4-esm.mjs";
console.log(cjs === esm); // true
console.log("cjs", cjs.title, esm.author); // cjs 博客 { uname: 'xiaoqinvar.' }
console.log("esm", esm.title, esm.author); // esm 博客 { uname: 'xiaoqinvar.' }
cjs.update();
console.log("cjs", cjs.title, esm.author); // cjs 博客 { uname: 'aka. xqv yo.' }
console.log("esm", esm.title, esm.author); // esm 博客 { uname: 'aka. xqv yo.' }

这里因为源码是cjs规则,所以整条链路下来都是走cjs规则的,cjs本身就是值拷贝所以title字段没变,而author引用类型自然会变

单例模式导出在Github仓库代码:看"5-*"开头的代码文件

官方减少双包危害(我觉得它这里说的特别稀里糊涂,还是要自己多思考一下):nodejs官方文档:减小双包危害

参考