该系列文章连载于公众号coderwhy和掘金XiaoYu2002中
对该系列知识感兴趣和想要一起交流的可以添加wx:coderwhy666,拉你进群一起成长进步
课程对照进度:JavaScript高级系列9-12集(coderwhy)
脉络探索
在上一章节中,我们厘清了浏览器内核与JS引擎的关系
也学习了JS代码如何通过V8引擎进行代码转化,进而能够在浏览器中进行执行的
在本章节中,我们就要来探索在JS中,它是如何处理内存的,这很重要。不管是哪门语言,对内存的处理都是核心的内容,只是有些语言会把内存的管理交给你,比如C语言,这就很考验使用者对内存的释放时机
而我们要讲的JS,它所具备的内存管理是自动进行了,不对使用者开放。这在性能极致上,就没办法做到如C语言那么厉害,但对使用者的考验也会降低
内存管理是自动进行了,但不意味着我们不去学习。因为JS的内存管理对我们来说,变成了黑盒一样的存在,我们不知道我们写下这行代码在内存中怎么管理的。一旦我们因为错误的引用持有(例如闭包或全局变量),就会导致
内存泄漏
。到时候想解决,没有对应的知识储备,是很难找到问题的这也是我们要学习内存的意义,那就让我们开始吧!
一、认识内存管理
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。
但不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
第一步:分配申请我们需要的内存(申请);
第二步:使用分配的内存(存放一些东西,比如对象等);
第三步:不需要使用时,对其进行释放;
不同的编程语言对于第一步和第三步会有不同的实现:
手动管理内存
:比如C、C++,包括早期的OC,都是需要手动来管理内存的申请和释放的(malloc和free函数);这种方式需要程序员手动管理内存,容易出现内存泄漏和野指针等问题,程序的稳定性和安全性有一定的风险。
自动管理内存
:比如Java、JavaScript、Python、Swift、Dart等,它们有自动帮助我们管理内存;在这些语言中,存在
垃圾回收机制
来自动回收不再使用的内存空间,程序员只需要正确地使用变量和对象等引用类型数据,垃圾回收器就会自动进行内存管理,释放不再被引用的内存空间。这种方式可以避免内存泄漏和野指针等问题,提高程序的稳定性和安全性
对于开发者来说,JavaScript 的内存管理是自动的、无形的,也是抽象的
我们创建的原始值、对象、函数……这一切都会占用内存
好处在于我们并不需要手动来对它们进行管理,JavaScript引擎会帮助我们处理好它,但同样会带来一定的坏处(人为):我们可能会忽略掉内存管理在其中的作用,导致相关理解产生偏差
1.1. 为什么要学var?
在接下来很长的一部分篇章(讲ES6-ES14之前),我们在声明变量的时候,都会使用
var
,在这里,我要解答大家几个困惑,比如我们为什么要学var?var
其实是JS最初的设计缺陷,它的出现给JS带来了很多负面的作用。比如它是全局的声明。这对作用域边界会造成很强烈的影响,导致我们在使用的时候,不注意就会产生各种变量冲突问题这是可以直接下定义的事情,用var声明变量这一件事情,大部分情况下都是不太好的事情,我们以后写项目也不怎么会去用它,这是毋庸置疑的事情。那为什么它这么不好,我们还要学习它?
声明变量是在所有项目中,用到最多的地方之一,不可能有项目能不声明变量的。而
var
在ES6语法的let
和const
出来之前,是唯一的声明选择,这导致了直到现在大量的项目,内部都还存在大量的var声明变量。如果我们不学,就很难维护,也不好看懂公司都是以稳定为主,没有公司会莫名其妙去重构项目,能跑就不会重写。一开始用的var,我们不懂就维护不了。一定程度上,是我们的需求迫使我们不得不去学习这一稍微落伍的内容
但也不全是被动的原因,通过学习var,我们能够理解到底不好在哪里,对JS的影响在哪一部分,新出的let和const又是如何替代它的,解决了什么问题?
基于此点,还能回答一个面试题:var、let、const的区别是什么?
面试题大家一向是觉得如同造火箭一样的存在,反正我以后正常声明变量就let,遇到不改动的内容就const
完事了,遇到还在维护使用var项目的公司就跑路,基本上不会有问题的。非要这么说,那也没错
但有一个学习它最重要的好处,也是我认为想学好JS是避不开var的。var在JS历史中具备里程碑的意义,虽然它已经落伍了,不中用了。但通过学习它,我们能够知道JS这门语言都是在如何趋向于完美的,这能够补充我们对JS的认知水平,才能对这门语言感同身受和自豪(也许学习起来会更有动力)
可以打个比方,国家如今的强盛并不是突然冒出来的,我们对国家产生自豪感和被其魅力所深深吸引,那就避不开这一路走来的不容易。从建国的艰辛一步步过来,当今外交部对外说的每一句充满底气的话,背后都意味着以往的多少辛酸,同时也对当下有更多的认同
对历史的学习,有助于产生这种作用,这同样对于我们理解JS有巨大帮助,如果我们不是发自内心的认同这门语言,其实是比较难以坚持和难以学好的。对JS认知的提升,在看到对应的代码的时候,就不会没有感觉到被代码所吸引
这点是很重要的,因为学习的过程应该是略有乐趣的,如果硬着吸收,去背去硬记,效率会很低。我们在高中背古诗写历史,和在抖音刷到时,所带来的感受差距有多明显,我想大家都能有所体会
二、执行上下文栈(调用栈)
js引擎内部有一个
执行上下文栈(Execution Context Stack,简称ECS)
,它是用于执行代码的调用栈
那么现在它要执行谁呢?执行的是
全局的代码块
:全局的代码块为了执行会构建一个
Global Execution Context(GEC 全局执行上下文)
GEC会
被放入到ECS中
执行
GEC被放入到ECS中里面包含两部分内容:
第一部分:在代码执行前,在
parser转成AST的过程
中,会将全局定义的变量、函数
等加入到GlobalObject
中,但是并不会赋值
这个过程也称之为
变量的作用域提升(hoisting)
第二部分:在代码执行中,对变量赋值,或者执行其他的函数
三、JavaScript的执行过程
3.1. 初始化全局对象
js引擎会在
执行代码之前
,会在堆内存中创建一个全局对象
:Global Object(GO)该对象 所有的作用域(scope)都可以访问
里面会包含
Date、Array、String、Number、setTimeout、setInterval
等等其中还有一个window属性指向自己
3.2. 代码执行过程
3.2.1. ECMA的版本说明
基于早期ECMA的版本规范:
GEC(global excution context)全局执行上下文:执行全局代码
FEC(functional excution context)函数执行上下文:执行函数代码
每一个执行上下文会被关联到一个变量环境(variable object,VO),在源代码中的变量和函数声明会被作为属性添加到VO中
对于函数来说,参数也会被添加到VO中
解释
不管执行的是哪个,都会关联到VO对象(variable object),只是这个VO对象所代表的东西不一样而已
参数被添加到VO中,是形参的那个地方
3.2.2. 变量执行过程
在JavaScript中,内存分为堆内存和栈内存两部分。堆内存主要用于存储对象类型的数据,而栈内存用于
存储基本类型数据
和执行上下文
而我们目前存储的恰好是
基础数据类型
的一种:Number,这说明这些变量内容是存在栈内存里面的我们的num1在声明之前就打印了,但结果是
undefined
,而不是报错。我们如果想要搞清楚发生了什么,就需要知道在什么情况下会出现undefined
,什么情况下会出现报错
console.log(num1);
var num1 = 20
var num2 = 30
var result = num1 + num2
console.log(result);
3.2.2.1. undefined情况
undefined常见的一共三种情况,变量、对象、函数各一种
所以其实问题就很明显了,num1之所以打印出来undefined,就是因为声明了变量但没有赋值。这就值得说道了,我们明明赋值了20的,怎么能说没有赋值
//如果声明了一个变量但没有赋值,JavaScript会自动将其初始化为undefined
var a;
console.log(a); // 输出:undefined
//尝试访问一个对象的属性,如果该属性没有在对象中定义,则返回undefined
var obj = {};
console.log(obj.prop); // 输出:undefined
//如果一个函数没有明确的返回语句,那么该函数返回undefined
function foo() {}
console.log(foo()); // 输出:undefined
这主要是存在一个变量提升的问题,我们var声明的变量会提升到代码的最前面,就如同下面的这个效果。是不是就跟我们undefined的第一种情况就对应上了
同时,我们可以稍微在深入一点,理解一下LHS(左查询)和RHS(右查询)的概念,L和R分别是Left和Right,很好理解,如图4-1
不管是
var num1
还是num1 = 20
,本身都不会触发左右查询,因为他们躺在那里没人去动他们,查询这个词汇本身就是一种主动的态度而
console.log()
恰好具备了这种主动,查询的是num1,很明显赋值的操作目标
自己跑兜里来了,我们只需要找这赋值的源头。所以这里进行的是RHS(右查询)。不过就这个位置来说,什么都找不到,空的内容在JS不会报错,JS会默认初始化一个undefined回去,所以RHS只能找到undefined,并且带回去交差
LHS:赋值的操作目标是谁
RHS:谁是赋值操作的源头
var num1
console.log(num1);
num1 = 20
//此时在这里打印就能够正常打印
图4-1 赋值的过程
3.2.2.2. 报错情况
在进行RHS右查询的时候,有一个优先度更高的事情会先执行,那就是
变量提升
,也就是我们上面所说的打印出来undefined的情况。在这里,JS引擎已经知道我们num1没有声明了,当一打算对num1进行RHS右查询的时候,JS引擎就会发现刚刚点名没看到num1
你小子,就不会真正进行右查询,而是直接报引用错误了因为声明是给我们分内存空间,没有声明就没有内存空间。这条到内存空间的路叫做
引用
。都没内存空间了,那就相当于没路了,引用就报错了(ReferenceError)
//1.如果尝试使用一个未声明的变量,JavaScript会抛出ReferenceError
console.log(num1); // 报错:ReferenceError: num1 is not defined
//2.在严格模式(use strict;)下,给未声明的变量赋值将抛出错误
'use strict';
y = 10; // 报错:ReferenceError: y is not defined
//3.访问未初始化的let和const变量,会出现暂时性死区问题,这个后面具体会讲,暂时跳过
3.2.2.3. 正常运行情况
var num1 = 20
console.log(num1)
在我们打印的num1中,目前处于的是全局执行上下文
,除此之外,还有函数的作用域所形成的函数执行上下文
(后面讲)
在这里中,当前的环境变量是全局,而全局的内容都在globalObject(全局对象)里面,而全局对象其实就是顶级对象window,则num1必然是存在window里面的,对此,我们通过图4-2来进一步理解
图4-2 全局变量
3.2.3. 函数执行过程
可以看到,我们的foo1和foo2的调用顺序,分别在函数之后与函数之前,对结果的影响并没有发生实际的变化,该打印的内容都打印出来了,难道函数也会像变量一样,提升到最前面去吗?
function foo(){
//foo函数并没有特殊的含义,是编程约定俗成的一种习惯(定义我们不知道要取什么名字的东西)
}
// 函数变量提升
function foo(){
console.log("小余")
}
foo1()
//会在控制台打印出"小余"
foo()
function foo(){
console.log('小余')
}
// 一样在控制台能够打印出来'小余'
如果按顺序执行的话,应该是下面这样的
前面执行的时候,涉及到后面的内容通常是不看的,因为JS一般都是同步执行的,从上往下执行
所以正常情况下,我们执行函数就应该报错才对
foo()
而这就是函数的重点了,函数在内存中的表达情况,并不像num1(基础数据类型)那样是undefined
首先,JS代码在执行之前,还存在一个编译阶段(
预编译和即时编译
)。这个我们在JS引擎如何转化JS代码中,是有实现讲解的顺序是:语法分析 => 预编译 => 即时编译 => 优化,如图4-3
预编译:在这一阶段,引擎会进行
变量提升
等优化措施,确定作用域链等信息,为变量和函数的查找提供方便即时编译:在代码执行的同时,根据代码的运行情况进行优化,将频繁执行的代码编译成机器码,以加快执行速度。这个过程可能会反复进行,以适应代码在运行时的变化
图4-3 预编译与即时编译
3.2.4. 全局函数执行过程
过程是
编译 => 执行
其中编译阶段是js->AST的时候就确立了
当我们创建了函数的时候,js引擎会重新开辟一块空间来进行存储(编译阶段)
保存父级作用域(函数的上一层作用域)
保存函数的执行体(就是执行的代码块)
3.2.4.1. AO对象
函数执行的前一刻,会创建一个称为执行期上下文的内部对象(AO)。一个执行期上下文定义了一个函数执行时的环境,函数每次执行时对应的执行上下文都是独一无二的,所以多次调用一个函数会导致创建多个执行上下文,当函数执行完毕时,它所产生的执行上下文会被销毁
通俗易懂的说就是:
AO对象
在函数执行上下文里面而为函数开辟的内存空间一般是0x开头,如图4-4
我们在
GlobalObject
(全局对象)中一般是放入了我们函数的名字,例如foo然后在全局对象中的foo再引用了对应存储函数的内存空间,也就是保存的是指向该函数的内存地址的指针
foo()的()是调用的意思,执行之后就会放入函数的调用栈中(调用栈会再创建出来一个
函数执行上下文(Functional Execution Context)
,在里面会有一个类似GOglobal object
的东西,叫做AOActivation Object
在执行函数之前会先创建AO对象,会将函数的内容提升到AO对象中(比如对变量的声明),此时函数里面的内容都是undefined,当我们在AO开始执行函数代码的时候,函数内的undefined逐渐被替换掉执行的内容
函数里所有代码执行完了之后,函数的执行上下文就会被弹出栈,执行上下文就会销毁掉。此时AO如果没有人指向它的话,也会跟着一起销毁掉
如果后续在后面又调用了一遍foo(),然后没准还传了一些参数进去,那刚刚的过程又会重复执行了一遍
图4-4 全局函数执行内存图
此时,我们就需要一个更复杂的案例,来验证我们的AO。看在AO之中,是如何
逐渐替换内容
的
foo(123)
var m = 666
function foo(num){
console.log('1',m);
var m = 10
console.log('2',m);
var n = 20
}
在这个案例中,我们的函数还是放到最前面执行,毕竟作为函数,内部的执行体会放到内存中了,在前面运行并不会影响结果
在这里,我们有几个疑问,而这几个疑问会有助于我们了解内存的分配情况:
打印出来的两个m分别都是什么?
第1个m结果和什么有关?第2个m结果和什么有关?
从第一个疑问开始,打印出来的两个m分别是undefined和10
,如图4-5,紧接着我们会产生疑惑,为什么是这两个值?这就和我们第二个问题有关了
第一个m为undefined,这是因为m被var声明了,而变量会被提升到最前面去。需要注意,这里的最前面指的是什么?
我们的最前面,指的是当前作用域的最前面,而不是全部代码的最前面。这个概念很重要
而函数是唯一一个能够产生作用域的东西,所以代码应该是这样的
var m
foo(123)
m = 666
function foo(num){
var m
var n
console.log('1',m);
m = 10
console.log('2',m);
n = 20
}
所有的变量都会提升到当前作用域的最前面,所以能够看到,为什么m在第一次打印出来是undefined,第二次打印出来是10
而第二个问题,两个m都和什么有关系?结果的不同,根据我们的代码拆解,能够明白,内容确实是逐渐填入内存之中的,而不是一个一蹴而就的过程。两个m都和当前指向的内存空间有关系,结果的内容取决于当前内存空间的内容
图4-5 变量提升结果
而作为函数,有自己的AO执行体,这个执行的过程又是怎么样的,在内存中的执行方式,虽然我们在上面已经讲解了,但没有图好像有点抽象,不好理解。我们这里来针对当前的函数案例再画一张图,如图4-6
图4-6 函数内存执行过程
然后,当代码执行结束后,函数执行上下文从栈中被丢出去,此时没有东西继续指向AO对象了,那AO对象就处于一个孤立状态,此时就会被销毁。函数体的内容就会不复存在,直到下一次调用,又会重新创建
AO对象
出来,如图4-7在这里,我提到了一个概念,那就是当没有任何东西指向于AO时,AO会被回收,这个知识点是我们等下会讲到的
而函数里如果还嵌套了函数,就会不断的套娃下去,形成一个连续的指向,这一整条的指向就像一个链子一样,将所有东西串联起来。而这个就会是我们即将讲到的作用域链
图4-7 函数内存执行结束
四、作用域链的查找规则
作用域链是一系列的作用域,它定义了代码在特定部分可以访问的变量。每当代码执行进入一个新的作用域(比如调用一个函数),都会创建一个新的作用域。每个作用域都可以访问其外层的作用域中的变量,但外层作用域不能访问内层作用域的私有变量
4.1. 作用域、作用域链的理解
在 JavaScript 中,当进入一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
作用域链是一个对象列表,用于变量标识符的查找和求值
当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象
作用域链是由当前执行上下文的变量对象(VO)
和所有外部环境的作用域链
组成的,如图4-8所描述的情况
图4-8 作用域链的描述
foo(123)
var m = 666
function foo(num){
console.log('1',m);
var m = 10
console.log('2',m);
var n = 20
}
还是刚刚的案例,我们的console.log()
第一次找m的时候,会在自己当前作用域找是否拥有m这个变量,没有就会往上找,往上是父级作用域,也是我们目前的全局作用域。不过我们当前作用域是有m的,只是处于一个未赋值状态,所以为undefined。如果我们函数体没有声明m的话,就真的会找到外面的666(父级作用域的m值)了
概括的说:查找变量,是沿着
作用域链
进行查找的,从当前作用域往上找图4-9中,向外访问的这个箭头,其实就是形象化的链子。这是一个向外查找的过程
图4-9 作用域查找规则
4.2. AO、GO与VO、VE的区别
而我们在前面讲解AO和GO对象的时候,也进行了区分
全局执行上下文中,VO通常指的是全局对象(Global Object),例如在浏览器中是
window
对象函数执行上下文中,VO通常指的是AO,因为它包含了函数的局部变量和参数
所以VO从某种角度下就是AO和GO,具体是哪一个就需要根据当前所处环境进行决定了。这是最新的叫法,用来取代之前AO与GO的概念的,但其实他们代表的意思是一样的
AO对象
:执行期上下文的对象GO对象
:执行代码之前,堆内存中创建的全局对象VO对象
:VO是一个更通用的概念,它可以指代任何包含变量的环境,包括全局环境和函数的局部环境最新ECMA版本中,VO称呼改变为VE(
Variable Environment
),变量对象 => 变量环境的称呼改变。称呼为环境,会更贴切一点,根据具体的环境进行变化
我们将要看到,在内存之中,作用域链是如何探索AO与GO的
函数的变量是存储在AO对象里的,而全局的变量是存在GO对象里的。这些我们都已经通过画图或者在控制台打印window进行印证了
在接下来的案例中,我们更复杂的常见案例,让我们对
VO和作用域链
的概念能够有更透彻的了解
函数里面如果嵌套函数的话,这个时候进行执行的时候,嵌套函数是没有被编译的,而是预编译。等AO对象被创建的时候(创建前一刻),它才会被正式编译
在经过了几个章节的学习,我想大家对于这个
预
的概念已经非常熟悉了,是一个构建基础架子,而内在细节尚未填充的行为在我们这个案例中,主要探讨的点是:name的结果是什么?
这个问题和我们的作用域链息息相关,我想是比较容易得出答案的。但在内存中的表现形式会相当有趣
var name="XiaoYu"
foo(123)
function foo(num){
console.log("1",m);
var n = 10
var m = 20
function bar(){
console.log("2",name)
}
bar()
}
//结果如下
1 undefined
2 XiaoYu
我们以画图的形式来进行表达,如图4-10
在此代码中,我们需要明确:
而这也是一个指向的过程,从GEC开始指向,执行完的内容会进行内存指向判定,从而进行垃圾回收(内存),这是我们下一章所需要讲解的内容
代码执行之前,AO对象会先创建出来,并进行变量提升(变量为undefined)
后执行代码体,然后对当前的AO对象进行变量的赋值覆盖
图4-10 作用域的内存表达形式
此时需要注意。我们的name为什么是XiaoYu,通过函数具备作用域链中我们可以看到
我们代码的执行顺序,是
从全局开始执行
,遇到了foo函数的时候,创建对应的AO对象,然后执行foo函数体,在foo函数中,又嵌套了一个bar函数,此时注意看bar函数此时会进行预编译,创造对应的AO对象(bar函数的),然后再执行内部的代码此时bar内部的代码是控制台打印name,而我们的name在当前AO对象里面找不到,就会去父级作用域foo函数的AO对象里找,不过也找不到,最后会继续查到GO,也就是全局对象上面。而此时的全局对象刚好有name为"XiaoYu",就被bar函数沿着这一路的
作用域链
所拿走了
同时,我们要清楚知道,父级作用域到底是谁。由于会产生作用域的只有函数,所以其实只有两种情况
对父作用域,其实需要判断考虑的就第二种情况,只需要看函数体的就行了,往外一层就是父级作用域。在哪调用并不会造成父级作用域的改变。因为函数体在内存中是存在具体空间的,而我们在进行
调用
的时候,其实就是一个内存地址去进行引用函数体,而在这个过程中,函数体的位置并不发生变化但调用会影响当前执行上下文的创建和作用域链的构建,这就取决了作用域链是什么样子的了,如图4-11
在全局直接调用函数:该函数的父级作用域直接就是全局对象了
在foo函数内嵌套bar函数:foo函数是bar函数的父级作用域
图4-11 作用域链的查找过程
作用域链所依靠的就是
AO+父级作用域
所形成的,这是它的实现原理,如图4-12目前所画图形并不是全部的内容,在这里面还有两个非常重要的知识点,那就是闭包以及this指向
现在暂时跳过,但我们再过几章就会讲到,不用担心
图4-12 作用域链原理
4.3. var缺陷
但我们只需要把这个嵌套函数案例中,GO的name变量注释掉了,最终打印出来的内容,会是undefined吗?
如图4-13,从浏览器中打开,打印出来的还是XiaoYu,而不是undefined
从终端输出,会报错,也不是undefined
这难道是黑魔法吗?
其实并不是,在原理中,其实已经讲得清清楚楚,明明白白了。我们作用域链最终是找到全局对象的
而我们的var也是赋值给全局对象GO,而此时window对象中,其实默认是存在name这个属性的,我们一旦使用了var,就会赋值到这个上面去,导致哪怕我们注释掉声明name的这一行代码,也会导致赋值操作已经完成,无法通过刷新重置掉
而这也是黑魔法(难以理解的事情)产生的原因,我们只需要把name这个变量名称换一下其实就OK了,换成name1或者names,随意的变量名。只需要不和window里面的属性名重复就行,但之前对name属性名进行覆盖的结果并不会复原
而这也就是var的一个缺陷之一,如果使用var在全局中声明了太多内容,我们很可能就会产生各种内容覆盖冲突问题
而至于为什么终端会直接报错,这是因为终端是node环境,这个全局对象其实是不一样的。如果有讲到Node的话,我们会知道的,这里就暂时跳过
图4-13 var的全局覆盖缺陷
后续预告
在这章节中,我们讲解了JS在执行过程,数据是如何获取的,通过深刻了解作用域链的知识
配合画图的形象表达,从而进一步掌握在内存之中的变化
但我想,我们应该还缺乏一点练习,我决定把题目留下来(不留答案,先想,然后vscode中运行一下进行对照),我们下一章节中进行讲解,然后在深入的去学习一下,JS中,是如何对内存进行回收的
//JS作用域提升题目1-4
//面试题1
var n = 100
function foo(){
n = 200
}
foo()
console.log(n)
//面试题2
function foo(){
console.log(m)
var m = "小余"
console.log(m);
}
var m = "coderwhy"
foo()
//面试题3
var n = 100
function foo1(){
console.log("这是foo1内部",n);
}
function foo2(){
var n = 200
console.log("这是foo2内部",n);
foo1()
}
foo2()
console.log("这是最外层",n);
//面试题4
var a = 100
function foo(){
console.log(a)
return
var a = 200
}
foo()
//面试题5
function foo(){
var a = b = 10
}
foo()
console.log(a);
console.log(b);
通过本章的了解,我们已经很清楚函数执行体在执行结束后就会自动销毁了,主要的其实是AO对象什么时候会被回收,而AO对象中其实存储的都是变量,换句话说,这是在问数据什么时候会被回收
这个是很重要的,因为所有的编程语言,最终其实都是在做一件事情,就是对数据的处理,而数据什么时候回收,意味对数据的应用什么时候结束
后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力