前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >前端模块化基石:commonJS原理浅析

前端模块化基石:commonJS原理浅析

作者头像
玖柒的小窝
修改2021-10-09 14:20:55
5240
修改2021-10-09 14:20:55
举报
文章被收录于专栏:各类技术文章~各类技术文章~

前言

node是前端工程化不可或缺的工具,其内部使用commonJS规范的模块化解决方案。虽然esModule是未来的趋势,但时代更替还需一段时间,我们先来了解一下commonJS内部的实现原理。

从 module.exports 到 require

假设我们在a文件里要使用b文件的某个变量,一般会这样做。

代码语言:javascript
复制
// a文件
const b = require('./b.js')
console.log(b.someData)
复制代码
代码语言:javascript
复制
// b文件
const someData = 'im b'
module.exports = { someData }
复制代码

思考一下这两个问题

  1. requiremodule是哪来的,是全局上挂的吗
  2. 为啥require一个文件地址,就能取到那个文件导出的exports

一个简单实现

require可以分为两部分,加载器解析器。我们主要讲解析器加载器主要做的就是获取内容字符串,这块儿一笔带过。

加载器

我们可以用fs.readFilefs.readFileSync导入一个js文件,获取该文件内容的字符串。在node提供的require里第一步也是要获取内容字符串,但内部肯定要更复杂。

代码语言:javascript
复制
const fs = require('fs')
const b = fs.readFileSync('./b.js', 'utf-8') 
// 没有第二个参数,会得到一个buffer对象,我们要操作字符串,所以要传入字符编码
console.log(b)
/**
 *  const someData = 'im b'
 *  module.exports = { someData }
 */
复制代码

解析器

接着就要对得到的内容字符串进行操作,不同于noderequire, 我们的这个require接收内容字符串为第二个参数,跳过加载器,就当内容已经加载好了。这里要做一个缓存,如果要导入的模块已经存在于缓存中,则直接从缓存中取出。然后创建一个Module的实例。

代码语言:javascript
复制
const moduleCache = new Map()
function require(pathName, source){
  if(moduleCache.has(pathName)){
    return moduleCache.get(pathName)
  }
  const module = new Module(pathName, source)
  const exports = module.compile()
  // 设置缓存
  moduleCache.set(pathName, exports)
  return exports
}
复制代码

再次说明一下,这些代码只是为了说明原理,不代表真正实现。现在应该能隐约感觉到module.compile里会把内容字符串执行吧。好,接着开始写Module类。constructor没啥好说的,存了一下pathNamesource, 声明了要导出的exports, 并赋值空对象。

代码语言:javascript
复制
class Module{
  constructor(pathName, source){
    this.pathName = pathName
    this.source = source
    this.exports = {}
  }
  ...
}
复制代码

compile里,首先我们要把内容字符串用一个函数包一下。

代码语言:javascript
复制
class Module{
  ...
  compile(){
    const iife = this.getIIFE()
    ...
  }
  getIIFE(){
    return `function(module,exports,require){${this.source}}`
  }
}
复制代码

哦,原来内容字符串被一个匿名函数包裹,而这个匿名函数的形参就有module, 这就回答了开头两个问题中的第一个,module是从哪来的。 接着调用createSandbox, 把这个匿名函数字符串传进去。这个字符串是不是有点像抽干了水份的三体人 😄

代码语言:javascript
复制
class Module{
  ...
  compile(){
    const iife = this.getIIFE()
    const sandboxFunc = this.createSandbox(iife)
    ...
  }
  ...
复制代码

现在我们需要一个沙盒环境,这个沙盒环境要满足2个条件:

  1. iife在执行过程中遇到未定义变量,要禁止它沿着作用域链向上查找。
  2. 指定一个对象,当iife在执行过程中遇到未定义变量,则在这个对象上查找。

那我们看看createSandbox里具体要怎么做。好家伙,第一行就涉及了两个冷门知识点。使用new调用创建一个函数实例,嗯,正常不会这么干。通过这种形式创建的函数在调用时,查找变量会直接在全局上找。相当于是在全局上定义了这个函数。 而使用了with语句后,查找变量时会先在传入with语句的对象上找,没找到再沿着作用域连逐层找到全局。相当于在作用域链的底部加了一个节点。 使用这两个基本没人用的特性,我们是不是能做一个沙箱满足上面的两个条件了。嗯 还不行,还差关键一步。

代码语言:javascript
复制
class Module{
  ...
  createSandbox(iife){
    const func = new Function('sandbox', `with(sandbox){return ${iife}`)
    ...
  }
复制代码

现在做到了最开始在传入with的对象上找,接着在全局上找。而我们想要的是只在传入with的对象上找,没有也不往上找了,在顺手做个白名单功能,在白名单上的允许在全局上找。要做到这点就要请出强大的proxy了。

代码语言:javascript
复制
class Module{
  ...
  createSandbox(iife){
    const func = new Function('sandbox', `with(sandbox){return ${iife}`)
    return function(obj, whiteList){
      const proxyedObj = new Proxy(obj, {
        has(target, key){
          if(!whiteList.includes(key)){
            return true
          }
          return false
        },
        get(target, key, receiver){
          if(key === Symbol.unscopables){
            return void 0
          }
          return Reflect.get(target, key, receiver)
        } 
      }) 
      return func(proxyedObj)
    }
  }
}
复制代码

with底层是通过in操作符来判断要查找的变量是否在传入的对象上的。而proxyhas捕获器就是专门捕获in操作的。 在has捕获器里我们看到,当要查找的变量不在白名单上时返回了true, 这代表在传入with的对象上查找啥,这个对象都会说:“有! 有”,即使这个对象上不存在。这样就把查找行为结束在了这一层。 而要查找的变量在白名单上时,这个对象就说:“这个真没有,去全局找吧”。哪怕这个对象上存在。 get捕获器就好理解了,代理读取属性的行为。Symbol.unscopablesReflect.get又是两个知识点,由于跟主内容关系不大,网上又有很多相关资料,这里就不再赘述了。 这函数一层套一层的,跟俄罗斯套娃似的,我们回到compile里捋一捋。

代码语言:javascript
复制
class Module{
  ...
  compile(){
    const iife = this.getIIFE()
    const sandboxFunc = this.createSandbox(iife)
    ...
  }
  ...
复制代码

sandboxFunc是个函数,长这样。

代码语言:javascript
复制
const sandboxFunc = function(obj){
  with(obj){
    return function(module, exports, require){
      //文件b的内容字符串
      const someData = 'im b'
      module.exports = { someData }
    }
  }
}
复制代码

调用sandboxFunc得到内部函数,最后调用内部函数,返回exports.

代码语言:javascript
复制
class Module{
  ...
  compile(){
    const iife = this.getIIFE()
    const sandboxFunc = this.createSandbox(iife)
    const coreFunc = sandboxFunc({}, ['console']) // 传入一个空对象,供with使用。console添加到白名单。
    coreFunc.call(null, this, this.exports, require)
    return this.exports
  }
  ...
复制代码

回到require里,调用compile返回了exports, 存到缓存中并且return出去。到这里就能回答第二个问题,为啥require一个文件能得到这个文件里的exports了。

代码语言:javascript
复制
const moduleCache = new Map()
function require(pathName, source){
  if(moduleCache.has(pathName)){
    return moduleCache.get(pathName)
  }
  const module = new Module(pathName, source)
  const exports = module.compile()
  // 设置缓存
  moduleCache.set(pathName, exports)
  return exports
}
复制代码

跑一下试试,可以看到模块正常导出了。

代码语言:javascript
复制
const source = `
  const someData = 'im b'
  module.exports = {someData}
`
console.log(require('./b.js', source))
// {someData:'im b'}
复制代码

结语

如果了解过ATS, 或者看过webpack的一些loader,plugin的源码,那这篇文章对你来说应该比较easy. 这中间穿插的一些小知识比如with, new Function(), Symbol.unscopables, Reflect等还是比较有趣,值得了解的。

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系转载前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 从 module.exports 到 require
  • 一个简单实现
    • 加载器
      • 解析器
      • 结语
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档