commonjs修改模块中值后外部模块能不能读到变化后的值

586 阅读5分钟

1. commonjs为啥模块导出的基本数据类型修改后不变,引用类型修改后改变

counter.js

let count = 1;

let obj = {
    count: 1
}

function increment () {
  count++;
  obj.count++;
}

module.exports = {
  count,
  obj,
  increment
}

main.js

const counter = require('./counter.js');

counter.increment();

console.log(counter.count); // 1
console.log(counter.obj.count); // 2
  • 模块要导出的count是number基本类型值、test是一个引用类型的值
  • 模块要导出的increment是一个方法,可以修改模块要导出供别人使用的值count和obj的count属性
  • main.js中调用require加载counter.js后,就会生成模块的缓存,缓存其实不会重新读取js文件,直接返回内存中的module.exports对象
  • 如果模块中导出的某个变量是基本类型变量如count,那么module.exports对象会对count进行值的拷贝,也就是内存中重新开辟一块内存,把count值的拷贝过来
  • counter.increment()调用之后修改的counter.js模块的内部count变量的值,不会修改module.exports对象的count变量的值,因为commonjs没有living binding的功能;这也就是ESModule改进的点,让模块的值的变化能让其他模块感知到,读取到最新的值
  • commonjs没有living binding的功能,为啥obj.count就变了呢?因为commonjs如果导出引用数据类型时会进行浅拷贝该对象;module.exports中的obj和模块内部的obj指向了同一份内存,所以及时在模块内部修改obj的count,也能反应到module.exports.obj上

2. commonjs通过require后的模块修改基本数据类型后为啥值就变化了

counter.js

let count = 1;

let obj = {
    count: 1
}

function increment () {
  count++;
  obj.count++;
}

module.exports = {
  count,
  obj,
  increment
}

main.js

const counter = require('./counter.js');

// counter.increment();
counter.count++;
counter.obj.count++;

console.log(counter.count); // 2
console.log(counter.obj.count); // 2
  • main.js模块中counter的count和obj的count都改变,很好理解,因为改的就是counter的变量的值
  • 为啥下面main2.js中的值也会变化呢?

main2.js

require('./main.js')
const counter = require('./counter.js');

console.log(counter.count); // 2
console.log(counter.obj.count); // 2
  • require('./counter.js')会从缓存中直接读取模块,返回的是仅有的一份module.exports对象,只要该模块被加载过一次,module.exports对象每次就取到的是一个对象;
  • module.exports对象每取到和main.js的counter是一个对象,所以值就改变了
  • 如果main.js中还是通过counter.increment();就拿不到最新的counter值了,因为没改module.exports对象中的counter

3. commonjs通过require后的模块解构后为啥不能修改基本数据类型的变量

main3.js

const {count, obj} = require('./counter.js');

count++; // TypeError: Assignment to constant variable.
obj.count++;

console.log(count); 
console.log(obj.count); // 2
  • require的 {count, obj} 是真正的结构,ESModule不是解构
  • 解构后count是一个const类型的值了,所以不能改
  • 解构后修改obj.count仍然是修改module.exports对象中的obj,因为是同一份引用

4.commonjs模块中直接module.exports一个变量的情况

const.js

var a = 1;

module.exports = a;

const-test.js

let a = require("./const.js");

a++;

console.log(a)
  • 能直接导出变量,不用加{}
  • 如果⽤户导出的是⼀个变量,后续变量被修改了,是否会影响导⼊的值?
// exportsA.js
let a = 0
setInterval(() => {
  a++
}, 1000)
module.exports = a // 如果导出的是引⽤类型则会产⽣变化。
// useExportsA.js
setInterval(() => {
  let a = require("./exportsA")
  //多次require拿到是第一个,第一个值如果是对象,对象中的内容变化了可以得到更新,如果是具体的值 则不会变化
  console.log(a) // require会缓存上次导⼊的结果。
}, 1000)
  • 和上面1里面的情况一样,修改模块中基本类型变量,不会living bing到已经生成的module.exports上;除非直接改module.exports

5. commonjs模块的模拟实现

简化的 require

// 1) 内部采用了同步读取的方案 ,将文件内容获取到。 require方法是同步的。 底层用的是同步的读取 fs.readFileSync
function require(文件名) {
  // 根据文件名获取内容,包装函数,并且把获取到的结果放到module.exports
  let module = {
    exports: "",
  }
  ;(function (module, exports) {
    module.exports = "结果"
  })(module, module.exports)
  return module.exports
}

const fs = require("fs")
const path = require("path")
const vm = require("vm")
function Module(id) {
  this.id = id
  this.exports = {} // 模块最终的导出的结果都在这里面
}
Module._cache = {} // 模块缓存
Module._extensions = {
  ".js"(module) {
    const content = fs.readFileSync(module.id, "utf8")
    // 如何将字符串包装成函数, 可以用new Function来直接实现
    // console.log(new Function('a','b','c',content).toString())
    // eval 找的是上层作用域 可以直接将代码放到这个eval转成成js
    // 支持沙箱 可以保证作用域不污染,可以指定函数中的this,手动指定上下文
    // 快照, 通过proxy来实现沙箱
    const wrapperFunction = vm.compileFunction(content, [
      "module",
      "exports",
      "require",
      "__dirname",
      "__filename",
    ])

    let require = req
    let __dirname = path.dirname(module.id) // 文件对应的目录
    let __filename = module.id // 绝对路径
    Reflect.apply(wrapperFunction, exports, [
      module,
      exports,
      require,
      __dirname,
      __filename,
    ])
  },
  ".json"(module) {
    // json如何处理
    const content = fs.readFileSync(module.id, "utf8")
    module.exports = JSON.parse(content)
  },
}
Module._resolveFilename = function (id) {
  // 默认会查找同名的文件,会尝试添加后缀
  const exts = Reflect.ownKeys(Module._extensions)
  let isExisits = fs.existsSync(id) // 不会抛错 fs.access 需要用tryCatch
  if (isExisits) return path.resolve(__dirname, id)
  // 先查找js在查找json
  for (let i = 0; i < exts.length; i++) {
    let fileUrl = path.resolve(__dirname, id) + exts[i]
    if (fs.existsSync(fileUrl)) {
      return fileUrl
    }
  }
  throw new Error("模块未找到")
}
Module.prototype.load = function () {
  const ext = path.extname(this.id) // a.min".js"
  Module._extensions[ext](this) // 根据后缀名来处理对应的模块
}
function req(id) {
  // 1.根据用户传递的id 来进行模块的加载,相对路径转换成绝对路径
  let absPath = Module._resolveFilename(id)
  // 2.创建模块
  let existsModule = Module._cache[absPath] // 是否存在这个模块
  if (existsModule) {
    return existsModule.exports // 返回上一次导出的结果
  }
  const module = new Module(absPath) // 如果我多次require模块这个模块只会被读取一次
  Module._cache[absPath] = module
  // 3.就是加载这个模块
  module.load() // 加载完模块后既可以拿到最终的模块导出结果
  return module.exports
}

const result = req("./b.json")
console.log(result)

基本实现

    1. Module._load 加载这个模块
    1. Module._resolveFilename( 处理路径为绝对路径, 并且添加文件后缀
    1. 拿到文件 看一下文件是否加载过 Module._cache 是否缓存过,如果缓存过则直接结束
    1. 如果没有缓存过 则会new Module(id,exports = {}) exports 是对应模块的导出结果,默认为空
    1. 将创建的模块缓存
    1. 根据文件加载模块 (给module.exports 赋值)
    1. 找到对应的文件后缀 做加载操作 Module._extensions[.js](this, filename); 策略模式
    1. 读取文件内容 fs.readFileSync(filename, 'utf8');
    1. 将字符串执行 module._compile 编译字符串
    1. 包裹函数 'exports','require','module','__filename', '__dirname', module.exports = exports this = exports
    1. Reflect.apply(this,[exports,require,module,filename,path.dirname]) module.exports = 'abc' 最终返回的是 module.exports