1. 幼年期:无模块化
script标签的参数
- 普通 - 解析到立即阻塞,立刻下载执行当前script
- defer - 解析到标签开始异步下载,解析完之后开始执行
- async - 解析到标签开始异步下载,下载完成后开始执行并且阻塞渲染,执行完成之后继续渲染
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);
-
- 了解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.js,index.json,index.node。
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,得到的结果为
为什么是这个输出结果呢?CJS是如何实现上述效果的呢?
require的加载原理
首先我们要明白两个概念,module和Module。
module:在nodejs中,每一个js文件都是一个module,上面保存了上述提过的exports等信息之外,还保存了loaded,表示该模块是否被加载。
Module:nodejs在整个系统运行之后,会将每一个module在Module上缓存
// 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.exports。
module.exports
module.exports可以导出一个对象,也可以导出一个类。最好exports和module.exports不要同时存在,有可能会出现覆盖的情况。
exports.name = 'khkhkh'
module.exports ={
name:'lkh',
say(){
console.log(666)
}
}
面试题
exports和module.exports的优缺点:
module.exports可以自定义的导出函数、数组等各种类型,而exports只能导出对象。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')
}
//打印如下
**import()可以做些什么?
import()动态加载,可以放到条件语句或函数执行上下文中import()可以实现懒加载,比如vue中的路由懒加载;
[
{
path: 'home',
name: '首页',
component: ()=> import('./home') ,
},
]