JavaScript的发展史
JavaScript的发展壮大
在JavaScript诞生之初只是作为一个脚本语言来使用,主要是做一些简单的表单校验等,因为代码量不多,所以是直接跟html写在一个文件里面,并且用script标签包裹
javascript
// index.html
<script>
var name = 'shiyuq'
var age = 18
</script>
但是随着业务越来越复杂,尤其在ajax出现后代码量飞速增长,开发者们纷纷将JavaScript代码写到单独的js文件中,与html文件解耦,如下:
javascript
// index.html
<script src='./index.js'></script>
// index.js
var name = 'shiyuq'
var age = 18
再后来,多个开发者都将自己的js文件引入到一个html文件:
javascript
// index.html
<script src='./index.js'></script>
<script src='./index-shiyuq1.js'></script>
<script src='./index-shiyuq2.js'></script>
// index.js
var name = 'shiyuq'
var age = 18
// index-shiyuq1.js
var name = 'faker'
var age = 28
// index-shiyuq2.js
var name = (name) => {
return `hello ${name}`
}
var age = (age) => {
return age + 1
}
不难发现,问题已经稍有眉头了,此时用哪个文件中的变量完全就取决于谁引用在最下面(在调用它之前),如果在不同文件中变量的类型还不一致,就会导致程序直接崩溃,这种从某种程度上来讲也属于全局变量污染,开发者的噩梦就此来临
模块化的出现
为了解决全局变量污染的问题,开发者开始使用命名空间的方法,如下所示:
javascript
// index.html
<script src='./index.js'></script>
<script src='./index-shiyuq1.js'></script>
<script src='./index-shiyuq2.js'></script>
// index.js
app.module = {}
app.module.name = 'shiyuq'
app.module.age = 18
// index-shiyuq1.js
app.moduleA = {}
app.moduleA.name = 'faker'
app.moduleA.age = 28
// index-shiyuq2.js
app.moduleB = {}
app.moduleB.name = (name) => {
return `hello ${name}`
}
app.moduleB.age = (age) => {
return age + 1
}
此时,已经有隐隐约约的模块化的概念了,只不过是用命名空间来实现的。但还是有个隐性问题,index-shiyuq1.js
的文件作者可以很方便的通过app.module.name
来获取到模块index.js
中的name,当然也可以很方便的去修改它,但是修改却让index.js
毫不知情。这是不允许发生的!
接着,聪明的开发者们又想到了JavaScript的函数作用域,振臂一呼,用闭包可以解决现在的问题。
javascript
// index.html
<script src="./index.js"></script>
<script src="./index-shiyuq1.js"></script>
<script src="./index-shiyuq2.js"></script>
// index.js
app.module = (function() {
var name = 'shiyuq'
var age = 18
return {
getName: () => name,
getAge: () => age
}
})()
// index-shiyuq1.js
app.moduleA = (function() {
var name = 'faker'
var age = 28
return {
getName: () => name,
getAge: () => age
}
})()
// index-shiyuq2.js
app.moduleB = (function(name, age) {
var name = name
var age = age
return {
getName: () => name,
getAge: () => age
}
})('hello shiyuq2', 28 + 1)
现在index-shiyuq2.js
可以通过app.moduleA.getName()
来获取到模块A中的名字,但是各个模块的名字都保存在各自的函数里面,无法修改,但是由于模块的加载有先后顺序,模块B可以访问模块A,但是模块A却无法访问模块B
所以模块化,不仅要处理全局变量污染,数据的保护,还要解决模块间的依赖关系
CommonJS应运而生
为了解决上面出现的一系列问题,所以需要制定模块化的规范,CommonJS就是新的规范,下面来讲解以下CommonJS大致的作用
概述
node应用是由模块组成,采用了CommonJS规范,每个文件就是一个模块,有自己的作用域,且在一个文件中定义的变量、函数和类都是私有的,对其他文件不可见
javascript
// index.js
var age = 28
var getAge = (val) => val - 10
上面的age和getAge都是当前index.js文件私有的,但是如果你想要在多个文件中分享变量,可以使用global
关键字
javascript
global.name = 'shiyuq'
虽然但是,不推荐!
CommonJS规范规定:每个模块内部有两个变量可以使用,分别是require
和module
require:用来加载一些模块
module:代表的是当前模块,是一个对象且上面保存了当前模块的信息。然后它上面有一个exports
属性,保存着当前模块要导出的接口或者变量,使用require
加载其他模块获取的值其实就是module上面的exports属性
javascript
// a.js
var name = 'shiyuq'
var age = 18
module.exports.name = 'shiyuq'
module.exports.age = 18
// b.js
var a = require('a.js') // 使用require加载a模块
console.log(a.name) // shiyuq
console.log(a.age) // 18
CommonJS------exports
为了方便,nodejs在实现CommonJS规范的时候,为每个模块都提供了一个exports
的私有变量,指向了module.exports
,于是你可以理解为exports和module.exports都指向了一个内存地址,所以你给exports中添加属性的同时,module.exports的数据也会同步变化,相当于在每个模块开始的地方,加入了下面一行代码:
javascript
var exports = module.exports
所以上面的代码也可以这么写:
javascript
// a.js
var name = 'shiyuq'
var age = 18
exports.name = 'shiyuq'
exports.age = 18
warning:
由于exports是模块内部的私有局部变量,它是指向了module.exports的地址,所以你直接对他赋值是不可取的,这相当于改变了此变量的内存地址!!!
javascript
// a.js
var name = 'shiyuq'
var age = 18
exports = name // 这个是不可以的哦,因为你想要导出的肯定是name和age两个属性,现在会导致你实际并未往module.exports上赋值
请看下面的代码:
javascript
var module = {exports: {}}
var exports = module.exports
console.log(exports) // {}
console.log(module.exports) // {}
var name = 'shiyuq'
var age = 18
exports = name
console.log(exports) // shiyuq
console.log(module.exports) // {}
同样,如果你对module.exports重新赋值,也是需要注意的地方
javascript
var name = 'shiyuq'
exports.name = name
console.log(module.exports) // {name: 'shiyuq'}
module.exports = 'hello shiyuq'
console.log(module.exports) // 'hello shiyuq'
建议:可以只使用一种导出的方式,建议使用module.exports,写在每个模块的结尾
CommonJS的实现
我们先了解以下CommonJS的一些模块的特点:
1:所有的代码都运行在模块的作用域,不会污染全局作用域
2:模块可以多次加载,但只会在第一次加载时运行一次,之后缓存运行结果,以后再次加载,直接读取缓存结果,想要让模块再次运行,需要清除缓存
3:模块加载的顺序,按照其在代码中出现的顺序
在了解了CommonJS的一些关键规范和特点后,我们不难发现CommonJS的主要使用的技术,离不开三个关键字,就是exports
,module
和require
javascript
// a.js
var name = 'shiyuq'
var age = 18
exports.name = name
exports.age = age
// b.js
var a = require('a.js')
console.log(a.name) // shiyuq
console.log(a.age) // 18
var name = 'jenny'
var age = 17
exports.name = name
exports.age = age
// c.js
var b = require('b.js')
console.log(b.name) // jenny
所以结合第一部分咱们对于Javascript的解析后,不难写出CommonJS的简易实现(使用立即执行函数),将require、exports、module三个参数传入,再把模块代码放入立即执行函数中,模块的导出值放在module.exports中,这样就实现了模块的加载,如下:
javascript
(function(module, exports, require) {
// b.js
var a = require('a.js')
console.log(a.name) // shiyuq
console.log(a.name) // 18
var name = 'jenny'
var age = 17
exports.name = name
exports.age = age
})(module, module.exports, require)
知道了CommonJS的实现原理后,就很容易可以把规范的项目代码转换成浏览器支持的代码,例如咱们熟知的webpack,咱们以webpack为例,看看使用webpack构建的时候,主要做了哪些工作? javascript
javascript
// bundle.js
(function(modules) {
// 模块管理
})({
'a.js': function(module, exports, require) {
// a.js 的文件内容
},
'b.js': function(module, exports, require) {
// b.js 的文件内容
},
'c.js': function(module, exports, require) {
// c.js 的文件内容
}
})
接下来,我们需要按照CommonJS的规范,实现模块管理中的内容,然后,我们知道加载过的模块会被缓存,所以我们需要一个对象来缓存加载过的模块,然后需要一个require函数来加载模块,在加载的时候需要生成一个module,并且module上需要有一个exports属性,用来接收模块导出的内容
javascript
// bundle.js
(function(modules) {
// 模块管理
var cachedModules = {}
// 加载模块的方法
var require = function (moduleName) {
// 如果已经有了缓存,直接返回
if (cachedModules[moduleName]) return cachedModules[moduleName].exports
// 如果没有加载,就生成一个module,并且放入到cachedModules中
var module = {
moduleName,
exports: {}
}
cachedModules[moduleName] = module
// 执行要加载的模块
// 这里的call只是把this指向到了当前的module.exports,然后会把所有的模块中导出的内容自动加载到module的exports属性上
modules[moduleName].call(module.exports, module, module.exports, require)
// 最后返回module.exports
return module.exports
}
return require('a.js')
})({
'a.js': function(module, exports, require) {
// a.js 的文件内容
},
'b.js': function(module, exports, require) {
// b.js 的文件内容
},
'c.js': function(module, exports, require) {
// c.js 的文件内容
}
})
所以上面基本已经实现了CommonJS的核心规范
require文件的加载流程
咱们上面已经讲过了require文件的时候,文件会有缓存,那么咱们一般在开发的时候,会有三种类型,通常是核心模块、文件模块以及第三方模块,那么require在加载的时候,遵循什么规律呢?
核心模块:像fs、http、path等等,都会被识别为nodejs的核心模块
文件模块:像是使用./
、../
作为相对路径的文件模块,/
作为绝对路径的文件模块
第三方模块:非路径形式并且也是非核心模块的模块,被称为第三方模块,比如咱们常用的lodash、moment等等
其中核心模块的加载速度最快,因为已经被编译成二进制代码;而文件模块由于第一次加载会被缓存,所以第二次加载的时候也会很快;所以咱们需要关注的是第三方模块的加载,加载顺序如下:
- 首先是在当前的node_modules目录查找
- 如果没有,在父级目录的node_modules中查找,如果没有,继续向上查找
- 沿着路径递归,直至根目录下的node_modules
- 如果没有,提示模块未找到(module *** not found)
那既然知道了require是如何引入模块的,那么我们来看下面这个问题:循环引入
javascript
// a.js
var b = require('b.js')
console.log('我是a文件')
exports.sayHi = () => {
console.log(b())
}
// b.js
var a = require('a.js')
console.log('我是b文件')
var userInfo = {
name: 'shiyuq',
age: 18
}
module.exports = () => userInfo
// main.js
var a = require('a.js')
var b = require('b.js')
console.log('我是入口文件')
接下来大家可以在终端中输入node main.js
,运行结果如下:
bash
我是b文件
我是a文件
我是入口文件
所以从上面的运行结果,不难看出CommonJS在分析模块的加载阶段,采用的是深度优先遍历,执行的顺序是父->子->父,但是要注意的是在b.js模块还没有加载完成的时候,此时在b.js模块中是没有a模块的sayHi方法的,因为a模块还没有导出sayHi方法【如果你循环引用了,就会出现这样的问题,这在我们的工作中,已经是令人非常头疼的存在】
其他模块化方案
我们学习了CommonJS的模块化规范,知道了它的模块加载机制是同步的,这在服务端是可行的,但是在浏览器端,难免会出现页面假死,阻塞后续代码执行,为了解决这个问题,所以后面又发展了一些其他的模块化规范
后端 | CommonJS | Node.js |
前端 | AMD | RequireJS |
CMD | Sea.js | |
前后端 | ES6 Modules | ES6 |
因为本来对于前端没有进行深入的研究,个人看法是一开始出现的RequireJS其实就是为了解决CommonJS规范不能用于浏览器的问题,而AMD就是RequireJS在推广过程中对模块定义规范化的产出;而后面出现的sea.js,是由于有人觉得AMD规范是异步的,不够自然和垂直,所以创造了sea.js,大家可以像nodejs一样书写模块代码,随之而然形成了CMD规范
CommonJS是服务于服务端的,AMD和CMD是服务于客户端的,但是他们都有一个共同点,就是只有在代码运行后才能确定导出的内容,所以从es6开始,ES6 Module将会取代其他规范,成为两端通用的模块解决方案
ES6 Module
从ES6开始,在语言标准的层面,就实现了模块化功能,具体可以看阮一峰ES6 Module
ES6模块的设计思想是尽可能的静态化,是的编译的时候就能确定模块的依赖关系,输入和输出的变量
javascript
// CommonJS
const {stat, exists, readFile} = require('fs')
// 等同于
const fs = require('fs')
const {stat, exists, readFile} = fs
实际CommonJS是去加载了整个模块,然后生成了一个对象,最后再从该对象上取到我们需要的方法,这种也叫做运行时加载,也就是在程序运行起来的时候才能得到这个对象,而ES6模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入
javascript
// es6
import {stat, exists, readFile} from 'fs'
export和import
export:规定模块的对外接口
import:输入其他模块提供的功能
javascript
// a.js
export var name = 'shiyuq'
export var age = 20
// 也可以这么写;推荐使用,因为你可以在模块的结尾很清楚的知道你导出了哪些变量
var name = 'shiyuq'
var age = 20
exports {
name,
age
}
你还可以重命名
javascript
// 输出函数getName
export function getName (name) {
return 'hello' + name
}
// 你还可以这样
function getAge () {}
export {
getAge as getAgeNew
}
但是你需要特别注意,export命令规定必须与模块内部的变量建立一一对应的关系
javascript
// 错误
export 1
// 错误
var name = 'shiyuq'
export name
上面实际导出的都是一个值,没有对应关系
javascript
// 建立name和shiyuq的对应关系
export var name = 'shiyuq'
// 建立name和shiyuq的对应关系
var name = 'shiyuq'
export {name}
// 建立name和shiyuq的对应关系
var name = 'shiyuq'
export {name as nameNew}
// 错误
function f() {}
export f
// 建立f变量和函数的对应关系
export function f() {}
// 建立f变量和函数的对应关系
function f() {}
export {f}
需要注意的是,export可以出现在模块的任何位置,只要处于模块的顶层,如果处于块级作用域,将会报错
使用了export命令定义了模块的对外接口后,其他的模块可以通过import命令加载这个模块
javascript
// b.js
import {name, age} from 'a.js'
// 重命名
import {name as nameNew, age} from 'a.js'
要注意,import命令具有提升效果,会放到整个模块的顶部,首先执行
javascript
console.log(name)
import {name} from 'a.js'
因为import命令是编译阶段执行的,在代码运行之前
由于import是静态执行,所以不可以使用表达式和变量(他们只能在代码运行的时候才有具体的结果)
我们还可以整体加载
javascript
import * as a from 'a.js'
console.log(a.name) // shiyuq
从上面可以看出使用import的时候,用户需要知道所要加载的变量名或者函数名,否则无法加载,所以为了方便用户,我们可以使用export default
命令
javascript
// a.js
export default function() {
console.log('shiyuq')
}
上面是默认输出的一个匿名函数,我们可以在其他模块中这么使用
css
import a from 'a.js'
a() // shiyuq
所以我们常常会在源码中看到以下代码
javascript
import _ from 'lodash'
// 同时输入默认方法和其他变量
import _, {each} from 'lodash'
//对应的export语句
export default function(obj) {}
export function each(obj, iterator, context) {}
export {each as forEach}
// 如果你想输出默认值
export default 18
// 这里实际是有一个default变量,然后把18赋值给了变量default,所以没有报错
模块加载的实质
CommonJS模块输出的是一个值的浅拷贝,而ES6模块输出的是值的引用,它在遇到import命令时,不去执行模块,而是生成一个动态的只读引用,等真的需要用到的时候再去模块中取值,所以ES6时动态引用,并不会缓存值
javascript
// 举个栗子
let name = 'shiyuq' // 值引用
const exports = {name: name} // 此时开辟了一个堆内存存储并且赋值给exports变量
console.log(exports.name) // shiyuq
exports.name = 'jenny'
console.log(exports.name) // jenny
console.log(name) // shiyuq
// 再看一个
let module = {exports: {}}
const moduleA = (function(module, exports, require) {
let name = 'shiyuq'
const setName = (n) => name = n
const getName = () => name
module.exports = {
name,
setName,
getName
}
return module.exports
})(module, module.exports, {})
console.log(moduleA) // {name: 'shiyuq', getName: f, setName: f}
moduleA.getName() // shiyuq
moduleA.name // shiyuq
moduleA.setName('jenny')
moduleA.getName() // jenny
moduleA.name // shiyuq
现在大家应该了解了为什么内部的变化不会影响到外部的值了,但是这个也仅仅只是针对的原始值,如果是引用值那就会跟着变化了
但是ES6的模块运行机制和CommonJS不一样,它在遇到import时,只是生成一个动态的只读引用,有点像linux里面的软链接,如果原始值变了,import输入的值也会跟着变化
javascript
// a.js
export let counter = 3
export function incCounter() {
counter++
}
// b.js
import {counter, incCounter} from 'a.js'
console.log(counter) // 3
incCounter()
console.log(counter) // 4
可见ES6模块不会缓存运行结果,而是动态获取
ES6模块的循环加载
ES6模块是动态引用,所以那些变量并不会被缓存,而是成为一个指向被加载模块的引用,只要你作为开发者能确保它真的能取到值
javascript
// a.js
import {bar} from 'b.js'
console.log('a.js')
console.log(bar)
export const foo = 'foo'
// b.js
import {foo} from 'a.js'
console.log('b.js')
console.log(foo)
export const bar = 'bar'
上面a模块中引用b,b模块中引用a,造成循环引用,现在执行node a.js
查看运行结果
javascript
b.js
ReferenceError: foo is not defined
这是因为在执行a模块的时候,其中引入了b模块,去b模块中加载,首先打印b.js,然后打印foo,但是此时a模块中并未输出foo接口,所以报错
大家也可以写一个 简单的demo,测试一下ES6打包后的代码,这样可以更加清晰的了解ES6和CommonJS的不同之处