# 运行JS
运行JS的过程,主要包括【预处理阶段】和【执行阶段】:
- 预处理阶段:预处理阶段会做一些事情,确保JS可以正确执行,比如分号补全,变量和函数的声明提升等(由于自动分号补全机制的问题,可能会导致出错,所以虽然有这个预处理阶段,但还是提倡要写分号);
- 执行阶段:执行阶段要涉及【执行上下文、变量对象和活动对象、作用域链、this机制】等知识点。
# 作用域(执行上下文,执行环境)
# 作用域概念
- JS中的执行上下文,又称为作用域,或执行环境。
- JS没有块级作用域,而是通过函数来划分作用域的。只有全局作用域和函数作用域。
# 作用域的作用
每个函数的执行都会创建其对应的函数作用域,作用是:作用域决定了内部变量的生命周期(即何时被释放),以及哪一部分代码可以访问其中的变量
。
# 作用域的工作流程
- 浏览器在首次载入脚本时,会创建全局执行上下文(
全局作用域
),并压入执行栈栈顶(全局执行上下文是永远不会被弹出的
); - 然后每进入其它作用域(即
函数作用域
)就创建对应的执行上下文并把它压入执行栈的顶部,一旦对应的上下文执行完毕,就从栈顶弹出,并将上下文控制权交给当前的执行栈。 - 这样依次执行(最终都会回到全局执行上下文)。
- 当前执行上下文执行完毕时,被弹出执行栈,然后如果其没有被引用(没有形成闭包),那么这个函数中用到的内存就会被垃圾处理器自动回收。
# 函数与变量提升
在作用域中,不管变量和函数写在什么位置
- 所有变量会被整体提升到作用域顶部;
- 所有函数也会被整体提升到作用域顶部;
- 但是函数整体在变量整体的下面(即变量提升的优先级更高,在最顶上)。
- 第一阶段,先提升函数:对所有函数声明进行提升(忽略表达式和箭头函数)
- 第二阶段,再提升变量因此变量在更高的位置:对所有的变量进行提升,全部赋值为 undefined(如果已经存在,不赋值为undefined)
# 例题:函数与变量提升1
DETAILS
console.log(a)
function a() {
console.log('a')
}
var a = 1
console.log(a)
function b() {
console.log(a)
let a = 2
}
b()
// 相当于
var a
function a() {
console.log('a')
}
function b() {
console.log(a)
let a = 2
}
console.log(a) // function a() {console.log('a')}
a = 1
console.log(a) // 1
b() // Uncaught ReferenceError: Cannot access 'a' before initialization
解释:
- 变量提升到最顶部
- 最后b()执行,内部用let声明a,使得函数b内部成为块级作用域,let a之上是暂时性死区
比较下面两个变体:
console.log(a)
function a() {
console.log('a')
}
var a = 1
console.log(a)
function b() {
console.log(a)
var a = 2 // 这里改用var声明a
}
b() // 输出undefined
console.log(a)
function a() {
console.log('a')
}
var a = 1
console.log(a)
function b() {
console.log(a)
var b = 2 // 这里改成声明b
}
b() // 输出1,b里的console输出的是全局作用域的a
# 例题:函数与变量提升2
例题:函数与变量提升
这题就像脑筋急转弯:
function Foo() {
getName = function() {
console.log(1)
}
return this
}
Foo.getName = function() {
console.log(2)
}
Foo.prototype.getName = function() {
console.log(3)
}
var getName = function() {
console.log(4)
}
function getName() {
console.log(5)
}
// 请写出如下输出结果:
Foo.getName() // (1)
getName() // (2)
Foo().getName() // (3)
getName() // (4)
new Foo.getName() // (5)
new Foo().getName() // (6)
new new Foo().getName() // (7)
要解出这道题,关键要搞懂两个知识点:函数变量提升 与 运算优先级。 首先,输出4与5的两处是有变量提升的,提升后如下:
// 提升到顶部
var getName;
function getName() {
console.log(5)
}
function Foo() {
getName = function() {
console.log(1)
}
return this
}
Foo.getName = function() {
console.log(2)
}
Foo.prototype.getName = function() {
console.log(3)
}
// 声明提升到顶部,但函数体还在原处
getName = function() {
console.log(4)
}
// 声明与函数体整体提升到顶部
// function getName() {
// console.log(5)
// }
因此,(1)到(4)输出如下:
Foo.getName() // (1):输出2,直接调用Foo的静态方法
getName() // (2):输出4,由于赋值为4的函数体在最后执行,给getName最终赋值为4
Foo().getName() // (3):输出1,普通调用函数 Foo()返回的 this 指向的是全局对象 window(谁调用指向谁),所以调用的是全局对象的 getName()。其中打印1的getName前面无var,这不是局部函数,而是对全局函数变量getName的重写赋值,所以这里输出的是全局的this。getName,输出1
getName() // (4):输出1,由于前一步中对全局getName变量重新赋值为1,因此这里还是打印1
再考虑第二个关键知识点,运算符优先级:()
> .
> 带参数New
> 无参数New
,因此(5)到(7)输出如下:
new Foo.getName() // (5):输出2,因为.的优先级大于new,先得出2,new 2,最终输出2
new Foo().getName() // (6):输出3,因为()的优先级大于. ,因此new Foo()先实例化得到foo,再计算foo.getName(),则会从原型上找到方法,输出3
new new Foo().getName() // (7):输出3,因为第二个new是带参数的new操作符,所以new Foo()先实例化得到foo,原式等价于new foo.getName(),先计算.操作符得到3,new 3,得到最终3
至此,最终结果为:
// 请写出如下输出结果:
Foo.getName() // (1):2
getName() // (2):4
Foo().getName() // (3):1
getName() // (4):1
new Foo.getName() // (5):2
new Foo().getName() // (6):3
new new Foo().getName() // (7):3
# 怎样理解“父作用域”
注意:函数的父级作用域是指函数定义的时候的父级作用域,不是指执行时候的父级作用域。
- 函数的局部环境不仅有权访问函数作用域中的变量,而且有权访问其包含(父)环境,乃至全局环境;
- 全局环境只能访问在全局环境中定义的变量和函数,而不能直接访问局部环境中的任何数据;
# 作用域的重要属性(3个:变量对象VO,作用域链,this):
# VO与AO
变量对象VO
(Variable object,VO)是执行上下文的属性,活动对象AO
(activation object)是当函数被调用者激活时创建的。
可以理解为:
- 在函数作用域中:VO === AO
- 在全局作用域中:VO === this === global
总的来说,VO中会存放一些变量信息(如声明的变量,函数,arguments参数等等)
# 作用域链有什么作用
- 当代码在一个环境中执行时,会创建关于变量对象的一个作用域链(scope chain)。
- 作用域链的用途,是
保证对执行环境有权访问的所有变量和函数的有序访问
。- 作用域链的前端,始终都是当前执行的代码所在环境的变量对象。
- 如果这个环境是函数,则将其活动对象(activation object)作为变量对象。活动对象在最开始时只包含一个变量,即arguments 对象(这个对象在全局环境中是不存在的)。
- 作用域链中的下一个变量对象来自包含(外部父)环境,而再下一个变量对象则来自下一个包含环境。
- 这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。
- 解析标识符的过程,是沿着作用域链一级一级地搜索标识符。搜索过程始终从作用域链的前端开始, 然后逐级地向上查找,直至找到标识符为止,如果在全局作用域依然没有找到,则报错。
# this指向问题
this是在运行时基于函数的执行环境绑定的,是由其调用方式决定的。
# 绑定规则
默认绑定
:绑定到全局对象window或global,在严格模式下绑定到undefined。隐式绑定
:由上下文对象调用,绑定到那个上下文对象(谁调用,指向谁
)。主动绑定
:由call或者apply(或者bind)调用,绑定到指定的对象。- 由new调用:绑定到新创建的实例对象。
# 例子及回退机制
- 默认绑定
function display(){
console.log(this); // 'this' 将指向全局变量
}
display();
解析:这是一个普通的函数调用。在这种情况下, display() 方法中的 this 在非严格模式下指向 window 或 global 对象。 在严格模式下,this 指向 undefined。
- 隐式绑定
var obj = {
name: 'inch',
display: function(){
console.log(this.name); // 'this' 指向 obj
}
};
obj.display(); // inch
以上就是常规调用规则:谁调用,指向谁。
但是,当将这个函数引用赋值给其他变量,并使用这个新函数引用去调用该函数时,我们在 display() 中获得了不同的this值。
var name = "uh oh! global";
var outerDisplay = obj.display;
outerDisplay(); // uh oh! global
当调用 outerDisplay() 时,我们没有指定一个具体的上下文对象。这是一个没有所有者对象的纯函数调用。在这种情况下,display() 内部的 this 值回退到默认绑定。现在这个 this 指向全局对象,在严格模式下,它指向 undefined。
回退机制:隐式绑定 => 默认绑定
在将隐式绑定的函数以回调的形式传递给另一个自定义函数
、第三方库函数
或者像 setTimeout 这样的内置JavaScript函数
时,都适用于上述情况,回退到默认绑定规则,例如:
var name = "uh oh! global";
var obj = {
name: 'inch',
display: function(){
console.log(this.name); // 'this' 指向 obj
}
};
setTimeout( obj.display, 1000 ); // uh oh! global
分析:当调用 setTimeout 时,JavaScript 在内部将 obj.display 赋给 setTimeout的回调参数 callback。这种赋值操作会导致 display() 函数丢失其上下文。当此函数最终在 setTimeout 函数里面被调用时,display()内部的 this 的值会退回至默认绑定。
- 主动绑定
var name = "uh oh! global";
var obj = {
...
};
obj.display = obj.display.bind(obj);
var outerDisplay = obj.display;
outerDisplay(); // inch
分析:将 this 的值通过 bind() 方法绑定到对象上。即使我们将 obj.display 直接作为 callback 参数传递给函数,display() 内部的 this 也会正确地指向 obj。
# 忘记使用new
如果你不是使用new来调用构造函数,那其实你就是在使用一个实函数。因此this就不会是你预期的值。this指向的就是window,而你将会创建全局变量(不过如果使用的是strict模式,那你还是会得到警告(this===undefined))。
function Point(x, y) {
this.x = x;
this.y = y;
}
// 此处忘记使用 new 了
var p = Point(7, 5);
console.log(p === undefined) // true
// 此时创建了全局变量
console.log(x); // 7
console.log(y); // 5
例题 tiger-fintech
var name = 'tiger'
var handle = function() {
var name = 'fintech'
return `${name}-${this.name}`
}
var departments = {
name: 'trade',
getName: function() {
return `${name}-${this.name}`
},
esop: {
name: 'fe',
getName: function() {
return `${name}-${this.name}`
},
},
other: {
name: 'dev',
getName: function() {
return `${name}-${this.name}`
},
}
}
var getName = departments.getName;
console.log(handle()) // fintech-tiger
console.log(getName()) // tiger-tiger
console.log(departments.getName()) // tiger-trade,注意隐式绑定回退到默认
console.log(departments.esop.getName()) // tiger-fe
console.log(departments.other.getName()) // tiger-tiger
DETAILS
window.name = 'ByteDance';
function A () {
this.name = 123;
}
A.prototype.getA = function(){
console.log(this);
return this.name + 1;
}
let a = new A();
let funcA = a.getA;
console.log(a.getA()); // 124
console.log(funcA()); // ByteDance1
# 闭包
# 什么是闭包
是指有权访问另外一个函数作用域中的变量的函数。解决的问题是在局部作用域中的变量无法被外部访问,这时就可以通过在局部作用域(即这个函数)中创建另一个函数来访问原来这个函数中的变量。
# 创建闭包的常见方式(2种)
- 在一个函数内部创建另外一个函数(通常通过return返回这个函数);
- 通过参数传递将一个函数传入另一个函数中;
# 闭包的缺点
- 滥用闭包会造成内存的大量消耗;消耗内存是因为:
- 正常情况,一个函数在调用开始执行时,创建其执行上下文及相应的作用域链,在函数执行结束后释放函数执行上下文及相应作用域链所占的空间。
- 但是由于闭包函数可以访问外层函数中的变量,所以外层函数在执行结束后,其作用域活动对象并不会被释放(注意,外层函数执行结束后执行环境和对应的作用域链就会被销毁,但活动对象不能被销毁),而是被闭包函数的作用域链所引用,直到闭包函数被销毁后,外层函数的作用域活动对象才会被销毁(
原始数据是保存在栈内存中的,为什么会被闭包保存引用呢?实际上,被闭包引用的原始数据也被存在了堆内存中
)。这也正是闭包要占用内存的原因。
- 使用闭包还有其他的副作用,可以说是bug,也可以说不是:这个副作用是闭包函数只能取到外层函数变量的最终值。这个问题可以通过立即执行函数解决。
清除闭包常驻内存
根据JavaScript回收机制,当一个内存空间没有变量指向的时候就会被回收。那么清除闭包常驻内存的方式就是,将不需要的函数名赋值为null
。
# 模拟块级作用域
# 如何模拟块级作用域
JS没有块级作用域,在其他类C的语言中,由花括号封闭的代码块都有自己的作用域,花括号的代码块执行完毕后,内部定义的变量随即被销毁。而JS中,花括号的外部依然能访问到花括号内部的变量,这会导致一些问题的出现。这就有了模拟块级作用域的方式:立即执行函数表达式IIFE(或者叫自执行匿名函数)。 以下两种方式都是IIFE的有效写法,其本质就是匿名函数的调用。
(function() { /* code */ } ());
(function() { /* code */ })();
- 用圆括号()将匿名函数包裹,js的解释器就会把圆括号()中的内容当做表达式来解释,而后面的第二个圆括号()是执行函数调用的,它写在匿名函数圆括号外部和内部都可以达到同样的目的。
- IIFE可以用来解决闭包只能取到最终值的问题:
function foo() {
var res = new Array();
for (var i = 0; i < 5; i++) {
res[i] = function() {
return i;
};
}
return res;
}
foo() // 5 5 5 5 5
# 闭包函数,只能取到i的最终值
- 函数在预解释阶段,都被当成字符串存入堆内存,在真正执行时,才会被拿出来执行(这里就是上文提到的,
函数在预解释阶段,被闭包引用的原始数据也被存在了堆内存中
)。 - 数组中存储的,其实只是指向这个堆内存的指针,i并没有传进去,执行的时候i才被传进去。
- 每次循环中的函数都会在父层执行环境中寻找i,他们都保有这个变量 i的引用。
- 在预解释阶段执行结束(即循环结束),变量i的值自增为6,才到达函数执行阶段,此时每个函数返回的都是这个最终值6。
function foo() {
var res = [];
for (var i = 0; i < 5; i++) {
res[i] = (function(index) {
return index;
})(i)
}
return res;
}
foo() // [0, 1, 2, 3, 4]
也可以直接使用let的块级作用域
function foo() {
for (var i = 0; i < 6; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
}
foo(); // 6 6 6 6 6 6
function foo() {
for (let i = 0; i < 6; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
}
foo(); // 0 1 2 3 4 5
# 通过IIFE解决闭包bug
- 用立即执行匿名函数包裹起原先的闭包函数,并在第二个()中传入i值,这里的i就是外部执行环境中的i;
- 由于
函数参数
是按值传递
的,所以就会将变量 i 的当前值复制给形参index。 - 闭包引用的变量不会因为垃圾回收机制销毁,所以内部的匿名函数可以返回对应的index值。
# 垃圾回收机制
JS有垃圾处理器,所以无需开发者手动回收内存,而是由垃圾处理器自动处理
。垃圾处理器对于那些执行完毕的函数,如果没有外部引用(被引用的话会形成闭包),则会回收。(当然一般会把回收动作切割到不同的时间段执行,防止影响性能)。
# 常用的两种垃圾回收规则
【标记清除】、引用计数
js引擎基础垃圾回收机制是标记清除
。
- 标记清除:遍历所有可访问的对象并回收已不可访问的对象。
- 引用计数:记录对象被引用的次数,引用时+1,减持时-1,下次垃圾回收器会回收次数为0的对象的内存(容易出循环引用的bug造成内存泄漏)。
# 垃圾回收的时候有个难以避免的问题
垃圾回收时停止响应其他操作。JS的停止响应时间在100ms以上,对于游戏动画影响很大。
# 优化:【分代回收】
避免垃圾回收造成长时间停止响应。区分“临时”与“持久”对象,多回收临时对象,少回收持久对象,减少每次需遍历的对象,从而缩短每次的垃圾回收耗时。Node V8引擎就是采用【分代回收】的,与Java一样。
# 内存泄漏
Q:什么是内存泄漏?
A:申请的内存没有及时回收,被泄漏了
Q:为什么会发生内存泄漏?
A:根本原因是,虽然前端有垃圾回收机制,但当某块无用的内存,却无法被垃圾回收机制认为是垃圾时,也就发生内存泄漏了。而垃圾回收机制通常是使用标志清除策略,简单说,也就是引用从根节点开始是否可达来判定是否是垃圾。
- 直接原因则是,当不同生命周期的两个东西相互通信时,一方生命到期该回收了,却被另一方还持有时,也就发生内存泄漏了。
# 什么会导致内存泄漏
内存泄漏指任何对象在你不再拥有或不再需要它之后仍然存在。【循环引用
】和【闭包
】会导致内存泄漏。只要闭包函数不被销毁,此对象就无法被垃圾回收。
# JS内存泄漏4种场景
# 1. 意外的全局变量
全局变量的生命周期最长,直到页面关闭前,它都存活着,所以全局变量上的内存一直都不会被回收。当全局变量使用不当,没有及时回收(手动赋值 null),或者拼写错误等将某个变量挂载到全局变量时,也就发生内存泄漏了。
比如这个例子,如果bar应该只在foo函数的范围内保存对变量的引用,并且您忘记使用var来声明它,那么会创建一个意外的全局变量:
function foo(arg) {
bar = "this is a hidden global variable";
}
// 实际相当于:
function foo(arg) {
window.bar = "this is an explicit global variable";
}
创建偶然全局变量的另一种方式是:
function foo() {
this.variable = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();
由于foo在全局作用域执行,variable以外成为全局变量。
因此,推荐使用严格模式'use strict'。
# 2. 被遗忘的定时器
setTimeout 和 setInterval 是由浏览器专门线程来维护它的生命周期,所以当在某个页面使用了定时器,当该页面销毁时,没有手动去释放清理这些定时器的话,那么这些定时器还是存活着的。
也就是说,定时器的生命周期并不挂靠在页面上
,所以当在当前页面的 js 里通过定时器注册了某个回调函数,而该回调函数内又持有当前页面某个变量或某些 DOM 元素时,就会导致即使页面销毁了,由于定时器持有该页面部分引用而造成页面无法正常被回收,从而导致内存泄漏了。
如果此时再次打开同个页面,内存中其实是有双份页面数据的,如果多次关闭、打开,那么内存泄漏会越来越严重。而且这种场景很容易出现,因为使用定时器的人很容易遗忘清除。
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
# 3. 闭包的不当使用
函数本身会持有它定义时所在的词法环境的引用,但通常情况下,使用完函数后,该函数所申请的内存都会被回收了 但当函数内再返回一个函数时,由于返回的函数持有外部函数的词法环境,而返回的函数又被其他生命周期东西所持有,导致外部函数虽然执行完了,但内存却无法被回收 所以,返回的函数,它的生命周期应尽量不宜过长,方便该闭包能够及时被回收 正常来说,闭包并不是内存泄漏,因为这种持有外部函数词法环境本就是闭包的特性,就是为了让这块内存不被回收,因为可能在未来还需要用到,但这无疑会造成内存的消耗,所以,不宜烂用就是了
# 4. 遗漏的 DOM 元素
DOM 元素的生命周期正常是取决于是否挂载在 DOM 树上,当从 DOM 树上移除时,也就可以被销毁回收了 但如果某个 DOM 元素,在 js 中也持有它的引用时,那么它的生命周期就由 js 和是否在 DOM 树上两者决定了,记得移除时,两个地方都需要去清理才能正常回收它