前端模块化

305 阅读6分钟

目标

  • 了解模块化的历史
  • 现代模块化规范
  • 手写简易实现

要点

模块化的历史

1. 什么是模块?

  • 块的内部数据与实现是私有的, 并向外部暴露一些接口(方法)与外部其它模块通信

  • 将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起

  • 本质上模块就是一种提供对外通信接口,进行代码切分/组合的管理方式。其呈现的方式因不同的模块化方案而不同,基本是以文件粒度区分。

2. 为什么要用模块化?

  • 随着前端的发展以及前后端的逐渐分离,也随着前端的能力在纵深都得到增强之后,市场决定了需要更好的代码管理、组织、通信的模式,也就伴随着各种模块化的技术模式。

  • 项目越来越大,拆分成多个模块可以降低复杂度,还可以更优雅的代码管理

  • 模块化便于多人协同开发,面向过程开发

  • 可以实现单一模块的独立发布-和微服务的思想更加match

3. 模块化发展历史

3.1 前期模块化处于混沌时期,组织代码就是靠「经验」基本就是全局function模式 : 将不同的功能封装成不同的全局函数

缺点:污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系

function fn () {};

3.2 命名空间 - namespace模式 : 简单对象封装

缺点:数据不安全(外部可以直接修改模块内部的数据)

var student = {
  name: 'tom',
  getScore: function() {} 
}

student.name;
student.getScore();

3.3 IIFE模式:匿名函数自调用(闭包)

  • 优点:模块化的解决方案再次提升,利用闭包使得污染的问题得到解决,更加纯粹的内聚。
  • 缺点:老司机🛴已经打开了度娘 如果当前这个模块依赖另一个模块怎么办?
// moduleA.js
(function(global) {
  var name = 'tom';
  function getScore() {};
  // 把模块挂载全局变量并暴漏出去
  global.moduleA = { name, getScrore };
})(window)

基于 IIFE 还有很多玩儿法,也是现代模块实现的基石 🛠

4. 总的来说,现代模块化机制要解决的问题如下:

  1. 命名污染,全局污染,变量冲突等基础问题
  2. 内聚且私有,变量不能被外界污染到
  3. 怎么引入(依赖)其它模块,怎样暴露出接口给其它模块
  4. 最烦人的是依赖顺序问题,老司机还记得以前的 Jquery 问题吗😩
  5. 引入模块可能导致循环引用的问题以及其他一些边界情况

模块化规范

简要说明,详解请找度娘娘

微信截图111.png

- CommonJS

Node.js采用 CommonJS 模块规范,加载模块是同步的 采用module.exports向外暴漏模块 采用require(xxx)引入模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径

// moduleA.js
let a = 5;
const add = function (value) {
  return value + a;
};
module.exports.a = a;
module.exports.add = add;
const moduleA = require('./moduleA.js');
console.log(moduleA.a); // 5
console.log(moduleA.add(1)); // 6

- AMD

  • AMD规范是异步加载模块,允许指定回调函数
  • AMD 的代表肯定就是大名鼎鼎的 RequireJS
  • 模块前置:顾名思义,就是首先引入需要的模块,类似jquery,但是优点是不用像jquery要严格按照引入顺序,否则会报错。 暴漏模块
define(['moduleA'], function(_) {
  console.log('moduleA load');

  return {
    str: function() {
      console.log('moduleA run');
      // 业务实现...
      return _.repeat('>>>>>>>>>>', 20)
    }
  }
})

引入模块

require(['moduleA'], function(moduleA){
   // 使用moduleA 的变量和方法
   console.log(moduleA.str())
})

优质程序员:请问 define和 require为什么可以直接使用,有点好奇? ok, 盘它⛏⛏⛏

const def = new Map();

// 定义模块,触发的时机是在 require 的时候,所以先依赖 -> 收集
define = (name, deps, factory) => {
   /**
   * name 模块名
   * deps 依赖
   * factory 回调函数
   */
  def.set(name, { name, deps, factory });
}
// 触发加载依赖
require = (deps, factory) => {
  return new Promise((resolve, reject) => {
    Promise.all(deps.map(dep => {
    // __load方法此处省略,老司机都知道是新建一个script标签然后放到head中
      return __load(dep).then(() => {
        const { deps, factory } = def.get(dep);
        if (deps.length === 0) return factory(null);
        // 递归处理模块中嵌套引入子模块
        return require(deps, factory)
      })
    })).then(resolve, reject)
  })
  .then(instances => factory(...instances))
}

看到这儿还不明白AMD如何实现的call我上门服务☎

- CMD

CMD规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。CMD规范整合了CommonJS和AMD规范的特点。在 Sea.js 中,所有 JavaScript 模块都遵循 CMD模块定义规范。

不啰嗦直接上例子,想看啰嗦的可以去找CSDN、博客园、segmentfault...

// sea.js
// 回调函数有三个参数 require, exports, module
define('a', function (require, exports, module) {
  console.log('a load')
  exports.run = function () { console.log('a run') }
})

define('b', function (require, exports, module) {
  console.log('b load')
  exports.run = function () { console.log('b run') }
})

define('main', function (require, exports, module) {
  console.log('main run')
  // cmd本质就是按需加载,也就是网上的说法依赖后置也好,依赖就近也好
  // 需要的时候才引入模块
  var a = require('a')
  a.run()
  var b = require('b')
  b.run()
})

sj.use('main')
// main run
// a load
// a run
// b load
// b run

> 这个use哪里来的,客官别急

// 使用正则表达式获取 require的所有模块
const getDepsFromFn = (fn) => {
  let matches = [];
  // require('a ')
  //1. (?:require\() -> require(  -> (?:) 非捕获性分组
  //2. (?:['"]) -> require('
  //3. ([^'"]+) -> a -> 避免回溯 -> 回溯 状态机
  let reg = /(?:require\()(?:['"])([^'"]+)/g; // RegExp
  let r = null;
  while((r = reg.exec(fn.toString())) !== null) {
    reg.lastIndex
    matches.push(r[1])
  }
  return matches
}

// define 提取依赖
const define = (id, factory) => {
  const url = toUrl(id); // url是解析后模块的路径
  const deps = getDepsFromFn(factory); // 获取require的所有模块
  if (!modules[id]) {
    modules[id] = { url, id, factory, deps }
  }
}

const __exports = (id) => exports[id] || (exports[id] = {});
const __module = this;

// __require 引入并加载模块
const __require = (id) => {
   // 这里的id是依赖的标识 比如require('a')  id 就是 a
   // _load方法省略,就是在head中新建一个script标签 src引入对应的依赖
  return __load(id).then(() => {
    // 加载之后
    const { factory, deps } = modules[id];
    if (!deps || deps.length === 0) {
      // define的回调函数,三个参数 require, exports, module
      factory(__require, __exports(id), __module);
      return __exports(id);
    }

    return sj.use(deps, factory);
  })
}

// 实现__require
sj.use = (modules, callback) => {
  mods = Array.isArray(modules) ? modules : [modules];
  return new Promise((resolve, reject) => {
    Promise.all(modules.map(module => {
    // _load方法省略,就是在head中新建一个script标签 src引入对应的依赖
      return __load(module).then(() => {
        const { factory } = modules[mod];
        return factory(__require, __exports(modules), __module)
      })
    })).then(resolve, reject)
  }).then(instances => callback && callback(...instances))
}

同上~看不明白CMD如何实现的call我上门服务☎

- ES6模块化

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。

// export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
// add.js
let num = 0;
const add = function (a, b) {
    return a + b;
};
export { num, add };
/** 引用模块 **/
import { num, add } from './add';
console.log(add(10 + num))

import export 底层实现等我总结之后再来补充,未来可期 😁😁

补充知识点

CommonJS 模块输出的是一个值的拷贝

  • CommonJS
// a.js
let name = 'morrain'
let age = 18
exports.name = name
exports.age = age
exports.setAge = function(a){
    age = a
}
// b.js
var a = require('a.js')
console.log(a.age) // 18
a.setAge(19)
console.log(a.age) // 18

a.js 中,exports 被赋值为一个对象(暂称为导出对象),而导出对象的 age 属性源自 age 变量,由于 age 变量是数值类型,属于 js 的基本类型之一,是按值传递的,所以 age 属性得到的只是 age 变量的拷贝值,也就是说从赋值之后开始 age 变量的任何变化都与导出对象的 count 属性毫无关系

  • ES6 ES6 模块输出的是值的引用
// a.js
let name = 'xuSir君'
let age = 18
exports.name = name
exports.getAge = function(){
    return age
}
// b.js
var a = import('a.js')
console.log(a.name) // 'xuSir君'
a.name = 'rename'
var b = import('a.js')
console.log(b.name) // 'rename'

与 Commonjs 提供的导出规范不同,ES module 模块导出是值的引用,在原始值改变时 import 的加载值也会随之变化。

欢迎纠错🎈🎈🎈