前端工程化三部曲之模块化技术

174 阅读12分钟

前端模块化

1.什么是前端模块化

  • 将复杂的程序根据规则或者规范拆分成若干模块,一个模块包括输入和输出
  • 模块化的内部数据和实现是私有的,对外暴露一些接口与其他模块进行通信

2.前端模块化的背景

  • 前端模块化是一种**标准,不是实现**
  • 理解模块化是理解前端工程化的**前提**
  • 前端模块化是前端项目规模化的必然结果

3.脚本和模块的区别

有很多同学会对脚本和模块之间产生一定的混淆,我这里通过一张图来帮助大家区分两者的不同。

4.前端模块化的进化过程

4.1 全局function模式:将不同功能封装成不同的全局函数

  • 缺陷:容易**引发全局命名空间冲突**,而且模块成员之间看不出直接关系
// 所有function都是挂在window下面的
funtion api(){
  return {
    xxx
  }
}

function handle(data, key){
  return xxx
}

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

const data = api();
const a = handle(data, 'a')

4.2 全局namespace模式

  • 作用:减少了全局变量,解决了命名冲突
  • 缺陷:存在数据安全的问题,外部可以直接修改模块内部数据
window.__Module = {
  x: 1,
    api(){
        xxx
    },
    handle(){
      xxx
    },
    sum(a,b){
        return a + b
    }
}

const module = window.__Module
consr data = module.api()

console.log(module.x) // 1
module.x = 2

4.3 IIFE模式:匿名函数自调用 -- 闭包

  • 作用:通过自执行函数创建闭包,解决私有化的问题,外部只能通过暴露的方法操作
  • 缺陷:无法解决模块间相互依赖的问题
(function(window){
  var x = 1;

  function api(){
    xxx
    }

  function setX(v){
    x = v
    }

  function getX(){
    return x
  }

  window.__Module = {
    x,
    setX,
    getX,
    api,
  }
})(window)

const m = window.__Module

// 这里改的是函数作用域内变量的值
m.setX(10)
console.log(m.getX()) // 10

// 这里改的是对象属性的值,不是修改的模块内部的data
m.x = 2
console.log(m.getX()) // 10

4.4 IIFE模式增强,支持传入自定义依赖

  • 作用:通过模块间参数的传递,来实现解决模块间的依赖问题

  • 缺陷:

    • 多依赖传入时,代码阅读困难
    • 无法支持大规模的模块化的开发
    • 无特定语法支持,代码简陋

A: __Module_API模块

(function(global){
  var a = 1;
  function api(){
    return {
      code: 0,
      data: {
        a,
        b: 2
      }
    }
  }
  function handle(data, key){
    return data.data[key]
  }
  global.__Module_API = {
    api,
    handle
  }
})(window)

B:__Module模块

(function(global, moduleAPI){
  function sum(a, b){
    return a + b;
  }
  global.__Module = {
    api: moduleAPI.api,
    handle: moduleAPI.handle,
    sum,
  }
})(window, window.__Module_API)

const module = window.__Module
const data = module.api.api()
const a = module.api.handle(data, 'a')

通过将Module_API模块作为入参传入到Module模块中,实现在Module模块中引用依赖Module_API的一些模块方法

5.前端模块化的好处

  • 减少了全局变量,解决了命名冲突
  • 能够更好的分离,实现按需加载
  • 有更高复用性和更高可维护性

CommonJS模块化规范

1.CommonJs规范介绍

  • Node.js默认的模块化规范,每个文件就是一个模块,有自己的作用域
  • Node中CJS模块加载采用在**服务器端运行时同步加载方式,在浏览器端提前编译打包处理**方式
  • 通过**require加载模块,通过exportsmodule.exports**输出模块

2.CommonJS规范特点

  • 所有代码都运行在**模块作用域**,不会污染全局作用域
  • 模块可以多次加载,第一次加载时会**运行模块模块输出结果会被缓存,再次加载时,会从缓存结果中直接读取模块输出结果**
  • 模块加载的顺序,按照其在代码中出现的顺序

  • CommonJS 规范的核心变量: exports、module.exports、require

    • CommonJS规范规定,每个模块内部,module变量代表当前模块。
    • 这个module变量是一个对象,它的exports属性(module.exports)是对外的接口, 负责对模块中的内容进行导出
    // lib.js
    var counter = 3;
    function incCounter() {
      counter++;
    }
    module.exports = {
      counter: counter,
      incCounter: incCounter,
    };
    
    • require 函数基本功能就是**读入并执行一个JavaScript文件,然后返回该模块的exports对象。**
    const mod = require('./lib')
    
  • 模块输出的值是值的拷贝,类似IIFE方案中的内部变量

3.CommonJS加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值

请看下面这个模块文件**lib.js**的例子。

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代码输出内部变量**counter和改写这个变量的内部方法incCounter。然后,在main.js**里面加载这个模块。

// main.js
var mod = require('./lib');

console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

**lib.js模块加载以后,它的内部变化就影响不到输出的mod.counter了。这是因为mod.counter**是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。比如改成下面的这种写法

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter
  },
  incCounter: incCounter,
};

这样输出的counter属性就变成了一个取值器的函数,就可以正确读取到内部变量counter的变动。

ESModule模块化规范

1.简单了解AMD规范和CMD规范

1.1 AMD规范:

  • AMD规范采用非同步加载模块,允许指定回调函数(针对commonjs同步而诞生的规范)
  • Node模块主要用于**服务器编程,模块文件通常都位于本地硬盘,加载起来速度比较快,所以适用于CommonJS的这种同步加载**
  • 但是浏览器环境下,模块需要请求获取,要从服务端加载模块,所以适用于异步加载,一般采用**AMD**规范
  • require.js是AMD的一个具体实现库
AMD基本语法 -- 定义暴露模块
//定义没有依赖的模块
define(function(){
   return 模块
})

//定义有依赖的模块
define(['module1', 'module2'], function(m1, m2){
   return 模块
})
AMD基本语法 -- 引入使用模块
require(['module1', 'module2'], function(m1, m2){
   使用m1/m2
})

1.2 CMD规范

  • CMD专门用于浏览器端,整合了CommonJS和AMD的优点,模块的加载是异步的,模块使用时才会加载执行
  • Sea.js是CMD规范的一个实现
CMD基本语法 -- 定义暴露模块
//定义没有依赖的模块
define(function(require, exports, module){
  exports.xxx = value
  module.exports = value
})

//定义有依赖的模块
define(function(require, exports, module){
  //引入依赖模块(同步)
  var module2 = require('./module2')
  //引入依赖模块(异步)
  require.async('./module3', function (m3) {
    console.log(m3)
  })
  //暴露模块
  exports.xxx = value
})
CMD基本语法 -- 引入使用模块
define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.show()
  m4.show()
})

注意:AMD 和 CMD 规范现在已经不怎么去用了。AMD和CMD最大的问题是没有通过语法升级解决模块化(它们定义模块还是通过调用js的方式定义一个模块,它没有办法对模块进行规模化的引用)

所以我们现在主流的使用:node环境下用commonjs,浏览器环境下用ESModule

2.ESModule规范介绍

  • ESModule设计理念是希望在**编译**的时就确定模块的依赖关系及输入输出
  • CommonJS和AMD都只能在**运行**时才能确定依赖和输入、输出

举例说明:

// ES6模块
import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

3.ESModule模块化语法(结合日常实战开发)

3.1 export命令

工作编码的时候我们常用的一些公共导出方法(例如utils.js文件),经常会采用下面两种方式来书写

第一种写法:
// 获取url上指定参数的值
export const getQueryString = (name) => {
  console.log('getQueryString...')
};
// 随机数生成
export const getRandomString = (len) => {
  console.log('getRandomString...')
};
// 获取cookie
export const getCookie = (objName) => {
  console.log("getCookie....")
};
第二种写法:(推荐)

优先推荐使用这种写法。因为这样我们就可以在脚本尾部,一眼看清楚输出了哪些变量。

而且方便通过as的关键字可以对输出的变量进行重命名。

// 获取url上指定参数的值
const getQueryString = (name) => {
  console.log('getQueryString...')
};
// 随机数生成
const getRandomString = (len) => {
  console.log('getRandomString...')
};
// 获取cookie
const getCookie = (objName) => {
  console.log("getCookie....")
};

export { getQueryString as getQuery, getRandomString, getCookie }

3.2 export default命令

从前面的实战我们可以看出,使用**import**命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。

为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到**export default**命令,为模块指定默认输出。

1)实战用法举例1:

导出ace接口文件配置:

// api/ace文件
import * as ace from '@/api/common/color.jd.com';

// ace.jd.com
const getACEData = async (id: string) => {
  return await ace.get({
    url: `//api.m.jd.com/client.action?xxx`,
  });
};
const api = {
  getACEData,
};
export default api;

引用ace文件:(**import**命令可以为该函数指定任意名字。)

import ACE_API from '@/api/ace';
const res = await ACE_API.getACEData(123);

注意:需要注意的是,这时**import**命令后面,不使用大括号。

2)实战用法举例2:

如果想在一条**import**语句中,同时输入默认方法和其他接口,可以写成下面这样。

import _, { each, forEach } from 'lodash';

对应上面代码的**export**语句如下

export default function (obj) {
  // ···
}

export function each(obj, iterator, context) {
  // ···
}

export { each as forEach };

比如我们平时用react框架开发时,也会用到这种写法:

import React, { useEffect, useState } from 'react';

3.3 import命令

使用import命令有一些重要的关键点,这里给大家列举阐述一下:

  • 可以使用as关键字将输入变量重命名
import { lastName as surname } from './profile.js';
  • import命令是只读的,不允许在加载模块的脚本里面,改写接口。
import {a} from './xxx.js'

a = {}; // Syntax Error : 'a' is read-only;
  • **import**命令具有提升效果,会提升到整个模块的头部,首先执行

下面的代码不会报错,因为**import的执行早于foo的调用。这种行为的本质是,import**命令是编译阶段执行的,在代码运行之前。

foo();

import { foo } from 'my_module';
  • 多次重复执行同一句**import**语句,那么只会执行一次,而不会执行多次。
  • 由于**import**是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
// 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

// 报错
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}

3.4 模块的整体加载

我们可以用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

日常开发举例:

比如我们开发所用到的公共方法类文件(utils.js文件)

// utils.js
// 获取url上指定参数的值
const getQueryString = (name) => {
  console.log('getQueryString...')
};
// 随机数生成
const getRandomString = (len) => {
  console.log('getRandomString...')
};
// 获取cookie
const getCookie = (objName) => {
  console.log("getCookie....")
};

export { getQueryString as getQuery, getRandomString, getCookie }

那么我们整体加载的写法可以更改如下:

import * as utils from '@/utils/utils';
utils.getQueryString('shopId')

4.CommonJS和ESModule规范对比

这里主要对上面两种不同的规范进行一个对比总结:

4.1 CommonJS模块输出的是值的拷贝,ES6模块输出值的引用

ESModule举例:输出的a是一个地址,这个值变化后面是跟着变化的

// test.js
export let a = 1;
export function plus(){
  a++;
}

// entry.js
import { a , plus } from './test.js'

console.log(a); // 1
plus();
console.log(a); // 2

CommonJS是对值是进行拷贝的,例如这里是对值a的一个拷贝

// test.js
let a = 1;

exports.a = a;
exports.plus = function(){
  a++;
}
exports.get = function(){
  return a;
}

// entry.js
const { a, plus, get } = require('./test.js')
console.log(a) // 1
plus();
console.log(a) // 1
console.log(get()) // 2

4.2 CommonJS模块运行时加载,ES6模块是编译时输出接口

第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

4.3 CommonJS模块为同步加载,ES6模块支持异步加载

// ESModule 可以通过promise的方式异步加载
import('./test.js').then(mod =>{
  console.log('mod', mod)
})

4.4 CommonJS中this是当前模块,ES6模块的this是undefined

// commonjs
console.log(this === module.exports)

5. 浏览器模块化的局限

缺乏模块管理能力,模块分散在各个项目中 ---- npm统一管理

性能加载慢,无法在大型项目中直接使用 --- webpack性能优化

npm + webpack原理

前端工程化之关键技术npm + webpack原理

1.npm包管理工具

1.1 npm诞生背景

  • npm由程序员Isaac发明

  • 初步思路

    • 集中管理所有模块,所有模块都上传到仓库(registry)
    • 模块内创建package.json标注模块的基本信息
    • 通过npm publish发布模块,上传到仓库(registry)
    • 通过npm install安装模块,模块安装到node_modules目录

1.2 npm介绍

  • npm解决的核心问题是模块管理问题
  • npm规范:package.json管理模块信息,node_modules保存依赖

1.3 npm原理分析

因此我们可以总结出如下常用的命令:

npm init创建模块,npm install 安装模块,npm publish发布模块

npm link本地开发,npm config 调整配置,npm run调用scripts

1.4 npm局限

  • npm只能解决**模块**的高效管理和获取问题
  • npm无法解决**性能加载**问题
  • 模块化发明后,制约其广泛应用的因素就是性能问题

2.webpack代码编译工具

2.1 webpack诞生背景

  • Webpack 2012年3月10号诞生,作者是Tobias
  • webpack的出现模糊了任务和构建的边界,使之融为一体

webpack诞生之前专门有一些工具是做任务的,例如**gulp或者grunt**。任务就是每一步要干什么东西,由这个任务的引擎来决定。构建是由其他工具来决定

2.2 webpack原理

  • 最初的webpack核心解决的是**代码合并与拆分**
  • webpack的核心理念是将资源都视为模块,统一进行打包和处理
  • webpack提供了loader和plugins完成功能扩展

总结

本节从前端模块化发展历史,衍生出了CommonJS规范、AMD规范、CMD规范到现在的ESModule,成为浏览器和服务器通用的模块解决方案。再加上npm管理工具和webpack打包编译工具的诞生,一举突破了前端工程化的关键技术。

作为前端工程化系列的第一篇文章,希望让大家认识到前端工程化并没有那么复杂,就是我们平时开发工作中经常用到的知识点,学习起来也会相对容易一些。接下来的进阶篇我会带领大家玩转webpack,正式入门前端工程化,成为前端工程化开发领域的实践者!