JS_基础知识点精讲
成功才是成功之母。这是心理学的胜者恒胜的”胜利者效应“:成功不是用失败累加而来的,而是需要用胜利来激励。
大家好,我是柒八九。
今天,我们继续前端面试的知识点。我们来谈谈关于JavaScript的相关知识点和具体的算法。
该系列的文章,大部分都是前面文章的知识点汇总,如果想具体了解相关内容,请移步相关系列,进行探讨。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
好了,天不早了,干点正事哇。
你能所学到的知识点
- JS 组成
- 基本数据类型
- 类型转换(装箱/拆箱)
- 作用域 & 执行上下文
- 调用栈
- 闭包
Environments
: JS变量查找的底层实现- JS 深浅复制
Event Loop
- ES6遍历对象的属性 (5种)
- 垃圾回收机制
- 内存问题
JS 组成
在浏览器环境下,JS = ECMAScript + DOM + BOM
。
::: block-1
ECMAScript
JS的核心部分,即
ECMA-262
定义的语言,并不局限于 Web 浏览器。
Web 浏览器只是 ECMAScript
实现可能存在的一种 {宿主环境|Host Environment}。而宿主环境提供 ECMAScript
的基准实现和与环境自身交互必需的扩展。(比如 DOM 使用 ECMAScript
核心类型和语法,提供特定于环境的额外功能)。
像我们比较常见的 Web 浏览器
、 Node.js
和已经被淘汰的 Adobe Flash
都是ECMAScript
的宿主环境。
ECMAScript 只是对实现ECMA-262规范的一门语言的称呼
JS
实现了ECMAScript
Adobe ActionScript
也实现ECMAScript
:::
::: block-1
文档对象模型(DOM)
DOM
是一个应用编程接口(API),通过创建表示文档的树,以一种独立于平台和语言的方式访问和修改一个页面的内容和结构。
DOM 将整个页面抽象为一组分层节点
DOM 并非只能通过 JS 访问, 像
- 可伸缩矢量图(
SVG
) - 数学标记语言(
MathML
) - 同步多媒体集成语言(
SMIL
)
都增加了该语言独有的 DOM
方法和接口。
:::
::: block-1
浏览器对象模型(BOM)
用于支持访问和操作浏览器的窗口。
DOM VS BOM
浏览器的全部内容可以看成DOM,整个浏览器可以看成BOM
:::
基本数据类型
数据类型分类(7+1)
undefined
null
Boolean
String
Number
Symbol
(es6)BigInt
(es2020)Object
- {常规对象|Ordinary Object}
- {异质对象|Exotic Object}
存储位置不同
- (1 - 7) :栈内存 (基本primary数据类型)
- (8): 堆内存
判断数据类型的方式 (TTIC)
-
typeof
- 判断基本数据类型
typeof null
特例,返回的是"object"
-
Object.prototype.toString.call(xx)
- 判断基本数据类型
- 实现原理:
- 若参数(xx)不为
null
或undefined
,则将参数转为对象,再作判断 - 转为对象后,取得该对象的
[Symbol.toStringTag]
属性值(可能会遍历原型链)作为tag
,然后返回"[object " + tag + "]"
形式的字符串。
- 若参数(xx)不为
-
instanceof
a instanceof B
判断的是a
和B
是否有血缘关系,而不是仅仅根据是否是父子关系。- 在ES6中
instanceof
操作符会使用Symbol.hasInstance
函数来确定关系。
-
constructor
- 只要创建一个函数,就会按照特定的规则为这个函数创建一个
prototype
属性(指向原型对象)。 - 默认情况下,所有原型对象自动获得一个名为
constructor
的属性,指回与之关联的构造函数。 - 每次调用构造函数创建一个新实例,实例的内部
[[Prototype]]
指针就会被赋值为构造函数的原型对象。 - 实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有
- 通过实例和构造函数原型对象的关系,来判断是否实例类型。
null/undefined
是一个假值,没有对应包装对象(无法进行装箱操作),也不是任何构造函数的实例。所以,不存在原型,即,无法使用constructor
判断类型。
- 只要创建一个函数,就会按照特定的规则为这个函数创建一个
undefined vs null
在JS中,存在两个空值
undefined
null
从使用上对其进行分类
undefined
: 是语言层面上使用的非值(定义一个变量,但未赋值,此时该变量会被JS引擎自动赋为undefined
)null
: 蓄意控制变量的值
如何产生undefined
和null
undefined
的产生
- 定义一个变量(
myVar
)但未进行初始化 - 调用函数,但是未提供参数(
x
) - 访问对象中不存在的属性(
.unknownProp
) - 调用一个没有
return
语句的函数
null
的产生
Object.prototype
不存在原型对象且值为null
- 正则匹配失败
- JSON格式的数据不支持
undefined
,支持null
类型转换(装箱/拆箱)
基本类型是没有任何属性和方法
其实,针对基本类型的属性和方法的调用,都是在基本类型的包装对象上进行操作。
装箱转换
每一种基本类型 String
、Boolean
、Number
、Symbol
、BigInt
在对象中都有对应的类。
特例:null/undefined
是一个假值,没有对应包装对象(无法进行装箱操作)
所谓装箱转换,正是把基本类型转换为对应的对象,它是类型转换中一种相当重要的种类。
let str = '789';
str.length; //3 属性调用
str.slice(1); // "89" 方法调用
=======等价于
let strObj = new String(789);
strObj.length; //3
strObj.slice(1); //"89"
拆箱转换
在
JavaScript
标准中,规定了ToPrimitive
函数,它是对象类型到基本类型的转换(即,拆箱转换)。
对象到 String
和 Number
的转换都遵循先拆箱再转换的规则。通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String
或者 Number
。
拆箱转换会尝试调用 valueOf
和 toString
来获得拆箱后的基本类型。如果 valueOf
和 toString
都不存在,或者没有返回基本类型,则会产生类型错误 TypeError
。
let o = {
valueOf : () => {console.log("valueOf"); return {}},
toString : () => {console.log("toString"); return {}}
}
o * 2
// valueOf
// toString
// TypeError
对象的 Symbol.toPrimitive
属性,指向一个方法。该对象被转为原始类型的值时,会调用这个方法,返回该对象对应的原始类型值。
let o = {
valueOf : () => {console.log("valueOf"); return {}},
toString : () => {console.log("toString"); return {}}
}
o[Symbol.toPrimitive] = () => {console.log("toPrimitive"); return "hello"}
console.log(o + "")
// toPrimitive
// hello
作用域 & 执行上下文
{作用域|Scopes}
变量的{词法作用域|Lexical Scopes} (简称:作用域)是程序中可以访问变量的区域。作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
JS的作用域是静态的(不会在运行时被改变)
{词法环境|Lexical Environments}
在ECMA-262
语言规范中定义:作用域是通过词法环境实现的。
词法环境由两个重要部分组成:
- {环境记录|Environment Record} : 将变量名映射到变量值
{name,value}
。这是作用域变量的实际存储空间。记录中的「名称-值」条目称为绑定。OuterEnv
内部属性:指向{外部环境|Outer Environment}的引用
Environment Record
(以下简称:ER
) 是一个抽象类,它由三个具体的子类实现:
- 声明式ER
- 声明式ER又派生出
-
- 函数ER
- 2.
module
ER
- 对象ER : 通过
with
扩展作用域链 - 全局ER
我们可以这样认为:
作用域被分为3大类
- 声明式作用域
- 函数作用域
- module作用域
- 对象作用域
- 全局作用域
::: block-1
声明式作用域
声明式ER可以通过 var/const/let/class/module/import/function
生成。
常说的ES6块级作用域和函数作用域属于同一大类(声明式作用域)。
根据实现层级,还有一个更准确的结论:
ES6块级作用域是函数作用域的子集 :::
::: block-1
全局作用域
全局作用域是最外面的作用域,它没有外部作用域。即全局环境的OuterEnv
为null
。
全局ER使用两个ER来管理其变量:
- 对象ER
- 将变量存储在全局对象中
- 顶层作用域下,
var
和function
声明的变量被绑定在对象ER里(在浏览器环境下,window
指向全局对象)
- 声明式ER
- 使用内部对象来存储变量
- 顶层作用域下,
const/let/class
声明的变量被绑定在声明ER
当声明式ER和对象ER有共同的变量,声明式优先级高。 :::
执行上下文
执行上下文是执行某段代码的函数的环境。每个函数都有自己的执行上下文。
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a) // {A}
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo() // 1,3,2,4,d is not defined
执行JS代码核心流程
- 先编译
- 后执行
针对如上代码,先对其进行编译并创建执行上下文,然后再按照顺序执行代码。
- 函数内部通过
var
声明的变量在编译阶段全都被存放到变量环境里面了。通过let/const
声明的变量,在编译阶段会被存放到{词法环境|Lexical Environments}中 - 继续执行代码,当执行到代码块里面时,变量环境中
a
的值已经被设置成了 1,词法环境中b
的值已经被设置成了 2。 当进入函数的作用域块,作用域块中通过let
声明的变量会被存放在词法环境的一个单独的区域中。这个区域中的变量并不影响作用域块外面的变量。在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶。当作用域执行完成之后,该作用域的信息就会从栈顶弹出。
当执行到作用域块中的{A}这行代码时就需要在词法环境和变量环境中查找变量 a
的值。
具体查找方式是:
- 沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给
JavaScript
引擎。 - 如果没有查找到,那么继续在变量环境中查找
作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
其实,在ECMA262
规范定义中,针对执行上下文还有更多的属性和方法。其中,最重要的就是我们上文讲到的词法环境和语法环境(变量环境)。
作用域和执行上下文的关系
- 作用域只是执行上下文有权访问的一组有限的变量/对象
- 同一个执行上下文上可能存在多个作用域
作用域链
在 JS 执行过程中,其作用域链是由词法作用域决定的。
变量的可访问性在编译阶段(执行之前)已经确定了。 所以,在函数进行变量查找时,我们只根据词法作用域(函数编码位置)来确定变量的可见性。
function tool(){
console.log(myName)
}
function test(){
var myName = 'inner';
tool();
}
var myName = 'outer';
test(); // outer
词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
块级作用域
ES6
是支持块级作用域的,当执行到代码块时,如果代码块中有 let
或者 const
声明的变量,针对变量的查询路径为:
- 词法环境
- 语法环境(变量环境)
OuterEnv
对象 (上一层作用域继续先1后2)
调用栈
{调用栈|Call Stack}是JS的基本组成部分。它是一个{记录保存结构|Record-keeping Structure}允许我们能够执行函数调用的操作。
在调用栈中,每一个函数调用被一种叫做{栈帧|Frame}的数据结构所替代。(栈帧中包含对应函数的执行上下文)该结构能够帮助JS引擎(V8)保持函数之间的调用顺序和关系。并且能够在某个函数结束后,利用存储在栈帧中的信息,执行剩余的代码。使得JS应用拥有记忆。
当JS代码第一次被执行时,此时的调用栈是空的。只有在第一个函数被调用时候,才会向调用栈的栈顶推入(push
)该函数对应的栈帧。当函数执行完成(执行到return
语句),对应的栈帧会从调用栈中抛出(pop
)。
{调用栈|Call Stack} 用于追踪函数调用。是LIFO(后进先出)的栈结构。每个栈帧代表一次函数调用。
function bar() {
}
function foo(fun){
fun()
}
foo(bar)
V8 准备执行这段代码时
- 先将全局执行上下文压入到调用栈
V8
在主线程上执行foo
函数,创建 foo 函数的执行上下文,并将其压入栈中V8
执行bar
函数时,创建 bar 函数的执行上下文,并将其压入栈中bar
函数执行结束,V8
就会从栈中弹出bar
函数的执行上下文foo
函数执行结束,V8
会将foo
函数的执行上下文从栈中弹出
堆栈溢出
过多的执行上下文堆积在栈中便会导致栈溢出
function foo(){
foo()
}
foo()
foo
函数内部嵌套调用它自己,调用栈会一直向上增长。最后会爆栈,把主线程给阻塞。
使用 setTimeout
来解决栈溢出的问题
setTimeout
的本质是将同步函数调用改成异步函数调用
function foo() {
setTimeout(foo, 0)
}
foo()
异步调用是将 foo 封装成事件,并将其添加进消息队列中,主线程再按照一定规则循环地从消息队列中读取下一个任务。
- 主线程会从消息队列中取出需要执行的宏任务
V8
将要执行foo
函数,并创建foo
函数的执行上下文,将其压入栈中V8
执行 foo 函数中的setTimeout
时,setTimeout
会将 foo 函数封装成一个新的宏任务,并将其添加到消息队列中。foo
函数执行结束,V8
就会结束当前的宏任务,调用栈也会被清空- 当一个宏任务执行结束之后,主线程会一直重复取宏任务、执行宏任务的过程,通过
setTimeout
封装的回调宏任务,会在某一时刻被主线取出并执行
foo
函数并不是在当前的父函数内部被执行的,而是封装成了宏任务,并丢进了消息队列中,等待主线程从消息队列中取出该任务,再执行该回调函数 foo
。
闭包
函数即对象
在JS中,一切皆对象。那从语言的设计层面来讲,函数是一种特殊的对象。
函数和对象一样可以拥有属性和值。
function foo(){
var test = 1
return test;
}
foo.myName = 1
foo.obj = { x: 1 }
foo.fun = function(){
return 0;
}
根据对象的数据特性:foo
函数拥有myName
/obj
/fun
的属性
但是函数和普通对象不同的是,函数可以被调用。
从V8内部来看看函数是如何实现可调用特性
在 V8 内部,会为函数对象添加了两个隐藏属性
name
属性:属性的值就是函数名称code
属性:表示函数代码,以字符串的形式存储在内存中
::: block-1
code 属性
当执行到,一个函数调用语句时,V8
便会从函数对象中取出 code
属性值(也就是函数代码),然后再解释执行这段函数代码。
在解释执行函数代码的时候,又会生成该函数对应的执行上下文,并被推入到调用栈里。 :::
闭包
在 JS 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。 当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了。但是内部函数引用外部函数的变量依然保存在内存中,就把这些变量的集合称为闭包。
function test() {
var myName = "fn_outer"
let age = 78;
var innerObj = {
getName:function(){
console.log(age);
return myName
},
setName:function(newName){
myName = newName
}
}
return innerObj
}
var t = test();
console.log(t.getName());//fn_outer
t.setName("global")
console.log(t.getName())//global
- 根据词法作用域的规则,内部函数
getName
和setName
总是可以访问它们的外部函数test
中的变量- 在执行
test
时,调用栈的情况
- 在执行
test
函数执行完成之后,其执行上下文从栈顶弹出了- 但是由于返回
innerObj
对象中的setName
和getName
方法中使用了test
函数内部的变量myName
和age
所以这两个变量依然保存在内存中(Closure (test)
)
- 但是由于返回
- 当执行到
t.setName
方法的时,调用栈如下: - 利用debugger来查看对应的作用链和调用栈信息
通过上面分析,然后参考作用域的概念和使用方式,我们可以做一个简单的结论
闭包和词法环境的强相关
而JS的作用域由词法环境决定,并且作用域是静态的。
所以,我们可以得出一个结论:
闭包在每次创建函数时创建(闭包在JS编译阶段被创建)
闭包是如何产生的?
产生闭包的核心两步:
- 预扫描内部函数
- 把内部函数引用的外部变量保存到堆中
function test() {
var myName = "fn_outer"
let age = 78;
var innerObj = {
getName:function(){
console.log(age);
return myName
},
setName:function(newName){
myName = newName
}
}
return innerObj
}
var t = test();
当 V8
执行到 test
函数时
-
首先会编译,并创建一个空执行上下文。
- 在编译过程中,遇到内部函数
setName
, V8还要对内部函数做一次快速的词法扫描(预扫描) 发现该内部函数引用了 外部函数(test
)中的myName
变量 - 由于是内部函数引用了外部函数的变量,所以 V8 判断这是一个闭包
- 于是在堆空间创建换一个
closure(test)
的对象 (这是一个内部对象,JavaScript
是无法访问的),用来保存myName
变量
- 在编译过程中,遇到内部函数
-
当
test
函数执行结束之后,返回的getName
和setName
方法都引用clourse(test)
对象。- 即使
test
函数退出了,clourse(test)
依然被其内部的getName
和setName
方法引用。
- 即使
-
所以在下次调用
t.setName
或者t.getName
时,在进行变量查找时候,根据作用域链来查找。
闭包示例
Event handler
let countClicked = 0;
myButton.addEventListener('click', function handleClick() {
countClicked++;
myText.innerText = `You clicked ${countClicked} times`;
});
Callbacks
在回调函数中,也存在变量捕获的情况。
例如:setTimeout
的回调函数
const message = 'Hello, World!';
setTimeout(function callback() {
console.log(message); //输出 "Hello, World!"
}, 1000);
callback
是一个闭包,它捕获了message
外部变量。
函数式编程(柯里化)
柯里化技术,主要体现在函数里面返回函数。就是将多变量函数拆解为单变量(或部分变量)的多个函数并依次调用。
function multiply(a) {
return function executeMultiply(b) {
return a * b;
}
}
const double = multiply(2);
double(3); // => 6
double(5); // => 10
const triple = multiply(3);
triple(4); // => 12
利用闭包,可以形成一个不销毁的私有作用域,把预先处理的内容都存在这个不销毁的作用域里面,并且返回一个函数,以后要执行的就是这个函数。
Environments: JS变量查找的底层实现
在
ECMAScript
规范定义中,Environment
是用于管理变量的数据结构:它是字典类型,键值(key
)是变量名,值(value
)是对应存储变量的值(7+1:7种基本类型,1种引用类型Object
)。
函数调用与Environment
函数每次被调用,都需要为函数的变量(参数和局部变量)提供新的存储空间(Environment对象)。V8通过被称为{执行上下文|Execution Contexts}的栈结构(stack
)来管理这些存储空间,而执行上下文是对Environment对象的引用。
Environment
对象是存放在堆内存(heap)中
之所以Environment
对象被存放于堆内存,是因为虽然函数执行完(从执行上下文中出栈pop
),但是在其他变量引用了该变量环境中的变量(形成了闭包)。
从V8垃圾回收的角度解释,刚才的环境变量处于{可达到|reachable},即:GC不会把该部分的数据清除。而如果直接存放在执行上下文里的话,在函数执行完,也就是函数的栈帧被pop后,该部分的数据是无法被访问的。
作用域链与Environment
- 作用域链是和函数调用是不同的机制
- 函数调用是通过执行上下文(stack)来引用一组互不相关的{环境变量|Environment Variables}。
- 作用域链保存着每个环境和创建该环境的外部环境之间的关联关系。
每个作用域的环境变量通过一个称为
outerEnv
(简称outer)的字段指向外部作用域的环境。
当我们查找一个变量的值时,
- 首先在当前环境中搜索它的名称,如果当前环境没有;
- 然后在外部环境中搜索,外部环境也没有;
- 然后在外部环境的外部环境中搜索,一直搜到全局作用域,
- 如果全局作用域也没有该变量,那该变量就是
undefined
每一次的函数调用,都会创建一个新的环境变量。该环境变量的外部环境就是定义该函数的所在的环境。
每个函数对象都有一个名为
[[Scope]]
的内部属性,该属性指向它的诞生环境,并且这个属性的值是在编译阶段被赋值的。
闭包与Environment
function add(x) {
return (y) => {
// 断点 3: plus2(5)
return x + y;
}; // 断点 1: add(2)
}
const plus2 = add(2);
// 断点 2
plus2(5)
断点1:执行add(2)
从图中我可以看到几个信息:
- 执行上下文的顶层记录指向由调用
add(2)
而生成的环境变量。 - 通过
add(2)
返回的函数对象已经被实例化了(右下角),并且它的内部属性[[Scope]]
指向了它的诞生环境:即当前执行环境(调用add(2)
生成)。
虽然,函数对象被实例化,但是与之对应的变量plus2
处于暂时性死区(temporal dead zone)并且值为undefined
。
断点2:add(2)
执行之后
plus2
指向了通过调用add(2)
返回的函数。虽然add(2)
已经从执行上下文堆栈中移除,但是由于plus2
所指向的函数对象引用了add(2)
的环境变量,使其还是处于{可达到|reachable}。
断点3:执行plus2(5)
调用plus2(5)
生成了对应的变量对象,并且将该变量对象的outer
指针与plus2
函数对象的[[Scope]]
相等。通过outer
将多个作用域进行关联,此时在plus2(5)
中有权访问变量x
。
JS 深浅复制
JS在语言层面仅支持浅复制,深复制需要手动实现
浅复制
扩展运算符(...)复制对象和数组
const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];
扩展运算符不足和特性。
不足&特性 |
---|
不能复制普通对象的prototype 属性 |
不能复制内置对象的特殊属性(internal slots) |
只复制对象的本身的属性(非继承) |
只复制对象的可枚举属性(enumerable) |
复制的数据属性都是可写的(writable)和可配置的(configurable) |
Object.assign()
Object.assign()
的工作方式和扩展运算符类似。
const copy1 = {...original};
const copy2 = Object.assign({}, original);
Object.assign()
并非完全和扩展运算符等同,他们之间存在一些细微的差别。
- 扩展运算符在副本中直接定义新的属性
Object.assign()
通过赋值的方式来处理副本中对应属性
Object.getOwnPropertyDescriptors()
和Object.defineProperties()
JavaScript允许我们通过属性描述符来创建属性。
function copyAllOwnProperties(original) {
return Object.defineProperties(
{}, Object.getOwnPropertyDescriptors(original));
}
- 能够复制所有自有属性
- 能够复制非枚举属性
深复制
通过嵌套扩展运算符实现深复制
const original = {name: '789', work: {address: 'BeiJing'}};
const copy = {name: original.name, work: {...original.work}};
original.work !== copy.work // 指向不同的引用地址
使用JSON实现数据的深复制
先将普通对象,
- 先转换为JSON串(
stringify
) - 然后再解析(
parse
)该串
function jsonDeepCopy(original) {
return JSON.parse(JSON.stringify(original));
}
const original = {name: '789', work: {address: 'BeiJing'}};
const copy = jsonDeepCopy(original);
original.work !== copy.work // 指向不同的引用地址
而通过这种方式有一个很明显的缺点就是:
只能处理JSON所能识别的
key
和value
。对于不支持的类型,会被直接忽略掉。
手动实现
递归函数实现深复制
实现逻辑就是
- 利用
for-in
对对象的属性进行遍历(自身属性+继承属性) source.hasOwnProperty(i)
判断是否是非继承的可枚举属性typeof source[i] === 'object'
判断值的类型,如果是对象,递归处理
function clone(source) {
let target = {};
for(let i in source) {
if (source.hasOwnProperty(i)) {
if (typeof source[i] === 'object') {
target[i] = clone(source[i]); // 递归处理
} else {
target[i] = source[i];
}
}
}
return target;
}
Event Loop
{事件循环|Event Loop}
事件循环是一个不停的从 宏任务队列/微任务队列中取出对应任务的循环函数。在一定条件下,你可以将其类比成一个永不停歇的永动机。 它从宏/微任务队列中取出任务并将其推送到调用栈中被执行。
事件循环包含了四个重要的步骤:
- 执行Script:以同步的方式执行script里面的代码,直到调用栈为空才停下来。其实,在该阶段,JS还会进行一些预编译等操作。(例如,变量提升等)。
- 执行一个宏任务:从宏任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。
- 执行所有微任务:从微任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。但是,但是,但是(转折来了),继续从微任务队列中挑选最老的任务并执行。直到微任务队列为空。
- UI渲染:渲染UI,然后,跳到第二步,继续从宏任务队列中挑选任务执行。(这步只适用浏览器环境,不适用Node环境)
事件循环的单次迭代过程被称为tick
setTimeout
当调用栈在调用setTimeout
的时候,浏览器会把setTimeout
的回调函数放到 Event Table
中。可以把Event Table
理解成为一个注册机构,调用栈会告诉Event Table
去注册一个特别的函数,当特定的事件发生的时候去执行它,当特定的事件发生时,Event Table
只是简单的把这个函数移动到Event Quene
里面,Event Quene
只是一个放置即将执行被执行的暂存区,最后由Event Quene
把函数移动到调用栈中。
JavaScript
引擎依据一条规则:有一个monitoring process
会持续不断地检查调用栈是否为空,一旦为空,它会检查Event Queue
里边是否有等待被调用的函数。如果存在,它就会调用这个Queue
中位置靠前的函数并将其移到调用栈中。如果Event Queue
为空,那么这个monitoring process
会继续不定期的检查。这一整个过程就是Event Loop
。
var firstFunction = function () {
console.log("I'm first!");
};
var secondFunction = function () {
setTimeout(firstFunction, 5000);
console.log("I'm second!");
};
secondFunction();
/* Results:
* => I'm second!
* (And 5 seconds later)
* => I'm first!
*/
-
secondFunction
调用setTimeout
,setTimeout
入栈: -
setTimeout
执行后,浏览器会把setTimeout
的回调函数放到Event Table
中 -
一旦回调函数加入到
Event
表中,代码不会被block
住,浏览器不会等待5秒之后再继续处理接下去的代码,相反,浏览器继续执行secondFunction
的下一行代码,console.log
。 -
在后台,
Event Table
会持续地监测是否有事件触发,将函数移到Event Queue中。- 在这个实例中,
secondFunction
执行完毕,接着main.js
也执行完毕。
- 在这个实例中,
-
从回调函数被放入
Event Table
后5秒钟,Event Table
把firstFucntion
移到Event Queue
中。 -
事件循环持续地监测调用栈是否已空,当它一注意到调用栈空了,就调用
firstFunction
并创建一个新的调用栈 -
一旦
firstFunction
执行完毕,调用栈空了,Event Table
里也没有注册函数,Event Queue
也为空。
{宏任务队列|Task Queue}
也可以称为{回调队列| Callback queue}。
调用栈是用于跟踪正在被执行函数的机制,而宏任务队列是用于跟踪将要被执行函数的机制。
宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。你可以将这些任务类比成一个函数对象。
事件循环不知疲倦的运行着,并且按照一定的规则从宏任务队列中不停的取出任务对象。
宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。当一个任务被执行,其他任务是被挂起的(按顺序排队)。
{微任务队列|Microtask Queue}
微任务队列也是一个FIFO(先进先出)的队列结构。并且,结构中存储的微任务也会被时间循环探查到。微任务队列和宏任务队列很像。作为ES6的一部分,它被添加到JS的执行模型中,以处理Promise回调。
微任务和宏任务也很像。它也是一个同步阻塞代码,运行时也会霸占调用栈。像宏任务一样,在运行期间,也会触发新的微任务,并且将新任务提交到微任务队列中,按照队列排队顺序,将任务进行合理安置。
- 宏任务是在循环中被执行,并且UI渲染穿插在宏任务中。
- 微任务是在一个宏任务完成之后,在UI渲染之前被触发。
微任务队列是ES6新增的专门用于处理
Promise
调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。
ES6遍历对象的属性 (5种)
for...in
:循环遍历对象自身的和继承的可枚举属性(不含 Symbol 属性)Object.keys(obj)
:返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含 Symbol 属性)的键名Object.getOwnPropertyNames(obj)
:回一个数组,包含对象自身的所有属性(不含 Symbol 属性,但是包括不可枚举属性)的键名Object.getOwnPropertySymbols(obj)
:返回一个数组,包含对象自身的所有Symbol
属性的键名Reflect.ownKeys(obj)
:返回一个数组,包含对象自身的(不含继承的)所有键名,不管键名是 Symbol 或字符串,也不管是否可枚举
上述遍历,都遵守同样的属性遍历的次序规则:
- 首先遍历所有数值键,按照数值升序排列
- 其次遍历所有字符串键,按照加入时间升序排列
- 最后遍历所有
Symbol
键,按照加入时间升序排
Reflect.ownKeys({ [Symbol()]:0, b:0, 10:0, 2:0, a:0 })
// ['2', '10', 'b', 'a', Symbol()]
{递归|Recursion}
如果一个函数在内部调用函数本身,这个函数就是递归函数
其核心思想是把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解
一般来说,递归需要有
- 边界条件
- 递归前进阶段
- 递归返回阶段
当边界条件不满足时,递归前进; 当边界条件满足时,递归返回
function pow(x, n) {
if (n == 1) {
return x;
} else {
return x * pow(x, n - 1);
}
}
pow(x, n)
被调用时,执行分为两个分支:
if n==1 = x
/
pow(x, n) =
\
else = x * pow(x, n - 1)
也就是说 pow
递归地调用自身 直到 n == 1
尾递归
尾递归,即在函数尾位置调用自身。尾递归也是递归的一种特殊情形。
尾递归在普通尾调用的基础上,多出了2个特征:
- 在尾部调用的是函数自身
- 可通过优化,使得计算仅占用常量栈空间
在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储,递归次数过多容易造成栈溢出。
使用尾递归,即一个函数中所有递归形式的调用都出现在函数的末尾,对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。
fibonacci数列
常规方式
function fibonacci(n){
if(n==1 || n===2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2)
}
测试
fibonacci(1000) // 浏览器卡死
尾递归方式
function fibonacci(n,start=0,total=1){
if(n ==1 || n ==2) return 1;
return fibonacci(n -1, total,total + start)
}
测试
fibonacci(1000) // 4.346655768693743e+208
{垃圾回收机制|Garbage Collecation}
垃圾回收算法
- 通过
GC Root
标记空间中活动对象和非活动对象- V8 采用的{可访问性| reachability}算法,来判断堆中的对象是否是活动对象
- 这个算法是将一些
GC Root
作为初始存活的对象的集合 - 从
GC Roots
对象出发,遍历GC Root
中的所有对象- 通过
GC Roots
遍历到的对象,认为该对象是{可访问的| reachable},也称可访问的对象为活动对象 - 通过
GC Roots
没有遍历到的对象,是{不可访问的| unreachable},不可访问的对象为非活动对象
- 通过
- 浏览器环境中,
GC Root
包括- 全局的
window
对象 - 文档
DOM
树,由可以通过遍历文档到达的所有原生DOM
节点组成 - 存放栈上变量
- 全局的
- 回收非活动对象所占据的内存
- 内存整理
- 频繁回收对象后,内存中就会存在大量不连续空间
- 这些不连续的内存空间称为内存碎片
代际假说
代际假说是垃圾回收领域中一个重要的术语
两个特点
- 第一个是大部分对象都是朝生夕死的
- 大部分对象在内存中存活的时间很短
- 比如函数内部声明的变量,或者块级作用域中的变量
- 第二个是不死的对象,会活得更久
- 比如全局的
window
、DOM
、Web API
等对象
- 比如全局的
堆空间
在 V8 中,会把堆分为
- 新生代
- 存放的是生存时间短的对象
- 新生代通常只支持 1~8M 的容量
- {副垃圾回收器| Minor GC} (Scavenger)
- 负责新生代的垃圾回收
- 老生代
- 存放生存时间久的对象
- {主垃圾回收器| Major GC}
- 负责老生代的垃圾回收
{副垃圾回收器| Minor GC}
新生代中的垃圾数据用 Scavenge
算法来处理。
所谓 Scavenge
算法,把新生代空间对半划分为两个区域: 一半是对象区域 (from-space), 一半是空闲区域 (to-space)
当对象区域快被写满时,就需要执行一次垃圾清理操作,
-
首先要对对象区域中的垃圾做标记,
-
标记完成之后,就进入垃圾清理阶段,
- 把这些存活的对象复制到空闲区域中,把这些对象有序地排列起来
-
完成复制后,对象区域与空闲区域进行角色翻转
-
副垃圾回收器采用对象晋升策略:移动那些经过两次垃圾回收依然还存活的对象到老生代中。
{主垃圾回收器| Major GC}
负责老生代中的垃圾回收,除了新生代中晋升的对象,大的对象会直接被分配到老生代里。
老生代中的对象有两个特点
- 对象占用空间大
- 对象存活时间长
采用{标记 - 清除|Mark-Sweep}的算法
- 标记过程阶段
- 从一组根元素开始,递归遍历这组根元素
- 这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据
- 垃圾的清除过程
-
主垃圾回收器会直接将标记为垃圾的数据清理掉
-
-
{标记 - 整理|Mark-Compact}
- 标记可回收对象
- 垃圾清除
- 不是直接对可回收对象进行清理
- 而是让所有存活的对象都向一端移动
- 直接清理掉这一端之外的内存
GC:优化效率
全停顿(Stop-The-World),由于 JavaScript
是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript
脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。
为了解决全停顿而造成的用户体验的问题,V8向现有的垃圾回收器添加
- 并行
- 增量
- 并发
等垃圾回收技术。
这些技术主要是从两方面来解决垃圾回收效率
- 将一个完整的垃圾回收的任务拆分成多个小的任务
- 将标记对象、移动对象等任务转移到后台线程进行
并行回收
主线程在执行垃圾回收的任务时,引入多个辅助线程来并行处理,加速垃圾回收的执行速度
V8 的副垃圾回收器所采用的就是并行策略
增量回收
指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。
采用增量垃圾回收时
- 垃圾回收器没有必要一次执行完整的垃圾回收过程
- 每次执行的只是整个垃圾回收过程中的一小部分工作
要实现增量执行,需要满足两点要求
- 垃圾回收可以被随时暂停和重启
- 暂停时需要保存当时的扫描结果
- 等下一波垃圾回收来了之后,才能继续启动
- 在暂停期间
- 被标记好的垃圾数据如果被 JavaScript 代码修改了
- 垃圾回收器需要能够正确地处理
主要的技术
- 实现垃圾回收器的暂停和恢复执行
- V8 采用了三色标记法
- 写屏障 (Write-barrier) 机制
并发 (concurrent) 回收
指主线程在执行 JavaScript
的过程中,辅助线程能够在后台完成执行垃圾回收的操作
并发回收的优势非常明显
- 主线程不会被挂起,
JavaScript
可以自由地执行 - 在执行的同时,辅助线程可以执行垃圾回收操作
主垃圾回收器同时采用了这三种策略
内存问题
内存泄漏 (Memory leak)
不再需要 (没有作用) 的内存数据依然被其他对象引用着。
污染全局(window)
function foo() {
//创建一个临时的temp_array
temp_array = new Array(200000)
/**
* 使用temp_array
*/
}
函数体内的对象没有被 var
、let
、const
这些关键字声明。
V8
就会使用 this.temp_array
替换 temp_array
在浏览器,默认情况下,this
是指向 window
对象的
闭包
function foo(){
var temp_object = new Object()
temp_object.x = 1
temp_object.y = 2
temp_object.array = new Array(200000)
/**
* 使用temp_object
*/
return function(){
console.log(temp_object.x);
}
}
闭包会引用父级函数中定义的变量。
如果引用了不被需要的变量,那么也会造成内存泄漏。
detached 节点
let detachedTree;
function create() {
var ul = document.createElement('ul');
for (var i = 0; i < 100; i++) {
var li = document.createElement('li');
ul.appendChild(li);
}
detachedTree = ul;
}
create()
只有同时满足 DOM 树和 JavaScript 代码都不引用某个 DOM 节点,该节点才会被作为垃圾进行回收。
“detached ”节点:如果某个节点已从 DOM 树移除,但 JavaScript 仍然引用它
后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。
转载自:https://juejin.cn/post/7154231180697550879