# JS的作用域,作用域链,闭包

# 变量

复制保存着对象的某个变量时,操作的是对象的引用。为对象添加属性时,操作的是该对象本身。

  1. 如果从一个变量向另一个变量复制基本类型的值,会在变量对象上创建一个新值,然后把该值复制到为新变量分配到位置上。
  2. 从一个变量向另一个变量复制引用类型到值,同样也会将存储在变量对象中值复制一份放到新变量 分配的空间中。不同的是,这个值的副本实际上是一个指针,而这个指针指向存储在堆中的一个小对象。
  1. 如果初始化变量没有使用var声明,该变量会自动被添加到全局环境。
  2. 基本数据类型的内存占据固定大小的空间,因此被保存在栈内存中。

TIP

内存空间分:栈空间和堆空间

  • 栈空间:由操作系统自动分配释放,存放函数的参数值,局部变量的值等。
  • 堆空间:一般由开发者分配,关于这部分空间要考虑垃圾回收的问题。

# 作用域(scope)

作用域 规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。(规定变量和函数的可使用范围叫做作用域)

块语句,如if,switch,for,while等循环语句,不像函数,它们不会创建一个新的作用域。

var name = 'linjiaheng'
function sayName() {
    var age = 18;
    if(name === 'linjiaheng') {
        var sex = 'male'
    }
    console.log(sex) // male
}
sayName()
1
2
3
4
5
6
7
8
9
  1. 词法作用域

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你写代码时将变量和作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。

var name = 'xiuyan'
function showName() {
    console.log(name)
}
function changeName() {
    var name = 'BigBear'
    showName()
}
changeName() // xiuyan
1
2
3
4
5
6
7
8
9
  • 词法作用域表现流程:
    • showName函数的函数作用域内查找是否有局部变量name
    • 发现没找到,于是根据书写的位置,查找上层作用域(全局作用域),找到了name的值是xiuyan,所以结果会打印xiuyan
  1. 动态作用域

在JS里,动态作用域和this机制息息相关。

var a = 1;
function foo() {
    var a = 2;
    console.log(this.a) // 1  这个this指向全局对象Window
}
foo()
1
2
3
4
5
6

从上面的例子中可以看出结果不是取之于写代码的位置,而是取决于函数执行的位置。

TIP

  • 词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。
  • 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

修改作用域:

  • eval对作用域的修改
function showName(str) {
    eval(str)
    console.log(name)
}

var name = 'xiuyan'
var str = 'var name="BigBear"'
showName(str) //BigBear
1
2
3
4
5
6
7
8

eval拿到一个字符串入参后,会把这段字符串的内容当作一段js代码,插入自己被调用的那个位置。成功修改了词法作用域规则约束下在书写阶段就划分好的作用域。

  • with对作用域的修改
var me = {
    name: 'xiuyan',
    career: 'coder',
    hobbies: ['coding','football']
}
with(me) {
    console.log(name) // xiuyan
    console.log(career)
    console.log(hobbies)
}

// 另一个例子
function changeName(person) {
    with(person) {
        name = "BigBear"
    }
}
var me = {
    name: 'xiuyan',
    career: 'coder',
    hobbies: ['coding','football']
}
var you = {
    career: 'product manager'
}
changeName(me)
changeName(you)
console.log(name) // BigBear
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

惊奇发现在执行两次changeName后,竟然多出一个全局变量name,其实是with在捣鬼。with做的事情就是凭空创建出一个作用域。

  • 第一次changeName调用,withme这个对象创建一个新的作用域,使得我们可以在这个作用域里直接访问name/career/hobbies等对象属性。
  • 第二次changeName调用,withyou这个对象也创建一个新作用域,使得我们可以在这个作用域里直接访问career这个对象属性
  1. 块作用域
  • try...catch语句中catch语句会创建一个块作用域,catch的参数变量仅在catch内部有效。
  • 利用ES6的let,const关键字定义变量也会隐式创建块作用域。
  • with语句声明中

WARNING

许多开发人员经常混淆作用域和执行上下文(执行环境),误认为是一样的概念。Javascript的执行分为:解释和执行两个阶段。

预编译阶段是前置阶段,这一阶段会由编译器将js代码编译成可执行的代码。注意,这里的预编译阶段和传统的编译阶段不同。虽然js是解释型语言,编译一行执行一行。但在代码执行前js引擎会预先做一些准备工作。

解释阶段(预编译阶段):

  • 词法分析
  • 语法分析
  • 作用域规则确定

预编译阶段会对变量声明;对变量声明进行提升,不过值为undefined;在预编译阶段对所有非表达式的函数声明进行提升。

执行阶段:

  • 创建执行上下文(例如this的指向)
  • 执行函数代码
  • 垃圾回收

每当有函数执行时就会产生一个新的上下文,这一上下文会被压入JS上下文堆栈(context stack)中,函数执行结束后则被弹出。

执行上下文(包括变量对象、作用域链、this的指向)在运行时确定,随时可能改变;作用域在定义时确定,并且不会改变。**执行上下文就是当前代码的执行环境/作用域,和作用域链相辅相成,但又是完全不同的两个概念。直观上看执行上下文包含了作用域链,有了作用域链才会有执行上下文的一部分。**作用域在预编译阶段确定,但是作用域链是在执行上下文的创建阶段完全生成的,因为函数在调用时才会开始创建对应的执行上下文。

TIP

  1. 全局执行上下文 — 这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。 它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。一个程序中只会有一个全局执行上下文。
  2. 函数执行上下文 — 每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
  3. Eval函数执行上下文 — 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但由于 JavaScript 开发者并不经常使用 eval

每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。某个执行环境中所有代码执行完后就会销毁环境,保存其中的所有变量和函数定义也随之销毁。

web浏览器全局执行环境就是window对象,node环境就是global

# 调用栈

在执行一个函数时,如果这个函数又调用了另一个函数,而这“另一个函数”又调用了另外一个函数,这样便形成了一系列的调用栈call stack。执行完就会依次出栈。

正常来讲在函数执行完毕并出栈时,函数内的局部变量在下一个垃圾回收节点会被回收,该函数对应的执行上下文也会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数才可以访问变量,该变量会在预编译阶段被创建,在执行阶段被激活,在函数执行完毕时,相关上下文会被销毁。

# 作用域链 (scope chain)

当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链用途是保证对执行环境有权访问的所有变量和函数有序访问,通过作用域链我们可以访问到外层环境到变量和函数。全局执行环境的变量对象始终都是作用域链中的最后一个对象。必须通过搜索来确定该标识符实际代表什么。如果在局部环境中找到了标识符,搜索停止,变量就绪。反之则沿着作用域链向上搜索。搜索过程将一直追朔到全局环境的变量对象。如果在全局环境中都没有找到这个标识符,则意味着该标识符(变量)未定义。这样由多个执行环境的变量对象构成的链表就叫做作用域链

TIP

每次进入一个新执行环境,都会创建一个用于搜索变量和函数的作用域链。作用域链的前端始终都是当前执行代码所在环境的变量对象。如果这个环境是函数,就将其活动对象作为变量对象。

# 垃圾回收(GC)机制

内存的生命周期无外乎分配内存,读写内存,释放内存。找出那些不再继续使用的变量,然后释放其内存。垃圾收集器会固定时间间隔,进行这一周期操作。

  • 标记清除(Mark-Sweep):变量进入环境就进行标记

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量将被视为准备删除的变量。最后垃圾收集器完成内存清除工作,销毁带标记的值并回收所占用的内存空间。

img

根可以理解为全局作用域,GC从全局作用域的变量,沿作用域逐层往里遍历(深度遍历),当遍历到堆中对象时,说明对象被引用,则打上一个标记,继续递归遍历(存在堆中对象引用另一个堆中对象),直到遍历到最后一个(最深一层的作用域)节点。

清除阶段,这次是遍历整个堆,回收没有打上标记的对象(非活动对象),连接到空闲链表上。

  • 引用计数

声明一个变量并将一个引用类型的值赋值给变量时,这个值的引用次数加1,同一值被赋予另一个变量,该值的引用计数加1。

IE9之前容易造成内存泄漏是因为循环引用的问题。这种垃圾回收机制是根据引用次数为0即清除掉的。

因为IE9之前的BOMDOM对象是采用引用计数策略的回收机制。Js引擎采用标记清除策略。

var element = document.getElementById('some——element');
var myObject = new Object()
myObject.element = element;
element.someObject = myObject;
1
2
3
4

所以myObjectelement循环引用,引用次数都为2次,所以永远都不会被回收,就会造成内存泄漏 这个时候就要手动断开DOMJS对象的连接。myObject.element = null; element.someObject = null;

TIP

引用计数策略的优缺点: 优点

  • 可立刻回收垃圾,当被引用值为0时。对象马上会把自己作为空闲空间连到空闲链表上,也就是说,在变成垃圾的时候就立刻回收了。
  • 因为是立刻回收,那么程序不会暂停很长一段时间去GC,那么最大暂停时间很短。
  • 不用去遍历堆里面的所有活动对象和非活动对象。

缺点

  • 计数器需要很大的位置,因为不能预估被引用的上限。
  • 最大的劣势是无法解决循环引用无法回收的问题。

引用计数的次数计算策略:声明一个变量并将一个引用类型的值赋值给 这个变量,这个引用类型的值的引用次数就是1。同一个值又被赋值给另一个变量,这个引用类型值的引用次数加1;当引用次数为0,则说明没办法访问这个值了。当垃圾收集器下一次运行时,就会释放引用次数为0的值所占的内存。

# 内存泄漏的情况

  • 变量:

    • 意外的全局变量(使用严格模式避免)
    • 闭包引起(将事件处理函数定义在外部,解除闭包。)
  • DOM:

    • 没有清理DOM的引用(手动删除)
    var element = document.getElementById('element')
    element.mark = 'marked'
    // 移除element节点
    function remove() {
        element.parentNode.removeChild(element)
    }
    
    1
    2
    3
    4
    5
    6

    上面到例子中我们只是把节点移除了,但是变量element还在,该节点依然占据内存,所以应该手动添加element=null

    如果代码有addEventListener,由于事件处理句柄还在,所以别忘了removeEventListener移除事件,防止内存泄漏。

    • 被遗忘的定时器或者回调(定时器中有DOM的引用,手动删除定时器和DOM
    function foo() {
        var name  = 'xing'
        window.setInterval(function() {
            console.log(name)
        }, 1000)
    }
    foo()
    
    1
    2
    3
    4
    5
    6
    7

    这段代码由于 window.setInterval 的存在,导致 name 内存空间始终无法被释放,如果不是业务要求的话,一定要记得在合适的时机使用clearInterval 进行清理。

    • 子元素存在引用引起内存泄漏

# 管理内存

一旦数据不再有用,最好通过将其设置为null来释放其引用--这个方式叫做解除引用。这一做法适用全局变量和全局对象及其属性,因为局部的离开执行环境就会自动销毁,不过,解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

解除引用有助于消除循环引用的现像,而且也有助于垃圾回收。为了确保有效地回收内存,应该及时解除不使用的全局对象,全局对象的属性,全局变量,以及循环引用变量的引用。

# 垃圾回收算法

评价垃圾回收算法性能几个方面:

  • 吞吐量
  • 最大暂停时间
  • 堆使用效率
  • 访问的局部性

# 减少JavaScript的垃圾回收

  1. 对象Object优化

为了最大限度的实现对象的重用,应该避免使用new语句,{}对象字面量的形式新建对象,因为意味着进行一次内存分配。最好是每次函数调用完成之后,将需要返回的数据放入一个全局对象中,并返回这个全局对象。如果使用这种方式,就意味着每次调用都会导致全局对象内容的修改,可能会导致错误发生。 有一种方式可以重复利用对象:遍历某个对象的所有属性,并逐个删除,最终将对象清理为一个空对象。虽然这样比直接创建一个新对象耗时,但是将有效减少垃圾堆积,并且最终避免垃圾回收暂停。

  1. 数组优化

将一个空数组[]赋值给一个数组对象,是清空数组的捷径,但是需要注意的是这样就会创建一个新的空对象,并且原来的数组对象变成了一片内存垃圾。实际上,将数组长度赋值为0也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生。

  1. 函数优化

只要是动态创建方法的地方,就有可能产生内存垃圾。

# 闭包(closure)

闭包是指有权访问另一个函数作用域中的变量的内部函数。--《高级js程序设计》

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。--《你不知道的js》

换一句话说就是当一个变量既不是该函数内部当局部变量,也不是该函数当参数,相对于作用域来说,就是一个自由变量,这样就会形成一个闭包

例子1

let count=500 //全局作用域
function foo1() {
  let count = 0;//函数全局作用域
  function foo2() {
    count++;//函数内部作用域
    console.log(count);
    return count;
  }
  return foo2;//返回函数
}
let result = foo1();
result();//结果为1 这里没有什么问题
result();//结果为2  这里为2是因为此处闭包了,count=1没有被销毁,还保持着状态
1
2
3
4
5
6
7
8
9
10
11
12
13

例子2

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i); // 输出什么? 直接输出6 6 6 6 6
  }, 1000); // 改成1000*i 就间隔一秒输出一个6,总共输出五个
}
1
2
3
4
5

这是因为setTimeout是异步执行,每一次for循环的时候,setTimeout都执行一次,但是里面的函数没有被执行,而是被放到了任务队列里,等待执行。只有主线上的任务执行完,才会执行任务队列里的任务。也就是说它会等到for循环全部运行完毕后,才会执行timer函数,但是当for循环结束后此时i的值已经变成了6

  • 解决方法:

    • for循环中的var改为let

    for循环头部的let声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

    • setTimeout套上一个函数
    for (var i = 1; i <= 5; i++) {
        log(i); // 1 2 3 4 5
    }
    function log(i) {
        setTimeout(function timer() {
            console.log(i);
        }, 1000);
    }
    
    //也可以:
    for (var i = 1; i <= 5; i++) {
        setTimeout((function timer() {
            console.log(i); // 输出什么? 6 6 6 6 
        })(i), 1000);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    • 包装成匿名函数(利用闭包)
    for (var i = 1; i <= 5; i++) {
        (function (j) {
            setTimeout(function timer() {
                console.log(j);
            }, 1000);
        })(j) // 立即执行函数
    }
    
    1
    2
    3
    4
    5
    6
    7
    • 使用setTimeout第三个参数,这个参数会被当成timer回调函数的参数传入。
    for(var i=1;i<5;i++){
        setTimeout(
            function timer(j) {
                console.log(j)
            },
            i*1000,
            i
        )
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

例子3:

function test(){
    var num = []
    var i
    for(i=0;i<10;i++) {
        num[i] = function() {
            console.log(i)
        }
    }
    return num[9]
}
test()() // 10 打印的是最后一次的i 10
1
2
3
4
5
6
7
8
9
10
11

例子4:

var test = (function() {
    var num = 0 // 闭包变量
    return () => {
        return num++ // 如果为++num,则输出11
    }
}())

for(var i=0;i<10;i++) {
    test()
}
console.log(test()) // 10
1
2
3
4
5
6
7
8
9
10
11

TIP

test是一个立即执行函数,当我们尝试打印test时。输出结果为

() => {
    return num++
}
1
2
3

在循环结束时,引用自由变量10次,num自增变量10次,最后执行test时得到10。这里的自由变量是指没有在相关函数作用域中声明,但却被使用了的变量。

例子5:

function foo(a,b) {
  console.log(b)
  return {
    foo: function(c) {
      return foo(c,a)
    }
  }
}
var func1 = foo(0)
func1.foo(1)
func1.foo(2)
func1.foo(3)
var func2 = foo(0).foo(1).foo(2).foo(3) // undefined 0 1 2
var func3 = foo(0).foo(1)
func3.foo(2)
func3.foo(3)
// undefined
// 0
// 0
// 0

// undefined
// 0
// 1
// 2

// undefined
// 0
// 1
// 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

例子6

var fn = null
const foo = () => {
    var a = 2
    function innerFoo() {
        console.log(a) // 使用了外部变量
    }
    fn = innerFoo
}
const bar = () => {
    fn()
}
foo()
bar()
// 输出2
1
2
3
4
5
6
7
8
9
10
11
12
13
14

TIP

正常来说根据调用栈的知识,foo函数执行完毕后,其执行环境生命周期会结束,所占用的内存会被垃圾收集器释放,上下文消失。但是通过将innerFoo函数赋值给全局变量fnfoo的变量对象a也会保存下来。所以函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象{a:2}

正常情况下外界是无法访问函数内部变量的,函数执行之后,上下文即被销毁。但是在函数(外层)中,如果我们返回了另一个函数,且这个返回的函数使用了函数(外层)内的变量,那么外界便能够通过这个返回的函数获取原函数(外层)内部的变量值。这就是闭包的基本原理。

# 闭包注意事项

  1. 函数作用域内的变量将在函数退出调用栈后清除,并且如果函数外部没有其他指向它们的引用,则将清理它们。但闭包将保留引用的变量并保持活动状态。

# 闭包应用场景

  1. 保护作用

团队开发时,每个人的代码都放在一个私有的作用域中,防止变量名冲突;把需要的方法提供给别人,通过return或者window.xxx的方式暴露在全局下。

  1. 保存作用

  2. 返回值(最常用)

function fn() {
  let name = 'hello'
  return function() {
    console.log(name)
  }
}

var fnc = fn() 
fnc()
1
2
3
4
5
6
7
8
9
  1. 函数赋值
var fn2;
function fn(){
    var name="hello";
    //将函数赋值给fn2
    fn2 = function(){
        return name;
    }
}
fn()//要先执行进行赋值,
console.log(fn2())//执行输出fn2
1
2
3
4
5
6
7
8
9
10
  1. 函数参数
function fn(){
    var name="hello";
    return function callback(){
        return name;
    }
}
var fn1 = fn()//执行函数将返回值(callback函数)赋值给fn1,

function fn2(f){
    //将函数作为参数传入
    console.log(f());//执行函数,并输出
}
fn2(fn1)//执行输出fn2
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. 自执行函数
(function(){
    var name="hello";
    var fn1= function(){
        return name;
    }
    //直接在自执行函数里面调用fn2,将fn1作为参数传入
    fn2(fn1);
})()
function fn2(f){
    //将函数作为参数传入
    console.log(f());//执行函数,并输出
}
1
2
3
4
5
6
7
8
9
10
11
12
  1. 迭代器
var arr =['aa','bb','cc'];
function incre(arr){
    var i=0;
    return function(){
        //这个函数每次被执行都返回数组arr中 i下标对应的元素
         return arr[i++] || '数组值已经遍历完';
    }
}
var next = incre(arr);
console.log(next());//aa
console.log(next());//bb
console.log(next());//cc
console.log(next());//数组值已经遍历完
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. 首次区分(相同的参数,函数不会重复执行)
var test = (function(){
  var arr = []
  return function(val) {
    val = JSON.stringify(arguments)
    if(arr.indexOf(val) > -1) {
      // 命中缓存
      console.log('此次函数不需要重复执行')
    } else {
      arr.push(val)
      console.log('函数被执行:',val)
    }
  }
})()

test('abc', 123, {b:3})
test('abc', 123, {b:3})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. 缓存
//比如求和操作,如果没有缓存,每次调用都要重复计算,采用缓存已经执行过的去查找,查找到了就直接返回,不需要重新计算

     var fn=(function(){
        var cache={};//缓存对象
        var calc=function(arr){//计算函数
            var sum=0;
            //求和
            for(var i=0;i<arr.length;i++){
                sum+=arr[i];
            }
            return sum;
        }

        return function(){
            var args = Array.prototype.slice.call(arguments,0);//arguments转换成数组
            var key=args.join(",");//将args用逗号连接成字符串
            var result , tSum = cache[key];
            if(tSum){//如果缓存有   
                console.log('从缓存中取:',cache)//打印方便查看
                result = tSum;
            }else{
                //重新计算,并存入缓存同时赋值给result
                result = cache[key]=calc(args);
                console.log('存入缓存:',cache)//打印方便查看
            }
            return result;
        }
     })();
    fn(1,2,3,4,5);
    fn(1,2,3,4,5);
    fn(1,2,3,4,5,6);
    fn(1,2,3,4,5,8);
    fn(1,2,3,4,5,6);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33