Skip to content

前端模块化

https://mp.weixin.qq.com/s/t0l_TrQguO0ARoir3D-yEw

模块:

  • 外部的模块:引入前端工程的某个外部的包(package),可能由多个 JS 文件组成,但会通过入口暴露给项目调用;

  • 内部的模块:自己的工程项目中编码的最小单元,即单个的 JS 文件。

时间线:

Node.js 2009

NPM 2010

requireJS(AMD) 2010

seaJS(CMD) 2011

broswerify 2011

webpack 2012

grunt 2012

gulp 2013

react 2013

vue 2014

angular 2016

redux 2015

vite 2020

snowpack 2020

外部模块的管理

最开始

去官网下载放到项目中,同时在入口 html 通过 script 标签引用。

NPM

Node.js 和自带的模块管理工具 NPM(node package manager)

可以用 Node.js 创建静态资源服务器,实现热重载和跨域代理等功能,同时还能用它源代码中的特殊写法做编译转换处理(JSX/Sass/TypeScript),将代码翻译成浏览器可以理解的格式(ES5/CSS)

两部分组成:

  • 是一个远程的 JavaScript 代码仓库,所有的可发这都可以向这里提交可共享的模块,并给其他开发者下载和使用

  • 还包含一个命令行工具,通过运行npm publish把自己写的模块发布到 NPM 仓库上,通过运行npm install [模块名],可以将别人的模块下载到自己项目根目录中一个叫 node_modules 的子目录下

每次 npm install 的时候,都会在 package.json 这个文件中更新模块和对应的版本信息。

内部模块的组织

第一阶段:原生 JS 组织

将不同的 JS 文件在 html 中引入,每个文件代表一个模块

通过模块模式去组织代码,通过立即执行函数表达式IIFE去组织模块,每个模块包裹在一个函数作用域里面执行,最大程度避免污染全局执行环境

第二阶段:在线处理阶段

出现许多模块化规范: AMD/CMD/CommonJS/ES6 Module

AMD && CMD 规范 ==> require.js/Sea.js

“在线处理”模块的方案,也就是等到用户流量 web 页面下载了对应的 require.js 和 sea.js 文件之后,才开始进行模块依赖分析,确定加载顺序和执行顺序,模块组织过程在线上进行。

AMD:define 和 require

  • define 定义一个模块,接收数组(表示模块依赖的其他模块)和方法(通过入参的方式将所依赖模块的输出依次取出,并在方法内使用,将返回值传递给依赖它的其他模块使用)
js
define(['Module1''Module2'], function (module1module2) {
    var result1 = module1.exec();
    var result2 = module2.exec();
    return {
      result1: result1
      result2: result2
    }
});
  • require 用于真正执行模块,通常 AMD 框架会以 require 方法作为入口,进行依赖关系分析并依次有序地进行加载
js
// 入口文件
require(['math'], function (math) {
  math.sqrt(15)
});

CMD:接收一个 factory 函数,包括 require(方法标识符,调用它可以动态的获取一个依赖模块的输出),exports(对象,用于对其他模块提供输出接口),module(对象,存储了当前模块相关的一些属性和方法,module.exports 属性等同于 exports)

js
// CMD
define(function (requieexportsmodule) {
    //依赖就近书写
    var module1 = require('Module1');
    var result1 = module1.exec();
    module.exports = {
      result1: result1
    }
});

// AMD
define(['Module1'], function (module1) {
    var result1 = module1.exec();
    return {
      result1: result1
    }
});

区别:

  • AMD 依赖前置,通过依赖数组的方式提前声明当前模块的依赖;CMD 依赖就近,在编程需要用到的时候通过调用 require 方法动态引入

  • AMD 通过返回值的方式对外输出;CMD 通过给 module.exports 赋值的方式对外输出

模块源码到目标代码的编译和转换

ES6 模块化风格 ==> CommonJS/ES6

随着 Babel 等编译工具和 webpack 等自动化工具的出现,AMD/CMD 不再使用

CommonJS && ES6

CommonJS 是 Node.js 使用的模块化方式,而 import/export 则是 ES6 提出的模块化规范。

js
// ES6
import { foo } from './foo'; // 输入
export const bar = 1;        // 输出

// CommonJS
const foo = require('./foo'); // 输入
module.exports = {// 输出
    bar:1
}

但在最开始的时候,我们却不能在前端页面中使用它们,因为浏览器并不能理解这种语法。

babel 的出现和 ES6 模块化的推广

babel 是一个 JavaScript 编译器,让我们能够使用符合开发需求的编程风格去编写代码,然后通过 babel 编译转化成对浏览器兼容良好的 JavaScript

babel 出现之前,开发和生产的代码并没有明显的区分性,而 babel 则将开发和生产这两个流程分开了,同时让我们可以用 ES6 中的 import/export 进行模块化开发。

babel 的工作原理

三个阶段:

  • Parse(解析):通过词法分析和语法分析,将源代码解析成抽象语法树 AST

  • Transform(转换):对解析出来的抽象语法树做中间转换处理

  • Generate(生成):用经过转换后的抽象语法树生成新的代码

第三阶段:预处理阶段

也就是预编译阶段

CMD/AMD 的在线编译,也就是等到用户浏览 web 页面下载了 js 文件之后,才开始进行模块依赖分析,确定加载顺序和执行顺序,但这样会带来问题:

1、在线组织模块的方式延长前端页面的加载时间,影响用户体验

2、加载过程中发出了海量的 http 请求,降低页面性能

于是开发者们:

  • 开发一个工具,让它把组织模块的工作提前做好,在代码部署上线前就完成,从而节约页面加载时间

  • 使用工具进行代码合并,把多个 script 的代码合并到少数几个 script 里,减少 http 请求的数量。

alt text

所以一系列模块预处理工具出现了:2011 年出现的 broswerify 和 2012 年的 webpack

最开始都是通过预先打包的方式,把前端项目里面的多个文件打包成单个文件或少数几个文件,这样就可以压缩首次页面访问时的 http 请求数量,从而提高性能

alt text

broswerify

cmd
npm install -g browserify
`

broswerify允许通过CommonJS的规范编写代码,例如下面的入口文件main.js
```js
// main.js
var a = require('./a.js');
var b = require('./b.js');
...

然后可以用 broswerify 携带的命令行工具处理 main.js,它会自动分析依赖关系并进行打包,打包后会生成集合文件 bundle.js。

cmd
browserify main.js -o bundle.js

webpack

cmd
npm install --save-dev webpack

它要求编写一份 webpack.config.js 的配置文件,并以 entry 字段和 output 字段分别表示打包的入口和输出路径

js
// webpack.config.js
const path = require('path');

module.exports = {
  entry: './src/index.js'
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  }
};

打包完毕,index.html 只需要加载 bundle.js 就可以了

html
<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    ...
    <script src="dist/bundle.js"></script>
  </body>
</html>

打包工具面临的问题 && 解决方案

  • 打包后代码体积过大的问题

如此一来,首屏加载会消耗很长时间并拖慢速度

webpack 于是引入了代码拆分的功能(Code Splitting)来解决这个问题, 从全部打包后退一步:可以打包成多个包

Code Splitting 有可分为两个方面的作用:

一是实现第三方库和业务代码的分离:业务代码更新频率快,而第三方库代码更新频率是比较慢的。分离之后可利用浏览器缓存机制加载第三方库,从而加快页面访问速度

这种代码拆分可通过 webpack 独特的插件机制完成。plugins 字段是是一个数组,可接收不同的 plugins 实例,从而给 webpack 打包程序附加不同的功能,CommonsChunkPlugin 就是一个实现代码拆分的插件。

js
// webpack.config.js
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'commons'// the commons chunk name
      filename: 'commons.js'// the filename of the commons chunk)
      minChunks: 3// Modules must be shared between 3 entries
    });
  ]
};

通过上面的配置,webpack 在执行打包的时候会把被引用超过 3 次的依赖文件视为"公共文件",并单独打包到 commons.js 中,而不是打包到主入口文件里。

对于 React,Redux,lodash 这些第三方库,因为引用次数远远超过 3 次,当然也是会被打包到 common.js 中去的。

alt text

二是实现按需加载: 例如我们经常通过前端路由分割不同页面,除了首页外的很多页面(路由)可能访问频率较低,我们可将其从首次加载的资源中去掉,而等到相应的触发时刻再去加载它们。这样就减少了首屏资源的体积,提高了页面加载速度。

按需加载就是等到需要的时候才加载一部分模块。并不选择将其代码打包到首次加载的入口 bundle 中,而是等待触发的时机,届时才通过动态脚本插入的方式进行加载: 即创建 script 元素,添加脚本链接并通过 appendChild 加入到 html 元素中

例如我们需要实现一个功能,在点击某个按钮的时候,使用某个模块的功能。这时我们可以使用 ES6 的 import 语句动态导入,webpack 会支持 import 的功能并实现按需加载

js
button.addEventListener('click'function(){
  import('./a.js').then(data => {
    // use data
  })
});

第四阶段:自动化构建

什么叫自动化构建工具呢? 简单的说就是: 我们需要这样一个工具,专门为开发过程服务,尽可能满足我们开发的需求,提高开发的效率。

自动化构建工具就是在开发流程中给开发者最大的自由度和便捷性,同时在生产流程中能保证浏览器兼容性和良好性能的工具。而所有的功能已经由插件直接提供,所以被称作“自动化” 构建工具。

alt text

gulp && webpack

  • gulp 是编程式的自动化构建工具

  • webpack 是配置式的自动化构建工具

Gulp 基本的风格是编程式的, 它是一种基于流即 Node.js 封装起来的 stream 模块的自动化构建工具,一般先通过 gulp.src 将匹配的文件转化成 stream(流)的形式,然后通过一连串的 pipe 方法进行链式的加工处理处理,对后通过 dest 方法输出到指定路径。

// gulpfile.js
const { src, dest } = require('gulp');
const less = require('gulp-less');
const minifyCSS = require('gulp-csso');

function css() {
  return src('client/templates/*.less')
    .pipe(less())
    .pipe(minifyCSS())
    .pipe(dest('build/css'))
}

webpack 的基本风格则是配置式的,它通过 loader 机制实现文件的编译转化。通过配置一组 loader 数组,每个 loader 会被链式调用,处理当前文件代码后输出给下一个 loader, 全部处理完毕后进行输出

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/, // 正则匹配less文件
        use: [
          { loader: 'style-loader' }, // creates style nodes from JS strings
          { loader: 'css-loader' },   // translates CSS into CommonJS
          { loader: 'less-loader' },  // compiles Less to CSS
        ],
      },
    ],
  },
};

gulp 和 webpack 在很多方面是类似的,这从它们的相关生态上也可以看得出来

Gulp

gulp-uglify : 压缩 js 文件; gulp-less : 编译 less; gulp-sass:编译 sass; gulp-livereload : 实时自动编译刷新; gulp-load-plugins:打包插件;

Webpack

uglifyjs-webpack-plugin: 压缩 js 文件; less-loader: 编译 less; sass-loader: 编译 sass; devServer.hot 配置为 true: 实时自动编译刷新; ....

CommonJS 与 ES6

语法

  • CommonJS

使用 module.exports 导出,require()导入

语法是函数式的,运行时调用 require

js
// 导出
module.exports = {
  name: "CommonJS",
};

// 导入
module.exports = require("./moduleA");
  • ES6 模块

使用 export 和 import 关键字

语法是声明式的,编译时就能解析模块依赖关系

js
// 导出
export const name = "ES6 Module";

// 导入
import { name } from "./moduleA.js";

语法导入

  • CommonJS

支持动态导入,require()可以在代码执行时动态调用,可以放在条件语句或函数中

js
if (condition) {
  const moduleA = require("./moduleA");
}
  • ES6 模块

默认情况下 import 语句是静态的,不能在条件中使用。

可以使用 import() 函数实现动态导入,它是一个异步操作,返回 Promise。

js
if (condition) {
  import("./moduleA").then((module) => {
    console.log(module.name);
  });
}

模块的导出类型

  • CommonJS

module.exports 只能导出一个对象、函数或值,这意味着你导出的东西通常是一个整体。

可以通过赋值 module.exports 或 exports 进行导出,exports 是 module.exports 的简写,但两者指向同一个对象,直接赋值时需要谨慎。

js
module.exports = { name: "CommonJS" };
// 或者
exports.name = "CommonJS";
  • ES6 模块

可以导出多个内容,可以通过 export 导出变量、函数、类等,export default 还允许导出默认的值

支持 named exports(具名导出)和 default exports(默认导出)

js
// Named export
export const name = "ES6 Module";

// Default export
export default function () {
  console.log("Default Export");
}

同步和异步

  • CommonJS 模块加载是同步的,因此require()是阻塞的,适合服务器端使用,不适合浏览器端直接使用(可能会影响性能)

  • ES6 模块:模块加载是异步的,尤其是在浏览器中,import 是非阻塞的,因此前端环境中效率更高(在编译阶段处理依赖关系)

作用域

CommonJS 模块是运行时加载,每次调用 require() 会得到同一个模块的实例(即单例模式)。每个模块都有自己的作用域,模块中的变量不会污染全局作用域

ES6 模块是静态定义的,并且严格遵循文件级作用域,每个文件都有自己的作用域,变量不会污染全局。模块内的变量默认是私有的,只有导出的内容才能被外部访问。

模块缓存

CommonJS 模块是单例模式,第一次加载后会被缓存。之后再次 require() 时,不会重新加载模块,而是直接使用缓存中的模块对象。

ES6 模块同样被缓存,但由于是编译时加载的,它在首次导入时就已经加载,后续再 import 时也是直接使用缓存。

Node.js 与浏览器支持

CommonJS 是 Node.js 的默认模块系统,支持广泛,用于服务器端,在浏览器端使用 CommonJS 需要工具如 Browserify 或 Webpack 来打包

ES6 模块是现代浏览器和 Node.js(自 v12 及以上版本)原生支持的模块系统。大多数现代浏览器支持 <script type="module"> 标签加载 ES6 模块,Node.js 也逐步支持 ES6 模块

在浏览器中使用 ES6 模块时,可以通过 <script type="module"> 标签直接导入

总结

alt text

另一种描述

区别:

  • CommonJS是对模块的浅拷贝,ES6 Module是对模块的引用,即ES6 Module只存在只读,不能改变其值,也就是指针指向不能变,类似const

  • import的接口是read-only只读状态,不能修改其变量值,即不能修改其变量的指针指向,但可以改变变量内部指针指向,可以对CommonJS重新赋值(改变指针指向),但是对ES6 Module赋值会编译报错

UMD

UMD(Universal Module Definition)并不属于一套模块规范,它主要用来处理CommonJS、AMD、CMD的差异兼容,使模块代码能在不同的模块环境下都能正常运行。

一个UMD模块由两部分组成:

  • 立即调用函数表达式(IIFE):它会检查使用模块的环境。有两个参数:root和factory。root是对全局范围的this引用,而factory是定义模块的函数

  • 匿名函数:创建模块,此匿名函数被传递任意数量的参数以及指定模块的依赖关系

js
(function (root,factory){
  if (typeof module==="object" && typeof module.exports==="object"){
    // Node CommonJS-like
    module.exports=factory();
  }
  else if(typeof define==="function" && define.amd){
    // AMD
    define(factory);
  }else{
    // 不使用任何模块系统,直接挂载到全局
    root.kkb=factory();
  }
}(this,function(){
  let a=1;
  let b=2;

  // 模块导出数据
  return {
    x:a,
    y:b
  }
}))