笔者最近在对原生JS的知识做系统梳理,因为我觉得JS作为前端工程师的根本技术,学再多遍都不为过。打算来做一个系列,以一系列的问题为驱动,当然也会有追问和扩展,内容系统且完整,对初中级选手会有很好的提升,高级选手也会得到复习和巩固
第一章:谈谈执行上下文(EC)的理解?
EC (Execution Context) 中文翻译执行上下文,也有翻译成执行环境的。 执行上下文可以简单理解为一个对象:
- 它包含三个部分:
- 变量对象(VO)
- 作用域链(词法作用域)
- this指向
- 它的类型:
- 全局执行上下文
- 函数执行上下文
- eval执行上下文
- 代码执行过程:
- 创建 全局上下文 (global EC)
- 全局执行上下文 (caller) 逐行 自上而下 执行。遇到函数时,函数执行上下文 (callee) 被push到执行栈顶层
- 函数执行上下文被激活,成为 active EC, 开始执行函数中的代码,caller 被挂起
- 函数执行完后,callee 被pop移除出执行栈,控制权交还全局上下文 (caller),继续执行
Javascript中 caller 和 callee 作用?
- **func.caller:**返回调用函数 (调用当前func函数的那个函数)
- **callee:**是返回正在被执行的function函数,也就是所指定的function对象的正文。
:::tips |
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
- 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
- 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。 ::: |
javascript
function foo() {
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
// ReferenceError: d is not defined
// console.log(d)
}
foo()
当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在
| 第一步是**「编译并创建执行上下文」** 通过上图,我们可以得出以下结论:
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
- 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
- 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。 | 第二步 「继续执行代码」 当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示: | | --- | --- | | 从上图你可以清晰地看出变量查找流程,不过要完整理解查找变量或者查找函数的流程,就涉及到作用域链了,这个我们会在下面继续介绍 | 当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如图所示 |
变量对象
VO (Variable Object) 中文翻译变量对象。是执行上下文中的一部分,可以抽象为一种 数据作用域,其实也可以理解为就是一个简单的对象,它存储着该执行上下文中的所有 变量和函数声明(不包含函数表达式)。
活动对象 (AO): 当变量对象所处的上下文为 active EC 时,称为活动对象。
作用域
执行上下文中还包含作用域链。理解作用域之前,先介绍下作用域。作用域其实可理解为该上下文中声明的 变量和声明的作用范围。可分为
- 块级作用域
- 函数作用域
特性: :::warning
- 声明提前: 一个声明在函数体内都是可见的, 函数优先于变量
- 【重点】非匿名自执行函数,函数变量为 只读 状态,无法修改 :::
javascript
let foo1 = function () { console.log("foo1") }
// 如果少了个 ; 会出现的莫名其妙的问题
; (function foo1() {
foo1 = 10 // 由于foo1在函数中只为可读,因此赋值无效
console.log(foo1)
})()
/* 结果打印:
[Function: foo1]
*/
//-------------------------------------------------------------------
let foo2 = function () { console.log("foo2") }
// 如果少了个 ; 会出现的莫名其妙的问题
; (function foo2() {
foo2 = 10 // 由于foo2在函数中只为可读,因此赋值无效
console.log(foo2)
}())
/* 结果打印:
[Function: foo2]
*/
let foo3 = function () { console.log(1) }
//; //会出现的莫名其妙的问题,会解析异常
// ❌ 异常结果输出1
(function foo3() {
foo3 = 10
console.log(foo3)
}())
//-------------------------------------------------------------------
let foo4 = function () { console.log("foo4") };
(function () {
foo4()
foo4 = 10
console.log(foo4)
}())
/*
foo4
10
*/
作用域链
我们知道,我们可以在执行上下文中访问到父级甚至全局的变量,这便是作用域链的功劳。作用域链可以理解为一组对象列表,包含** 父级和自身的变量对象**,因此我们便能通过作用域链访问到父级里声明的变量或者函数。
- 由两部分组成:
- [[scope]] 属性: 指向父级变量对象和作用域链,也就是包含了父级的 [[scope]] 和AO
- AO: 自身活动对象
如此 [[scope]] 包含 [[scope]],便自上而下形成一条 链式作用域。
哪些操作会延长作用域链
:::tips 1、闭包 2、eval函数 3、with语句 :::
第二章: 谈谈你对闭包的理解
红宝书(p178)上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数 ** 闭包产生的本质 当前环境中存在指向父级作用域的引用**
MDN 对闭包的定义为:闭包是指那些能够访问自由变量的函数(其中自由变量,指在函数中使用的,但既不是函数参数arguments也不是函数的局部变量的变量,其实就是另外一个函数作用域中的变量)肯古巴对闭包的理解:闭包属于一种特殊的作用域,称为 静态作用域 。它的定义可以理解为: 父函数被销毁 的情况下,返回出的子函数的 [[scope]] 中仍然保留着父级的单变量对象和作用域链,因此可以继续访问到父级的变量对象,这样的函数称为闭包
闭包会产生一个很经典的问题:
- 多个子函数的 [[scope]] 都是同时指向父级,是完全共享的。因此当父级的变量对象被修改时,所有子函数都受到影响。
解决:
- 变量可以通过 函数参数的形式 传入,避免使用默认的[[scope]]向上查找
- 使用 setTimeout 包裹,通过第三个参数传入
- 使用 块级作用域,让变量成为自己上下文的属性,避免共享
闭包产生的原因?
首先要明白作用域链的概念,其实很简单,在ES5中只存在两种作用域------全局作用域和函数作用域, 当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链 ,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。 比如:
javascript
var a = 1
function f1() {
var a = 2
function f2() {
var a = 3
console.log(a) //3
}
f2()
}
f1()
//----------------------------------------------------------------------
var a = 1
function f1() {
var a = 2
function f2() {
var a = 3
console.log(a) //3
}
return f2
}
f1()()
//----------------------------------------------------------------------
function createIncrement(i) {
let value = 0 //会缓存value、i的变量
setTimeout(() => {
console.log(value);
}, 2000) //2秒后输出3
console.log("value:", value) //value: 0
return function increment() {
value += i
console.log(value)
}
}
let a = 1
const inc = createIncrement(a)
inc() //1
inc() //2
inc() //3
console.log(a) //1
在这段代码中,f1的作用域指向有全局作用域(window)和它本身,而f2的作用域指向全局作用域(window)、f1和它本身。而且作用域是从最底层向上找,直到找到全局作用域window为止,如果全局还没有的话就会报错。就这么简单一件事情! 闭包产生的本质就是,当前环境中存在指向父级作用域的引用。还是举上面的例子:
javascript
function f1() {
var a = 2
function f2() {
console.log(a) //2
}
return f2
}
f1()()
这里x会拿到父级作用域中的变量,输出2。因为在当前环境中,含有对f2的引用,f2恰恰引用了window、f1和f2的作用域。因此f2可以访问到f1的作用域的变量 那是不是只有返回函数才算是产生了闭包呢? 回到闭包的本质,我们只需要让父级作用域的引用存在即可,因此我们还可以这么做:
javascript
var f3
var a = 1
function f1() {
console.log(a) //undefined
var a = 2
f3 = function () {
console.log(a) //2
}
}
f1()
f3()
让f1执行,给f3赋值后,等于说现在 f3拥有了window、f1和f3本身这几个作用域的访问权限 ,还是自底向上查找,最近是在f1中找到了a,因此输出2 在这里是外面的变量 f3存在着父级作用域的引用,因此产生了闭包,形式变了,本质没有改变 再看一条经典的面试题:
java
var a = 0, b = 0
function A(a) {
A = function (b) {
console.log(a + a++)
}
console.log(a++)
}
A(1) //1
A(2) //4
为什么闭包函数能够访问其他函数的作用域?
从堆栈的角度看待js函数 基本变量的值一般都是存在栈内存中,而对象类型的变量的值存储在堆内存中,栈内存存储对应空间地址。基本的数据类型: number 、boolean、undefined、string、null
javascript
var a = 1 //a是一个基本类型
var b = {m: 20 } //b是一个对象
当我们执行 b={m:30}时,堆内存就有新的对象{m:30},栈内存的b指向新的空间地址( 指向{m:30} ),而堆内存中原来的{m:20}就会被程序引擎垃圾回收掉,节约内存空间。我们知道js函数也是对象,它也是在堆与栈内存中存储的,我们来看一下转化:
javascript
var a = 1;
function fn(){
var b = 2
function fn1(){
console.log(b)
}
fn1()
}
fn()
栈是一种先进后出的数据结构
- 在执行fn前,此时我们在全局执行环境(浏览器就是window作用域),全局作用域里有个变量a;
- 进入fn,此时栈内存就会push一个fn的执行环境,这个环境里有变量b和函数对象fn1,这里可以访问自身执行环境和全局执行环境所定义的变量
- 进入fn1,此时栈内存就会push 一个fn1的执行环境,这里面没有定义其他变量,但是我们可以访问到fn和全局执行环境里面的变量,因为程序在访问变量时,是向底层栈一个个找,如果找到全局执行环境里都没有对应变量,则程序抛出underfined的错误。
- 随着fn1()执行完毕,fn1的执行环境被杯销毁,接着执行完fn(),fn的执行环境也会被销毁,只剩全局的执行环境下,现在没有b变量,和fn1函数对象了,只有a 和 fn(函数声明作用域是window下)
在函数内访问某个变量是根据函数作用域链来判断变量是否存在的,而函数作用域链是程序根据函数所在的执行环境栈来初始化的,所以上面的例子,我们在fn1里面打印变量b,根据fn1的作用域链的找到对应fn执行环境下的变量b。所以当程序在调用某个函数时,做了一下的工作:准备执行环境,初始函数作用域链和arguments参数对象
闭包有哪些表现形式?
明白了本质之后,我们就来看看,在真实的场景中,究竟在哪些地方能体现闭包的存在?
- 返回一个函数。刚刚已经举例
javascript
function f1() {
var a = 2
return function f2() {
console.log(++a)
}
}
var x = f1()
x() //2
x() //4
x() //5
- 作为函数参数传递
javascript
var a = 1
function foo() {
var a = 10
function baz() { console.log(++a) }
bar(baz)
}
function bar(fn) {
fn() //11
fn() //12
fn() //13
}
foo()
//------------------------------------------------------------------
// 这种不是闭包,因为访问的变量a是window的
var a = 1
function baz() { console.log(++a) }
function foo() {
var a = 10
bar(baz)
}
function bar(fn) {
fn() //2
fn() //3
fn() //4
}
foo()
//------------------------------------------------------------------
//结合了以上两种方法
var a = 1
function foo() {
var a = 10
//引用了父级函数的变量,是闭包
function baz(b) { console.log(`执行第${b}次闭包输出的:`, ++a) }
return bar(baz)
}
function bar(fn) {
//引用了全局window的变量,不是闭包
return () => { fn(a++) }
}
var fn = foo()
fn()
fn()
fn()
fn()
fn()
/*
执行第1次闭包输出的: 11
执行第2次闭包输出的: 12
执行第3次闭包输出的: 13
执行第4次闭包输出的: 14
执行第5次闭包输出的: 15
*/
- 在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包
以下的闭包保存的仅仅是window和当前作用域
javascript
// 定时器
setTimeout(function timeHandler() {
console.log('定时器 setTimeout')
}, 100)
// 事件监听
$('#app').click(function () {
console.log('DOM Listener')
})
- IIFE(立即执行函数表达式)创建闭包, 保存了 全局作用域window 和 当前函数的作用域,因此可以输出全局的变量
javascript
var a = 2
;(function IIFE() {
console.log(a) // 输出2
})()
java
var test = (function (i) {
return function () {
// 访问父级函数的形参i,因此这是个闭包closure,i会缓存
console.log(i *= 2)
}
})(2)
//考察的是闭包,现在这跟test里面的实参没有关系,i会缓存,屏蔽外边的变量
let i = 2
test(2 * i) //4
test(5 * i) //8
test() //16
test() //32
闭包各种坑
引用的变量可能发生变化
看样子result每个闭包函数对打印对应数字,1,2,3,4,...,10, 实际不是,因为每个闭包函数访问变量i是outer执行环境下的变量i,随着循环的结束,i已经变成10了,所以执行每个闭包函数,结果打印10, 10, ..., 10
javascript
//问题
function outer() {
var result = []
for (var i = 0;i<10;i++){
result[i] = function () {
console.info(i)
}
}
return result
}
//解决方案
function outer() {
var result = []
for (var i = 0; i < 10; i++) {
result[i] = (function (num) {
return function () {
// 此时访问的num,是上层函数执行环境的num,数组有10个函数对象,每个对象的执行环境下的number都不一样
console.info(num);
}
})(i)
}
return result
}
循环输出问题带来的坑?
javascript
for(var i = 1; i <= 5; i ++){
setTimeout(function timer(){
console.log(i)
}, 0)
}
为什么会全部输出6?如何改进,让它输出1,2,3,4,5?(方法越多越好) 因为setTimeout为宏任务,由于JS中单线程eventLoop机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,但输出i的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了6。因此会全部输出6 解决方法: 1、给定时器传入第三个参数, 作为timer函数的第一个函数参数
javascript
for (var i = 1; i <= 5; i++) {
setTimeout(function timer(params) {
console.log(params)
}, 0, i)
}
2、利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中
javascript
for (var i = 1; i <= 5; i++) {
(function (params) {
setTimeout(function timer() {
console.log(params)
}, 0)
})(i)
}
3、使用ES6中的let
javascript
for(let i = 1; i <= 5; i++){
setTimeout(function timer(){
console.log(i)
},0)
}
let使JS发生革命性的变化,让JS有函数作用域变为了块级作用域,用let后作用域链不复存在。代码的作用域以块级为单位,以上面代码为例:
this指向问题
javascript
var object = {
name: "一缕清风",
getName: function () {
console.info(this.name)
}
}
object.getName() // 一缕清风
//--------------------------------------
//使用闭包需要小心陷阱
var object = {
name: "一缕清风",
getName: function () {
return function () {
console.info(this.name)
}
},
getName2: () => {
return function () {
console.info(this.name)
}
}
}
// 因为里面的闭包函数是在window作用域下执行的,也就是说,this指向windows
object.getName()() // underfined
object.getName2()() // underfined
内存泄露问题
javascript
<button id="app">按钮</button>
<button id="app2">解决内存泄露无法被释放</button>
<script>
function show() {
var el = document.getElementById("app")
el.onclick = function () {
alert(el.id) // 这样会导致闭包引用外层的el,当执行完showId后,el无法释放
}
}
show()
// 改成下面解决内存泄露,el无法释放的问题
function show2() {
var el = document.getElementById("app2")
let id = el.textContent
el.onclick = function () {
alert(id)
}
el = null // 主动释放el
}
show2()
</script>
闭包过时的问题
javascript
function createIncrement(i) {
let value = 0
return function increment() {
value += i
console.log(value)
const message = `Current value is ${value}`
return function logValue() {
console.log(message)
}
}
}
const inc = createIncrement(1)
const log1 = inc() // 1
const log2 = inc() //2
const log3 = inc() //3
// log1()是过时的闭包。在第一次调用 inc() 时,闭包 log() 捕获了具有 "Current value is 1" 的 message 变量。而现在,当 value 已经是 3 时,message 变量已经过时了。
// 过时的闭包捕获具有过时值的变量。
log1() // 无法正确工作 打印 "Current value is 1"
//---------- 修复过时闭包的问题 ----------
// 方法1: 使用新的闭包
// 解决过时闭包的第一种方法是找到捕获最新变量的闭包。
// 咱们找到捕获了最新 message 变量的闭包。就是从最后一次调用 inc() 返回的闭包。
log3() //Current value is 3
//方法2: 关闭已更改的变量
function createIncrementFixed(i) {
let value = 0
function increment() {
value += i
console.log(value)
return function logValue() {
const message = `Current value is ${value}`
console.log(message)
}
} return increment
}
const inc = createIncrementFixed(1)
const log = inc()
inc()
inc()
log() //Current value is 3"
第三章:闭包记忆函数
第四章:实现一个once函数,传入函数参数只执行一次
javascript
function once(fn) {
let executed = false;
return function () {
if (executed) return;
executed = true;
return fn.apply(this, arguments);
};
}
let fn = once(() => { console.log('===================================='); })
fn()
fn()
第五章:偏函数
什么是偏函数? 偏函数就是将一个 n 参的函数转换成固定 x 参的函数,剩余参数(n - x)将在下次调用全部传入
javascript
function partial(fn, ...args1) {
return (...args2) => {
return fn(...args1, ...args2)
}
}
function add(a, b, c) {
return a + b + c
}
let partialAdd = partial(add, 1)
console.log(partialAdd(2, 3))
第六章:柯里化函数
什么叫函数柯里化? 其实就是将使用多个参数的函数转换成一系列使用一个参数的函数的技术
javascript
function curryDecorate(fn) {
let judge = (...args) => {
// console.log(fn.length,`打印输出fn的形参个数`)
if (args.length == fn.length) return fn(...args)
return (...arg) => judge(...args, ...arg)
}
return judge
}
function add(a, b, c) {
return a + b + c
}
console.log(add(1, 2, 3))
add = curryDecorate(add)
console.log(add(1)(2)(3))
console.log(add(4,5)(6))
console.log(add(7)(8,9))
/*
6
6
15
24
*/
javascript
function curryDecorate(fn) {
return judge = (...args) => {
return (...arg) => {
if (arg.length == 0) return fn(...args);
return judge(...args, ...arg)
}
}
// return judge
}
function add(...args) {
return args.reduce((previousValue, currentValue) => {
return previousValue + currentValue
}, 0)
}
console.log(add(1, 2, 3))
add = curryDecorate(add)
console.log(add(1)(2)(3)())
console.log(add(4, 5)(6)())
console.log(add(7)(8, 9)())
console.log(add(10, 11, 12, 13)(14, 15, 16, 17)(18, 19)())
/*
6
6
15
24
145
*/
参考文献
:::info 函数篇(上) 读李老课程引发的思考之JS执行机制 :::