前端模块化的演变

447 阅读5分钟

什么是前端模块化?它需要满足什么标准呢?

  • 将复杂程序根据规范拆分成若干模块,一个模块包括输入和输出;
  • 模块的内部是私有的,对外暴露接口与其他模块通信;
  • 一个 HTML 可以引用的script包括: 脚本和模块;

脚本 vs 模块

脚本和模块在最大区别是通过模块化拆分以后,我们能更清楚看到整个业务的逻辑,即各个模块是做什么的。如下图所示:

  • getdata.js 是向服务端发送请求获取数据
  • handle.js 处理数据
  • sum.js 处理数据求和的逻辑。

但是如果只有一个 index.js 脚本文件,其实不知道这个文件里面大致的逻辑的。

2.png

注意:模块化是一个标准,不是一个实现,实现是由具体一个具体的语言实现的。比如commonjs是模块化的一个标准,nodejs 实现了 commonjs。

前端模块化的进化过程

全局 function 模式

这种模式是将不同的功能封装成不同的函数。比如,在 index.js 文件中定义三个不同功能的方法。

function api() {
  return {}
}

function handle(data, key) {
  return data.data[key]
}

function sum(a, b) {
  return a + b
}

这个三个方法都是挂载在window的下面,如下图:

image.png

假如其他的同学或者第三方库也在全局下定义了上面的那三个方法,那么就会形成冲突,这样就会导致你的业务不能用,或者别人的业务不能用。

所以,这种模式最大的缺陷就是容易引发全局命名空间冲突

全局 namespace 模式

既然全局 function 模式容易导致命名冲突,那我们把方法不要定义在window下面,而是定义到一个对象下,这样不就好了吗?同时,我们可以把这个对象的名称变得稀有,比如,window.__ moduleApi__, 这样命名冲突的概率就大大下降了。

window.__module = {
  name: 'ming',
  api() {
    return {
      data: {
        a: 1,
        b: 2
      }
  },
  handle(data, key) {
    return data.data[key]
  },
  sum(a, b) {
    return a + b
  }
}

但是在这种模式下,外部能够修改内部的数据,比如我们修改了模块内部的变量 name

const m = window.__module
console.log(m.name) // ming
m.name = 'xiao'
console.log(m.name) // xiao

这与模块化的定义相冲突,模块化的定义要求是模块内部的变量私有化,不能被外部修改,如果能随便被外面修改就丧失了封装性。

IIFE 模式

那如何让模块内的变量私有化,不能被外部修改呢?可以通过函数作用域 + 闭包的方式解决。

function test() {
  let a = 1
}
console.log({ test })

执行上面的代码,打印结果只有 test 函数,是没有 a 这个变量的。这就是 javascript 这门语言天生的一个特性,就是在函数作用域下,它的变量是天生具有私有化性质,外部是拿不到的,这就符合模块内变量私有化的特点。

那如何才能拿到a呢?并能对a的值进行修改呢?这就可以通过闭包来实现。

function test() {
  let a = 1
  return {
    set(v) {
      a = v
    },
    get() {
      return a
    }
  }
}

const handle = test()
console.log(handle.get())  // 1
handle.set(2) 
console.log(handle.get())  // 2

根据闭包的特点,当test函数执行完的时候,里面的变量a是不会被释放的。于是,模块化就进入到第三阶段:IIFE模式,通过自执行函数创建闭包来实现。

;(function () {
  let x = 1
  function api() {
    return {
      data: {
        a: 1,
        b: 2
      }
    }
  }
  function handle(data, key) {
    return data.data[key]
  }
  function sum(a, b) {
    return a + b
  }
  function setX(v) {
    x = v
  }
  function getX() {
    return x
  }
  window.__module = {
    x,
    setX,
    getX,
    api,
    handle,
    sum
  }
})()

const m = window.__module
console.log(m.x) // 1
m.x = 2
console.log(m.x) // 2

为什么 m.x = 2 能赋值成功呢?

我们刚才不是说函数作用域内部的变量是无法被访问和修改的吗?这里就要区分什么是函数作用域内的变量和对象属性的区别。

我们先把m.getX()输出的值打印下:

console.log(m.getX()) // 1

发现m.getX() = 1m是 window 下面挂的__module对象,这个对象下面有一个属性是x,而这个对象m调用getX()获取的x是原函数作用域里面的x,这是两个不同的x

所以,一般不要把函数作用域里面的变量x暴露出来,因为你暴露出来的x仅仅是一个值的拷贝。如果你要更新函数作用域的值,就可以通过m.setX()来更新。

m.setX(10)
console.log(m.getX()) // 10

但是,这种模式无法解决模块间相互依赖的问题,因为模块写出来肯定要相互引用的。

IIFE模式增强

IIFE模式增强,就是要支持传入自定义的模块,完成模块间的引用。首先定义一个api模块,并把模块挂载到 window 下:

;(function (global) {
  function api() {
    return {
      data: {
        a: 1,
        b: 2
      }
    }
  }
  global.__module_Api = { api}
})(window)

接着定义个sum模块,并且引入api模块:

;(function (global, apiModule) {
  function sum(a, b) {
    return a + b
  }
  global.__module = {
    api: apiModule,
    sum
  }
})(window, window.__module_Api)

const m = window.__module

这种模式虽然解决了模块间的依赖问题,但是有很多缺陷:

  1. 多依赖传入时, 代码阅读困难;
  2. 无法支持大规模模块开发,比如某个模块依赖了50个依赖,50个依赖全部通过入参的形式传递进去,这显示是不现实的;
  3. 无特点语法支持,代码简陋;

总结

本文从全局 function 模式容易产生命名冲突问题开始,引出了全局 namespace 模式,把模块封装到一个特定的对象里面,这样就避免了命名的冲突,但是这会带来对象里面的变量容易被外部修改的缺陷。

紧接着利用函数作用域和闭包的特性,采用IIFE函数解决了模块内变量私有化的问题,但是这种模式还没有解决模块间的依赖问题,尽管通过把自定义模块作为参数传入来达到模块间引用,但是这种方式无法支持大规模模块的开发。

针对以上问题,nodejs 采用了 commonjs 模块规范,而 es6 则采用了 esModule 模块规范。下一节将学习 commonjs 。