前端模块化演进之路

183 阅读6分钟

前言

时至今日,模块化已然成为了前端标配的功能,可大家是否有曾深入思考过如下问题?

  1. 到底什么是模块化?
  2. 为什么需要模块化?
  3. 怎么去实现模块化?

下面我们就从模块化的演进角度去尝试解释这三个问题。

认识示例

在本文中我们会使用到一个动态生成列表的简单应用来作为贯穿全文的示例,所以在正式开始本文之前,让我们先来熟悉以下这些代码:

index.html
此页面最终会输出一个带标题的列表。

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>Module example</title>
  </head>
  <body></body>
</html>

createList.js 此代码接收一个文本数组,并将其转换成列表。

function createList(arr) {
  var ulWrapper = document.createElement('ul');
  document.body.appendChild(ulWrapper);

  for (let i = 0; i < arr.length; i++) {
    const text = arr[i];
    var liWrapper = document.createElement('li');
    liWrapper.innerText = text;
    ulWrapper.appendChild(liWrapper);
  }
}

createTitle.js 此代码接收一段文本,并将其转换成标题。

function createTitle(text) {
  var hWrapper = document.createElement('h1');
  hWrapper.innerText = text;
  document.body.appendChild(hWrapper);
}

controller.js 此代码对数据源(限stringorstring[])做简单分流。

function controller(data) {
  if (Object.prototype.toString.call(data) === '[object Array]') {
    createList(data);
  } else {
    createTitle(data);
  }
}

main.js 定义变量、执行代码的主逻辑

var title = '标题';
var content = ['内容1', '内容2', '内容3'];
controller(title);
controller(content);

演进之路

准备好。下面就让我们回到前端发展的源头,重新经历一次前端模块化的演进历程。

内联写法

首先是最原始、最简单粗暴的方式,直接在<script>标签书写 js 代码:

index.html

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>Module example</title>
  </head>
  <body>
    <script>
      function createList(arr) {
        var ulWrapper = document.createElement('ul');
        document.body.appendChild(ulWrapper);

        for (let i = 0; i < arr.length; i++) {
          const text = arr[i];
          var liWrapper = document.createElement('li');
          liWrapper.innerText = text;
          ulWrapper.appendChild(liWrapper);
        }
      }

      function createTitle(text) {
        var hWrapper = document.createElement('h1');
        hWrapper.innerText = text;
        document.body.appendChild(hWrapper);
      }

      function controller(data) {
        if (Object.prototype.toString.call(data) === '[object Array]') {
          createList(data);
        } else {
          createTitle(data);
        }
      }

      var title = '标题';
      var content = ['内容1', '内容2', '内容3'];
      controller(title);
      controller(content);
    </script>
  </body>
</html>

这种形式易于理解,代码也一目了然,但问题在于:

  1. 复用性差:如果别的页面想要重用这些代码,只能机械地复制粘贴;
  2. 书写顺序严格:所有代码必须按照其引用顺序严格书写,灵活性差;
  3. 容易发生冲突:所有变量及函数都会被存储在全局,容易发生冲突。

标签引用

首先为了解决逻辑复用性差的问题,我们可以将逻辑函数单独提取为 js 文件,只需要在需要时通过<script>标签引用即可:

index.html

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>Module example</title>
  </head>
  <body>
    <script src="./createList.js"></script>
    <script src="./createTitle.js"></script>
    <script src="./controller.js"></script>
    <script src="./main.js"></script>
  </body>
</html>

createList.js

function createList(arr) {
  var ulWrapper = document.createElement('ul');
  document.body.appendChild(ulWrapper);

  for (let i = 0; i < arr.length; i++) {
    const text = arr[i];
    var liWrapper = document.createElement('li');
    liWrapper.innerText = text;
    ulWrapper.appendChild(liWrapper);
  }
}

createTitle.js

function createTitle(text) {
  var hWrapper = document.createElement('h1');
  hWrapper.innerText = text;
  document.body.appendChild(hWrapper);
}

controller.js

function controller(data) {
  if (Object.prototype.toString.call(data) === '[object Array]') {
    createList(data);
  } else {
    createTitle(data);
  }
}

main.js

var title = '标题';
var content = ['内容1', '内容2', '内容3'];
controller(title);
controller(content);

诚然,此种方式较之第一种方式在复用性上确实有了巨大的提升。但部分问题仍然存在:

  1. 引用顺序严格:代码引用顺序依然需要严格保证;
  2. 容易发生冲突:所有变量及函数依然存储在全局。

同时也带了新的问题:
3. 内部依赖关系不可见:比如在main.js中完全无法知晓其使用到的方法的来源。

简单命名空间

为了解决全局变量冲突的问题,我们可以采用对象包裹来一定程度上减少了全局变量冲突的可能性:

index.html

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>简单命名空间</title>
  </head>
  <body>
    <script src="./creator.js"></script>
    <script src="./main.js"></script>
  </body>
</html>

在这里我们简单演示一下这个逻辑,将关联性比较高的createListcreateTitle方法统一封装在自定义方法myCreator中:

creator.js

window.myCreator = {
  createList(arr) {
    var ulWrapper = document.createElement('ul');
    document.body.appendChild(ulWrapper);

    for (let i = 0; i < arr.length; i++) {
      const text = arr[i];
      var liWrapper = document.createElement('li');
      liWrapper.innerText = text;
      ulWrapper.appendChild(liWrapper);
    }
  },

  createTitle(text) {
    var hWrapper = document.createElement('h1');
    hWrapper.innerText = text;
    document.body.appendChild(hWrapper);
  },

  controller(data) {
    if (Object.prototype.toString.call(data) === '[object Array]') {
      myCreator.createList(data);
    } else {
      myCreator.createTitle(data);
    }
  },
};

main.js

window.myData = {
  title: '标题',
  content: ['内容1', '内容2', '内容3'],
};
myCreator.controller(myData.title);
myCreator.controller(myData.content);

这种形式隐约有了模块化的雏形,但在这种方式下:

  1. 全局命名冲突依然存在:全局命名冲突的概率只是降低了(因为相比原先,暴露在全局的变量变少了而已);
  2. 成员函数并非私有:通过类似myCreator.createList = null这样的代码影响到原有的逻辑。
  3. 仍需确保引用顺序

所以这种尝试并不是一个完美的方案。

模拟私有环境

继续对上一阶段进行改造,我们利用 IIFE 将createListcreateTitle作为私有变量,可以一定程度上避免代码被破坏:

index.html

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>IIFE模拟私有空间</title>
  </head>
  <body>
    <script src="./creator.js"></script>
    <script src="./main.js"></script>
  </body>
</html>

creator.js

(function () {
  function createList(arr) {
    var ulWrapper = document.createElement('ul');
    document.body.appendChild(ulWrapper);

    for (let i = 0; i < arr.length; i++) {
      const text = arr[i];
      var liWrapper = document.createElement('li');
      liWrapper.innerText = text;
      ulWrapper.appendChild(liWrapper);
    }
  }

  function createTitle(text) {
    var hWrapper = document.createElement('h1');
    hWrapper.innerText = text;
    document.body.appendChild(hWrapper);
  }

  window.myCreator = {
    controller(data) {
      if (Object.prototype.toString.call(data) === '[object Array]') {
        createList(data);
      } else {
        createTitle(data);
      }
    },
  };
})();

main.js

window.myData = {
  title: '标题',
  content: ['内容1', '内容2', '内容3'],
};

myCreator.controller(myData.title);
myCreator.controller(myData.content);

经过 IIFE 封装后,createListcreateTitle已不再全局可见,一定程度上保证了其安全性。但它只能算是简单命名空间的改良版,骨子里存在的问题还是没有解决问题:

  1. 全局命名冲突依然存在:全局命名冲突的概率只是降低了(因为相比原先,暴露在全局的变量变少了而已);
  2. 仍需确保引用顺序

CommonJS

首先要说明的是,CommonJS 不是一种方法。CommonJS 最早叫做 ServerJS,是类似于 ECMA 一样的 JS 标准化规范,其主要侧重于服务端。 在服务端没有<script>标签,自然也就缺乏引用 JS 的方式,所以 CommonJS 定义了适用于服务端的 modules 引用方式:

createList.js

module.exports = function createList(arr) {
  for (let i = 0; i < arr.length; i++) {
    const text = arr[i];
    console.log(`li: ${text}`);
  }
};

createTitle.js

module.exports = function createTitle(text) {
  console.log(`title: ${text}`);
};

controller.js

var createList = require('./createList');
var createTitle = require('./createTitle');

module.exports = function controller(data) {
  if (Object.prototype.toString.call(data) === '[object Array]') {
    createList(data);
  } else {
    createTitle(data);
  }
};

main.js

var controller = require('./controller');

var title = '标题';
var content = ['内容1', '内容2', '内容3'];

controller(title);
controller(content);

CommonJS 主要使用module.exportsrequire来完成导入导出动作,其每个文件就是一个单独的模块,内部作用域独立。但这套规范也有一些缺陷:

  1. 对于客户端来说很大的一个问题在于,导入模块的动作是同步进行的,如果客户端需要一次性加载多个模块时,就会造成大量同步请求效率低下的问题;
  2. 每个文件只允许导出一个模块

另外说一句,如果想在浏览器端使用 CommonJS 的话也不是完全不可行,可以通过Browserify这样的打包工具先对使用 CommonJS 规范的文件进行 AST 解析,在转换相应语法后生成一个符合浏览器规范的bundle,将这个bundle引入 html 文件中即可正常执行。

AMD

AMD 是 Asynchronous Module Definition 的简写,其同样也是一种规范,可以看做 CommonJS 的改良版。AMD 定义了define关键字用于声明模块:

  define(id?, dependencies?, factory);
  • id 定义模块文件的标识,默认是文件名。
  • dependencies 数组定义当前模块所依赖的其他模块。
  • factory 函数最终返回的就是需要导出的内容。

AMD 的规范实现不得不提到 requireJs,这是一个可以做到异步加载模块的库。他在被<script>标签引用时会利用自定义属性data-main来指定入口文件:

index.html

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>AMD</title>
  </head>
  <body>
    <script
      src="https://cdn.jsdelivr.net/npm/requirejs@2.3.6/require.js"
      data-main="./main.js"
    ></script>
  </body>
</html>

createList.js

define(function () {
  return function (arr) {
    var ulWrapper = document.createElement('ul');
    document.body.appendChild(ulWrapper);

    for (let i = 0; i < arr.length; i++) {
      const text = arr[i];
      var liWrapper = document.createElement('li');
      liWrapper.innerText = text;
      ulWrapper.appendChild(liWrapper);
    }
  };
});

createTitle.js

define(function () {
  return function (text) {
    var hWrapper = document.createElement('h1');
    hWrapper.innerText = text;
    document.body.appendChild(hWrapper);
  };
});

main.js

define(['controller'], function (controller) {
  var title = '标题';
  var content = ['内容1', '内容2', '内容3'];
  controller(title);
  controller(content);
});

经过 requireJs 处理,应用依赖的文件会依次通过浏览器请求获取。

UMD

经过上边的介绍,我们知道了 JS 模块化在服务端和浏览器端分别由 CommonJS 规范和 AMD 规范,这样的话如果是要写一个通用的模块则有些麻烦。为了解决这个问题,UMD(Universal Module Definition)应运而生。 UMD 的实现也比较简单,其是在 AMD 实现的基础上兼容了 CommonJS 的实现。它的原理大致如下:

  1. 首先判断环境是否支持 AMD,若支持则使用 AMD;
  2. 其次判断环境是否支持 CommonJS,若支持则使用 CommonJS;
  3. 如果都不支持则会将模块直接暴露到全局。

用 UMD 改造 controller.js:

controller.js

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    console.log('--AMD--');
    define(['createList', 'createTitle'], factory);
  } else if (typeof exports === 'object') {
    console.log('**CJS**');
    module.exports = factory(require('./createList'), require('./createTitle'));
  } else {
    console.log('+++全局+++');
    root.controller = factory(root.createList, root.createTitle);
  }
})(this, function (createList, createTitle) {
  //方法本身
  return function (data) {
    if (Object.prototype.toString.call(data) === '[object Array]') {
      createList(data);
    } else {
      createTitle(data);
    }
  };
});

ES6 Modules

以上介绍了这么多方案,其实都可以称作‘时代的眼泪’。他们出现的原因也都是因为当时 JS 本身没有一个完整的模块解决方案。ES6 之后这一问题就彻底消失了,因为 ES6 带来了真正属于 JS 自己的模块系统,也就是我们现在常用的通过importexport来完成导入导出的模块机制。 下面我们用 ES6 语法来重写我们的引用。

index.html

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>ES6</title>
  </head>
  <body>
    <script src="./main.js" type="module"></script>
  </body>
</html>

createList.js

export default function createList(arr) {
  var ulWrapper = document.createElement('ul');
  document.body.appendChild(ulWrapper);

  for (let i = 0; i < arr.length; i++) {
    const text = arr[i];
    var liWrapper = document.createElement('li');
    liWrapper.innerText = text;
    ulWrapper.appendChild(liWrapper);
  }
}

createTitle.js

export default function createTitle(text) {
  var hWrapper = document.createElement('h1');
  hWrapper.innerText = text;
  document.body.appendChild(hWrapper);
}

controller.js

import createList from './createList.js';
import createTitle from './createTitle.js';

export function controller(data) {
  if (Object.prototype.toString.call(data) === '[object Array]') {
    createList(data);
  } else {
    createTitle(data);
  }
}

main.js

import { controller } from './controller.js';

var title = '标题';
var content = ['内容1', '内容2', '内容3'];

controller(title);
controller(content);

细心的同学可能已经发现。除了 JS 代码中使用到的导入导出关键字外,我们在index.html<script>标签中也使用了新的属性type = module,这是为了让浏览器区别模块化脚本和全局脚本。 关于 ES6 的新语法,很长一段时间里浏览器的兼容性实现并不好。以下是截止当前的浏览器兼容情况: 针对以上情况,babel和一些业界大名鼎鼎的打包工具就派上了用场: 以上打包工具发展到今天,功能特性已经越来越丰富。特别是对于现在开发大型工程化项目的需求,我们也越来越难以离开这些打包工具。因为对于他们的介绍不属于本文的主旨范围,感兴趣的同学可以自行深入了解。

结语

前端变化日新月异,继往才能开来。