Js模块化

208 阅读10分钟

1. 幼年期:无模块化

script标签的参数

  • 普通 - 解析到立即阻塞,立刻下载执行当前script
  • defer - 解析到标签开始异步下载,解析完之后开始执行
  • async - 解析到标签开始异步下载,下载完成后开始执行并且阻塞渲染,执行完成之后继续渲染

image.png

2. 成长期:模块化前夜 - IIFE(语法侧的优化) - 作用域的把控

因为我们所有的变量都在全局作用域中,所以会造成污染,不利于大型项目的共同开发。所以在作用域这块我们利用函数的块级作用域来进行隔离,使用IIFE。

 (() => {
   let count = 0;
   // ……
 })();

这样就初步形成了一个最简单的js模块。

那么如果该模块有外部依赖,该如何优化呢?

  • 1.依赖其他模块的传参型
  const iifeModule = ((dependencyModule1, dependencyModule2) => {
    let count = 0;
    const increase = () => ++count;
    const reset = () => {
      count = 0;
    }
    console.log(count);
    increase();
  })(dependencyModule1, dependencyModule2);
    1. 了解jq或者很多开源框架的模块加载方案吗?
  const iifeModule = ((dependencyModule1, dependencyModule2) => {
    let count = 0;
    const increase = () => ++count;
    const reset = () => {
      count = 0;
    }
    console.log(count);
    increase();
    return {
      increase, reset
    }
  })(dependencyModule1, dependencyModule2);
  iifeModule.increate();
  iifeModule.reset();

这就是揭示模式(revealing) => 上层无需了解底层是如何实现的,只关注他返回的内容。

3. 成熟期

1. CJS - CommonJs

Commonjs 的提出,弥补 Javascript 对于模块化,没有统一标准的缺陷。nodejs 借鉴了 Commonjs 的 Module ,实现了良好的模块化管理。 目前 Commonjs 广泛应用于以下几个场景:

  • Node 是 CommonJS 在服务器端一个具有代表性的实现;
  • Browserify 是 CommonJS 在浏览器中的一种实现;
  • webpack 打包工具对 CommonJS 的支持和转换;也就是前端应用也可以在编译之前,尽情使用 CommonJS 进行开发。

1. CJS的使用和原理

  • 每一个js文件都是一个module
  • 通过module.exports/exports 去对外暴露接口
  • 通过require来引入模块(自定义模块、系统模块、第三方库模块)
//main.js
let count = 0
const increase = () => ++count;
const reset = () => count = 0;
//单个暴露
exports.increase = increase
exports.reset = reset
//一起暴露
module.exports = {
    increase , reset
}
//exe
const {increase , reset} from './main.js'

那么在上述代码中,module,exports,require这三个变量是没有定义的,但是我们却可以直接使用他,是因为在编译的过程中,CJS对js代码进行了一个首尾封装。

(function (this,exports,module,require){
    const m = require('./main.js')
    exports.m = m
})

那么包装函数的本质是什么呢?

function wrapper (script) {
    return '(function (exports, require, module, __filename, __dirname) {' + 
        script +
     '\n})'
}

使用包装函数包装后返回的,暂时是一个字符串,在模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行 modulefunction ,传入require ,exports ,module 等参数。最终我们写的 nodejs 文件就这么执行了。

那么在很多开源框架中为什么要把(全局)、指针、框架本身作为参数传递进去呢?

(function(window, $, undefined) {
  const _show = function() {
    $("#app").val("hi zhaowa");
  }
  window.webShow = _show;
})(window, jQuery);

// 阻断思路
// window - 1. 全局作用域转化成局部作用域,提升执行效率 2. 编译时优化
(function(c){})(window) // window会被优化成c
// jquery - 1. 独立定制复写和挂载 2.防止全局串扰
// undefined - 防止重写

2. require文件加载流程

接下来我们来分析一下,require是如何进行文件加载的。 我们以nodejs为参考,比如

const fs =      require('fs')      // ①核心模块
const sayName = require('./hello.js')  //② 文件模块
const crypto =  require('crypto-js')   // ③第三方自定义模块

对于requier的加载标识符

  • 像fs,http,path等标识符,会被作为nodejs的核心模块
  • ./../作为相对路径,/作为绝对路径的文件模块
  • 对于非路径也非核心模块,则作为自定义模块

核心模块的优先级仅仅次于缓存架在,在Node的源码编译中,被编译成二进制代码。

路径形式的模块require()方法会将路径转换为真实路径,将编译后的结果缓存起来,第二次加载的时候会更快。

自定义模块它的查找会遵循以下原则

  • 在当前目录下的 node_modules 目录查找。

  • 如果没有,在父级目录的 node_modules 查找,如果没有在父级目录的父级目录的 node_modules 中查找。

  • 沿着路径向上递归,直到根目录下的 node_modules 目录。

  • 在查找过程中,会找 package.json 下 main 属性指向的文件,如果没有 package.json ,在 node 环境下会以此查找 index.jsindex.jsonindex.node

image.png

3. require模块引入与处理

我们来观察如下实例:

  • a.js
const getMsg = require('./b')
console.log('我是a文件');
exports.say = function(){
    const msg = getMsg
    console.log(msg);
}
  • b.js
const say = require('./a')
const obj ={name:'kh',age:20}
console.log('我是b文件');
exports.getMsg = function(){
    return obj
}
  • main.js
const a = require('./a')
const b = require('./b')
console.log('我是main文件');

我们在终端执行main.js,得到的结果为

image.png

为什么是这个输出结果呢?CJS是如何实现上述效果的呢? require的加载原理 首先我们要明白两个概念,moduleModule

module:在nodejs中,每一个js文件都是一个module,上面保存了上述提过的exports等信息之外,还保存了loaded,表示该模块是否被加载。

Module:nodejs在整个系统运行之后,会将每一个moduleModule上缓存

 // id 为路径标识符
function require(id) {
   /* 查找  Module 上有没有已经加载的 js  对象*/
   const  cachedModule = Module._cache[id]
   
   /* 如果已经加载了那么直接取走缓存的 exports 对象  */
  if(cachedModule){
    return cachedModule.exports
  }
 
  /* 创建当前模块的 module  */
  const module = { exports: {} ,loaded: false , ...}

  /* 将 module 缓存到  Module 的缓存属性中,路径标识符作为 id */  
  Module._cache[id] = module
  /* 加载文件 */
  runInThisContext(wrapper('module.exports = "123"'))(module.exports, require, module, __filename, __dirname)
  /* 加载完成 *//
  module.loaded = true 
  /* 返回值 */
  return module.exports
}

以上是require的源码,我们通过源码来分析require的大致流程:

  • 在接收到参数后,会首先在Module中查找是否有缓存,如果有直接返回,没有则创建module,加载之后将loaded属性设置为true,返回module.exports对象。
  • 最后导出返回的是module.exports,如果认为对exports进行赋值,会导致exports.xxx的操作出问题

那么现在上述的输出结果就非常清晰了

  • 首先执行require('./a'),判断是否有缓存,没有=>加入。然后执行a.js
  • a.js的第一行则是引入b.js,同样没有缓存=>加入=>执行。
  • b.js第一行是引入a.js中的say方法,有缓存=>直接返回,不过现在a还没暴露say,所以找不到say,可以通过异步的方式来加载,或者下面我们要讲的动态加载
  • 打印'我是b文件',暴露getMsg方法,b执行结束,开始继续执行a.js
  • 打印'我是a文件',暴露say方法,a.js执行结束。最后打印'我是main文件'

4. require动态加载

require可以在任意的上下文,动态加载模块。

console.log('我是 a 文件')
exports.say = function(){
    const getMsg = require('./b')
    const message = getMes()
    console.log(message)
}

这样在执行a中的say方法的时候,就可以直接加载到b中的getMsg

5. exports和module.exports

exports.xxx

exports.name = `lkh`
exports.say = function (){
    console.log(666)
}
//引用
const a = require('./a')
console.log(a)
//打印
{name:'lkh',say:[Function] }

通过上述描述可以看到,exports暴露出去的就是一个对象,本质就是module.exportsmodule.exports module.exports可以导出一个对象,也可以导出一个类。最好exportsmodule.exports不要同时存在,有可能会出现覆盖的情况。

exports.name = 'khkhkh'
module.exports ={
    name:'lkh',
    say(){
        console.log(666)
    }
}

面试题

exportsmodule.exports的优缺点:

  1. module.exports可以自定义的导出函数、数组等各种类型,而exports只能导出对象。
  2. module.exports在导出一些函数等非对象属性时,会有一些风险。在循环引用此类情况下,如果导出的是对象,他会保留相同的内存地址,即便后绑定了某些属性,也可以间接访问。但是如果导出一个非对象属性,在循环引用时,就容易造成属性丢失。
  • CJS的优点:CJS率先在服务端在框架层面解决了依赖、全局变量污染的问题
  • 只针对于服务端,并且处理异步问题不完美。

2. AMD -- 通过异步加载、允许制定回调函数

新增定义方式:

// define来定义模块
define("模块名称", ["模块的依赖项"], callback);
// require进行加载
require(["模块名称"], callback);

举例说明:定义了一个amdModule的模块,依赖内部的require和exports还有外部的bar

  define('amdModule', ["require","exports","bar"], (require,exporte,bar) => {
    exports.abc = function(){ // 给amdModule模块添加了abc方法
        return bar.def() // 返回bar模块中的def方法
        //或者
        return require("bar").def() // 可以随时加载模块,读取其中的方法
    }
  })
  • 优点:适合在浏览器中加载异步模块的方案
  • 缺点:引入成本较高

3. CMD - 按需加载

  define('module', (require, exports, module) => {
    let $ = require('jquery');
    // jquery相关逻辑

    let dependencyModule1 = require('./dependencyModule1');
    // dependencyModule1相关逻辑
  })
  • 优点: 按需加载,依赖就近
  • 缺点:依赖打包,加载逻辑存在于每个模块中,扩大了模块体积,同时功能上依赖编译

4. Es Module

Es Module 的产生有很多优势,比如:

  • 借助 Es Module 的静态导入导出的优势,实现了 tree shaking
  • Es Module 还可以 import() 懒加载方式实现代码分割。

1. 在 Es Module 中用 export 用来导出模块,import 用来导入模块。但是 export 配合 import 会有很多种组合情况,接下来我们逐一分析一下。

1. export正常导出,import正常导入

const name = 'lkh' 
export { name }
export const say = function (){
    console.log('hello , world')
}
//导入
import { name , say } from './a.js'

2. 默认导出 export default

const name = 'lkh'
const say = function (){
    console.log('hello , world')
}
export default {
    name,
    say
} 
//引入
import msg from './a.js'
//打印一下msg
    console.log(msg)  //{name : 'lkh' , say:Function}

3. 混合导入导出

第一种为上述两种混合使用,不多赘述。

第二种:

import * as msg from './a.js'
console.log(msg) // {上面所有不管什么方法暴露的都有}

4.重署名导入

import {name as Myname , say } from './a.js'
console.log(Myname) //'lkh'

5. 重定向导出

即在这中转,把引入的module再导出去

export * from 'module' // 第一种方式
export { name , say } from 'module' // 第二种方式
export {   name as MyName , say } from 'module' //第三种方式

2. ES6 module特性

1. 静态语法

ES6 mosule的引入和导出都是静态的,import会自动的提升到代码的顶层,import,export都不能放在块级作用域或条件语句中。

这种静态语法,在编译过程中确定了导入导出的关系,更方便的去查找依赖,也就是tree shaking,可以使用lint工具对模块依赖进行检查,可以对导入导出加上类型信息进行静态的类型检查。

2. 执行特性

ESmodule和CJS都会保存静态属性,但是CJS是加载并执行,而ESM则是提前在预处理阶段加载并执行模块,执行顺序是子 => 父

console.log('main.js开始执行') 
import say from './a' 
import say1 from './b' 
console.log('main.js执行完毕')
//打印
a -> b -> 开始 -> 完毕

3. 导出绑定

不能修改import导入的属性

//导出
export let num = 1
export const addNumber = ()=>{
    num++
}
//引入
import {num , addNumber} from './a'
num = 2
//报错:'num' is read-only
//所以如果我们想修改num的话,可以这么修改
import {  num , addNumber } from './a'
console.log(num) // num = 1
addNumber()
console.log(num) // num = 2

import()动态引入

import() 返回一个 Promise 对象, 返回的 Promise 的 then 成功回调中,可以获取模块的加载成功信息。我们来简单看一下 import() 是如何使用的。

//main.js
setTimeout(() => {
    const result  = import('./b')
    result.then(res=>{
        console.log(res)
    })
}, 0);
//b.js
export const name ='alien'
export default function sayhello(){
    console.log('hello,world')
}

//打印如下

image.png

**import()可以做些什么?

  • import()动态加载,可以放到条件语句或函数执行上下文中
  • import()可以实现懒加载,比如vue中的路由懒加载;
[
   {
        path: 'home',
        name: '首页',
        component: ()=> import('./home') ,
   },
]