「 JS 基础回顾 」- 作用域链 ( 内含执行上下文 ) & 闭包 & 原型链

224 阅读13分钟

目录

  1. 作用域链 (内含执行上下文)
  2. 闭包(内含使用场景 7 个)
  3. 原型链

一 作用域链

什么是作用域(Scope)

作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。

作用域链分为

  1. 全局作用域
  2. 局部作用域(包含函数作用域与块级作用域)

作用域就是一个独立的地盘,让变量不会外泄、暴露出去。也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数作用域。ES6的到来,为我们提供了‘块级作用域’,可通过新增命令let和const来体现。

全局作用域

  1. 最外层函数和在最外层函数外面定义的变量拥有全局作用域
  2. 所有末定义直接赋值的变量自动声明为拥有全局作用域
  3. 所有window对象的属性拥有全局作用域

一般情况下,window对象的内置属性都拥有全局作用域,例如window.name、window.location、window.top等等。

全局作用域有个弊端

如果我们写了很多行 JS 代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。这样就会 污染全局命名空间, 容易引起命名冲突。

这就是为何 jQuery、Zepto 等库的源码,所有的代码都会放在(function(){....})()中。因为放在里面的所有变量,都不会被外泄和暴露,不会污染到外面,不会对其他的库或者 JS 脚本造成影响。这是函数作用域的一个体现。

局部作用域(包含函数作用域与块级作用域)

函数作用域

是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部。

image.png

值得注意的是:块语句(大括号“{}”中间的语句),如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。在块语句中定义的变量将保留在它们已经存在的作用域中。

if (true) {
    // 'if' 条件语句块不会创建一个新的作用域
    var name = 'a'; // name 依然在全局作用域中
}
console.log(name); // logs 'a'

JS 的初学者经常需要花点时间才能习惯变量提升,而如果不理解这种特有行为,就可能导致 bug 。正因为如此, ES6 引入了块级作用域,让变量的生命周期更加可控

块级作用域

块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:

  1. 在一个函数内部
  2. 在一个代码块(由一对花括号包裹)内部

let 声明的语法与 var 的语法一致。你基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:

  1. 声明变量不会提升到代码块顶部
  2. 禁止重复声明
  3. 循环中的绑定块作用域的妙用

let/const 声明并不会被提升到当前代码块的顶部,因此你需要手动将 let/const 声明放置到顶部,以便让变量在整个代码块内部可用。

作用域链

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行

什么是作用域链

变量的查找,当前作用域没有,就去父级找,如果父级也没呢?再一层一层向上寻找,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就是 作用域链

关于自由变量的取值

在fn函数中,取自由变量x的值时,要到哪个作用域中取?——要到创建fn函数的那个作用域中取,无论fn函数将在哪里调用

所以,不要在用以上说法了。相比而言,用这句话描述会更加贴切:要到创建这个函数的那个域”。 作用域中取值,这里强调的是“创建”,而不是“调用 ,切记切记——其实这就是所谓的"静态作用域"

var a = 10
function fn() {
  var b = 20
  function bar() {
    console.log(a + b) //30
  }
  return bar
}
var x = fn();
var b = 200;
x();// 30 // look here

bar(); // bar is not defined

作用域执行上下文

许多开发人员经常混淆作用域和执行上下文的概念,误认为它们是相同的概念,但事实并非如此。

我们知道JavaScript属于解释型语言,JavaScript的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样:

解释阶段:

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

执行阶段:

  • 创建执行上下文
  • 执行函数代码
  • 垃圾回收

JavaScript解释阶段便会确定作用域规则,因此作用域函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是this的指向执行时确定的。而作用域访问的变量编写代码的结构确定的。

作用域和执行上下文之间最大的区别是: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变

一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值

闭包

背景:堆栈内存释放

  • 堆内存:存储引用类型值,对象类型就是键值对,函数就是代码字符串。
  • 堆内存释放:将引用类型的空间地址变量赋值成 null,或没有变量占用堆内存了浏览器就会释放掉这个地址
  • 栈内存:提供代码执行的环境和存储基本类型值。
  • 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。

但栈内存的释放也有特殊情况:① 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。② 全局下的栈内存只有页面被关闭的时候才会被释放

是什么?

JS 忍者秘籍中对闭包的定义:闭包允许函数访问并操作函数外部的变量红宝书上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数MDN 对闭包的定义为:闭包是指那些能够访问自由变量的函数。这里的自由变量是外部函数作用域中的变量。

概述上面的话,闭包突破了作用域,有权访问另一个函数作用户域变量函数

一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个闭包

形成闭包的原因

内部的函数存在外部作用域的引用就会导致闭包

var a = 0
function foo(){
    var b =14
    function fo(){
        console.log(a, b)
    }
    fo()
}
foo()

闭包经典使用场景

    1. return 返回一个函数
var n = 10
function fn(){
    var n =20
    function f() {
       n++;
       console.log(n)
     }
    return f
}

var x = fn()
x() // 21

这里的 return f, f()就是一个闭包,存在上级作用域的引用。

    1. 函数作为参数
var a = '1'
function foo(){
    var a = 'foo'
    function fo(){
        console.log(a)
    }
    return fo
}

function f(p){
    var a = 'f'
    p()
}
f(foo())
// 输出 foo

使用 return fo 返回回来,fo() 就是闭包,f(foo()) 执行的参数就是函数 fo,因为 fo() 中的 a 的上级作用域就是函数foo(),所以输出就是foo

    1. IIFE(自执行函数)
var n = 1;
(function p(){
    console.log(n)
})()
// 输出 1

同样也是产生了闭包p(),存在 window下的引用 n

    1. 循环赋值
for(var i = 0; i<10; i++){
  (function(j){
       setTimeout(function(){
        console.log(j)
    }, 1000) 
  })(i)
}

因为存在闭包的原因上面能依次输出1~10,闭包形成了10个互不干扰的私有作用域。将外层的自执行函数去掉后就不存在外部作用域的引用了,输出的结果就是连续的 10。为什么会连续输出10,因为 JS 是单线程的遇到异步的代码不会先执行(会入栈),等到同步的代码执行完 i++ 到 10时,异步代码才开始执行此时的 i=10 输出的都是 10。

    1. 使用回调函数`就是在`使用闭包
window.name = 'a'
setTimeout(function timeHandler(){
  console.log(window.name);
}, 100)

    1. 节流防抖
// 节流
function throttle(fn, timeout) {
    let timer = null
    return function (...arg) {
        if(timer) return
        timer = setTimeout(() => {
            fn.apply(this, arg)
            timer = null
        }, timeout)
    }
}

// 防抖
function debounce(fn, timeout){
    let timer = null
    return function(...arg){
        clearTimeout(timer)
        timer = setTimeout(() => {
            fn.apply(this, arg)
        }, timeout)
    }
}

    1. 柯里化实现
function curry(fn, len = fn.length) {
    return _curry(fn, len)
}

function _curry(fn, len, ...arg) {
    return function (...params) {
        let _arg = [...arg, ...params]
        if (_arg.length >= len) {
            return fn.apply(this, _arg)
        } else {
            return _curry.call(this, fn, len, ..._arg)
        }
    }
}

let fn = curry(function (a, b, c, d, e) {
    console.log(a + b + c + d + e)
})

fn(1, 2, 3, 4, 5)  // 15
fn(1, 2)(3, 4, 5)
fn(1, 2)(3)(4)(5)
fn(1)(2)(3)(4)(5)

闭包变量存储的位置

闭包中的变量存储的位置是堆内存

  • 假如闭包中的变量存储在栈内存中,那么栈的回收会把处于栈顶的变量自动回收。所以闭包中的变量如果处于栈中那么变量被销毁后,闭包中的变量就没有了。所以闭包引用的变量是出于堆内存中的。

闭包的作用

  • 保护函数私有变量不受外部的干扰。形成不销毁的栈内存
  • 保存把一些函数内的值保存下来。闭包可以实现方法和属性的私有化

使用闭包需要注意什么

容易导致内存泄漏。闭包会携带包含其它的函数作用域,因此会比其他函数占用更多的内存。过度使用闭包会导致内存占用过多,所以要谨慎使用闭包。不正当地使用闭包可能会造成内存泄漏。

先看以下两个例子:

function fn1(){
  let test = new Array(1000).fill('a')
  return function(){
    console.log('A')
  }
}
let fn1Child = fn1()
fn1Child()

上例是一个闭包,但是它造成内存泄漏了吗?并没有,因为返回的函数中并没有对 fn1 函数内部的引用,也就是说,函数 fn1 内部的 test 变量完全是可以被回收的,那我们再来看:

function fn2(){
  let test = new Array(1000).fill('a')
  return function(){
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()

显然它也是闭包,并且因为 return 的函数中存在函数 fn2 中的 test 变量引用,所以 test 并不会被回收,也就造成了内存泄漏

那么怎样解决呢?

其实在函数调用后,把外部的引用关系置空就好了,如下:

function fn2(){
  let test = new Array(1000).fill('a')
  return function(){
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()
fn2Child = null  // 置空

综上,我们以后就不要再说闭包会造成内存泄漏啦!

应该说成:不正当地使用闭包可能会造成内存泄漏。

经典面试题

  • for 循环和闭包(号称必刷题)
var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]()
/* 输出
    3
    3
    3
/

这里的 i 是全局下的 i共用一个作用域,当函数被执行的时候这时的 i=3,导致输出的都是3。

  • 使用闭包改善上面的写法达到预期效果, 写法1:自执行函数和闭包
var data = [];

for (var i = 0; i < 3; i++) {
    (function(j){
      setTimeout( data[j] = function () {
        console.log(j);
      }, 0)
    })(i)
}

data[0]();
data[1]();
data[2]()

let 具有块级作用域,形成的3个私有作用域都是互不干扰的。

写法2:使用 let

var data = [];

for (let i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]()

三 原型链

引用类型四个规则

引用类型:Object、Array、Function、Date、RegExp。

  1. 引用类型,都具有对象特性,即可自由扩展属性

  2. 引用类型,都有一个隐式原型 __proto__ 属性,属性值是一个普通的对象。

  3. 引用类型,隐式原型 __proto__ 的属性值指向它的构造函数的显式原型 prototype 属性值。

  4. 当你试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型 __proto__(也就是它的构造函数的显式原型 prototype)中寻找。

instanceof

instanceof 运算符用于测试构造函数的 prototype 属性是否出现在对象原型链中的任何位置。 instanceof 的简易手写版,如下所示:

// 变量R的原型 存在于 变量L的原型链上
function instance_of (L, R) {    
  // 验证如果为基本数据类型,就直接返回 false
  const baseType = ['string', 'number', 'boolean', 'undefined', 'symbol']
  if(baseType.includes(typeof(L))) { return false }

  let RP = R.prototype;  // 取 R 的显示原型
  L = L.__proto__; // 取 L 的隐式原型
  while (true) {
    if (L === null) { // 找到最顶层
      return false;
    }
    if (L === RP) { // 严格相等
      return true;
    }
    L = L.__proto__;  // 没找到继续向上一层原型链查找
  }
}

image.png

四 为什么做「 JS 基础回顾 」

写作是一个回顾的过程,尝试写这个系列也主要是为了巩固 JavaScript 基础,并查漏补缺,更加深入理解。如果有错误或者不严谨的地方,请务必给予指正,十分感谢! 整个系列会持续更新,不会完结。

参考

总结

  • 作用域执行上下文之间最大的区别是: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变
  • 执行上下文最明显的就是this的指向执行时确定的。而作用域访问的变量编写代码的结构确定的。
  • 一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个闭包
  • 闭包 突破作用域,有权访问另一个函数作用户域变量函数
  • 不正当地使用闭包可能会造成内存泄漏(变量不会被释放回收)
  • 原型链就是一个过程原型原型链这个过程中的一个单位,贯穿整个原型链