JavaScript函数、作用域、闭包

424 阅读11分钟

概念

明晰三个概念:名称(标识符)、实体、名称绑定实体

  • 创建名称:声明(Hoist到所在作用域的顶部)
  1. 函数声明和变量声明会在任何代码被执行前处理
  2. 函数声明先于变量声明
let name;

JavaScript解析器: 预解析+执行
预解析:把声明提升至当前作用域的最前面,只提升声明,不包括赋值(所以可以屏蔽外面的同名变量) 注意:预解析是分script段进行的 在这里插入图片描述

  • 创建实体:字面量、函数方法等
new Object();
  • 在函数运行过程中,名称和实体都有自己的生存期,依据变量与绑定实体的关系分类:原始值模型、引用值模型
// 引用值模型
let name = new Object();

  • var 声明函数作用域,变量在函数退出时会被销毁。冗余声明不会报错,声明自动提升到函数顶部(会将冗余合并)。
  • let声明作用域,冗余声明及会报错,声明也不会自动提升(使用前必须先声明)
  • ECMAScript 6的声明风格:const>let>var(尽量不用)

所有使用var定义的全局变量和函数会成为window对象的属性和方法,let和const的顶级声明不会定义在全局上下文中,但在作用域的解析上效果相同。更早被垃圾回收

var方式声明变量其实是很不合理的,甚至可以说是一个语言层面的bug,没有块级作用域会带来很多难以理解的问题,比如for循环var变量泄露,变量覆盖等问题。所以ES6新增了块级作用域——let

作用域

可以先理解作用域为变量的生命周期、及其赋值的相关事宜,从不同维度可以对作用域有不同的分类:

  • 基于函数机制,首先从变量与绑定实体的方式即“作用域的产生机制”,可以分为静态、动态作用域
  • 其次提到“执行上下文”,JavaScript有三种作用域-函数、模块、全局

一、

静态作用域【作用域的产生机制】

静态/词法 作用域(空间最近):运行前通过分析名称所在的相对位置,就能确认作用域。 很少有语言采用动态作用域(时间最近)

JavaScript的词法( 静态 )作用域

  • 整个代码中只有函数可以限定作用域
  • 函数的作用域在函数定义时确定
  • 函数可以访问函数外的数据
  • 如果当前作用域规则中有声明变量名字了,就屏蔽外面的同名变量 -暂时性死区

欺骗静态作用域的方式: eval

副作用是引擎无法在编译时对作用域查找进行优化,将导致代码运行缓慢,且严格模式下受到限制。

非严格模式下,JavaScript引擎不知道eval执行的语句是以动态形式插入进来的,eval会对词法作用域的环境进行修改(新增覆盖变量)

function f(str,a) {
    eval(str);
    console.log(a,b);// 1、3
}

var b = 2;
f('var b = 3;', 1);

严格模式下,eval在运行时有自己的词法作用域

function f(str,a) {
    'use strict';
    eval(str);
    console.log(a,b);// 1、2
}

var b = 2;
f('var b = 3;', 1);

伪动态作用域this【作用域的产生机制】

采用动态作用域的语言中,程序中某个变量所引用的对象是在程序运行时刻根据程序的控制流信息来确定的

  • JavaScript中的this关键字绑定的含义由其所在的代码的调用方决定(形似动态作用域),
  • 可以理解为-this会在每次调用时在函数顶部隐式声明,而其他静态作用域的变量只在函数声明时候声明一次
  • JavaScript中类似的参数还有arguments
  • this作用域是基于调用栈的,和变量搜索的静态作用域不同,this的值是在执行上下文中获取,而不会在作用域链中搜索。
  • 绑定this的三种场景:方法调用、new、bind\call\apply
const obj = {
    id: 'in obj',
    cool: function() {
        console.log(this.id);
    }
}

var id = 'in global';

obj.cool();// 'in obj'

setTimeout(obj.cool,100);// 'in global'

对象的函数我们称之为其方法,但是注意-函数永远不会“属于”一个对象,对象拿到的只是对 函数对象 的引用,在实际调用时候,通过调用方式决定this

image.png


关于this我们可以希望this

  1. 是某一个固定的对象-硬绑定
  2. 默认是某个对象,支持修改-软绑定

硬绑定this

硬绑定是一种很常见的模式

const a = 'in global';
const obj = {
  a:'in obj',
}
function f() {
  console.log(this.a);
}
const hard = function () {
  f.call(obj);
}

hard(); // in obj
hard.call(window); // in obj

箭头函数

箭头函数:词法作用域的this,this继承定义所在的外层函数调用的this调用

  • 如果上一层还是箭头函数,则继续向上指,直到指向到有自己this的函数为止,并作为自己的this。

软绑定this

this在默认情况下不再指向全局对象(非严格模式)或undefined(严格模式),而是指向两者之外的一个对象

if (! Function.prototype.softBind) {
  Function.prototype.softBind = function(obj) {
    let fn = this;
    let curried = [].slice.call( arguments, 1);
    let bound = function() {
      return fn.apply(
        (!this || this === (window || global)) ? obj : this,// 检查函数的this,如果undefined或全局,obj覆盖
        curried.concat.apply(curried, arguments)
      );
    };
    bound.prototype = Object.create( fn.prototype);
    return bound;
  };
}

foo::bar;
// 等同于
bar.bind(foo);

foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);

二、

var、function关键字声明的变量的作用域是包围他们的函数,不在任何函数内声明-外层作用域

基于上述规律和引用环境的概念,JavaScript作用域有三种:函数、模块、全局

某刻/某处的引用环境/上下文:运行的某一时刻(对应代码某一处),当不针对某个名称,当前所有有效的名称组成的集合

全局作用域:如果网页中包含嵌入的窗格iframe,被嵌入文档与嵌入的iframe各自拥有不同的全局对象和Document对象

模块作用域:将一个JS文件直接通过scripts标签插入页面中与封装成模块最大的不同在于:

  • 前者的顶层作用域是全局作用域,在进行变量及函数声明时会污染全局环境。
  • 后者会形成一个属于模块自身的作用域,所有的变量及函数只有自己能访问,对外是不可见的。

函数作用域(执行上下文)

作用域链:全局作用域+函数作用域构成,内层函数可访问所有外层函数的局部变量

  • 每个上下文都有一个关联的变量对象,在上下文中定义的所有变量和函数都存在于这个对象上。上下文在所有代码被执行完后就会被销毁。
  • 全局的上下文是最外层的上下文,根据js的宿主环境,表示上下文的对象可能不一样。全局上下文是在应用程序退出时被销毁。浏览器-window对象
  • 每个函数调用都有自己的上下文,js程序的执行流就是通过上下文栈进行控制的,当代码执行流进入函数时,其上下文被推到一个上下文栈。同时还有一个作用域链,正在执行的上下文的变量对象在最前端。

活跃对象与函数对象

函数对象

  • prototype属性
  • 函数可执行代码的引用
  • 当函数对象被创建时候,这个函数对应的活跃对象就会被激活
  • 函数是可嵌套的,当嵌套的函数对象被创建时,它会包含一个外层函数对象所对应的活跃对象

闭包【实际指-闭包引用】的一种解释:拥有“外层函数对象所对应的活跃对象引用”的函数对象

函数的活跃对象

  1. 对应函数对象的引用
  2. 函数的形参、变量、 this
  3. 调用者的活跃对象-用于return之后的控制权转移
  4. 后续执行指令的地址- return之后的后续执行

p.s. 尾调用

  • 自由变量:在函数内部使用、在该函数外部声明
  • 绑定变量:在函数内部使用、在该函数内部声明(包括形参) 当尾调用的新函数用到了自由变量,就获得了访问其创建者的激活对象的权力,就不会进行尾递归优化

作用域链增强

作用域前端临时添加一个上下文

  1. try.catch:创建新对象包含 要抛出的错误对象的声明
  2. with
try{
    undefinedF();// 错误
} catch (err) {
    console.log(err);
}

console.log(err);// 不能访问到catch的块作用域

闭包

很多有关JavaScript的文章介绍闭包时候都把它定义为:从某个函数返回的函数所记住的上下文信息。其实这是闭包引用,在闭包引用中,我们可以明显地感觉到闭包的存在,其实任何JavaScript的函数创建时候都会创建闭包。

闭包:一种能够在函数声明中将环境信息与所属函数绑定在一起的数据结构,以函数中心视角看待静态作用域

函数的局部名称都是存在于调用堆栈,若没有闭包这个概念,外套函数返回内嵌函数后,外套函数的堆栈帧被删除,返回的内嵌函数所能引用的外套函数的局部名称消失


闭包虽然是在函数定义时就创建,但是并不意味这其中变量的值会停留在那一刻,闭包中的变量值会正常改变,(内嵌函数拿到的是外层函数的活跃对象)等到嵌套函数执行时候,它引用环境中的变量值也会随之改变。例如var经典事件绑定

可以通过立即执行一个函数来【返回一个函数的】的方式,截取当下的值。


为什么函数 A 已经弹出调用栈了,为什么函数 B 还能引用到函数 A 中的变量?

因为这时候函数 A 中的变量是存储在堆上的。现在 JS 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。


只要使用了回调函数,就是在引用闭包 即:函数在本身词法作用域外执行时候,它拿到的仍然是词法作用域

> var a ='global';
function f1(){
    console.log(a);
}
function f(f1){
    var a ='in f';
    f1(); //[Log] global
    console.log(a);// in f
}
f(f1);

// 另一种
var a ='global';

function f(){
    var a ='in f';
    console.log(a);
}
 
 f(); //[Log] in f

引用闭包的例子

jquery:把需要用到的方法添加到window的属性上,作为全局来暴露出去的。
zepto:基于return,把需要暴露的方法直接暴露出去。

function b(temp,x){
    function c (){
        console.log(x);
    }
    if(x<1){
        b(c,1);
    } else {
        temp();
    }
    c();
}

function a(){
}

b(a,0);
let a = 1;
function f(){
	let a = 0;
	return function (b) {
		console.log(a,b);
		return a++ + b++;
	}
}

const res = f();
console.log(res(a)); // 0+1
console.log(res(a)); // 1+1
console.log(res(a)); // 2+1

其他

函数返回值

注意:函数默认返回undefined 箭头函数区别是否加{}:

()=> "返回值"
()=> {"返回值不是我,是undefined"}
()=> { return "返回值是我"}

模拟call、apply

这部分参考了

  • 不传入this时,默认为 window
  • 传入 this 指向,让新的对象可以执行该函数。给新的对象添加一个函数,然后在执行完以后删除
Function.prototype.myCall = function (context) {
  var context = context || window
  // 给 context 添加一个属性
  context.fn = this
  // 将 context 后面的参数取出来
  var args = [...arguments].slice(1)
  var result = context.fn(...args)
  // 删除 fn
  delete context.fn
  return result
}

Function.prototype.myApply = function (context) {
  var context = context || window
  context.fn = this

  var result
  // 需要判断是否存储第二个参数
  // 如果存在,就将第二个参数展开
  if (arguments[1]) {
    result = context.fn(...arguments[1])
  } else {
    result = context.fn()
  }

  delete context.fn
  return result
}


Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  var _this = this
  var args = [...arguments].slice(1)
  // 返回一个函数
  return function F() {
    // 因为返回了一个函数,我们可以 new F(),所以需要判断
    if (this instanceof F) {
      return new _this(...args, ...arguments)
    }
    return _this.apply(context, args.concat(...arguments))
  }
}

实践代码风格

对象形参

让函数只有一个形参,参数是一个对象,这样适应了形参的易变性。这也是类的书写风格

函数式编程

嵌套函数+闭包 构成了JavaScript的函数式编程

防止内存泄漏

IE在IE9之前对JavaScript对象和COM对象使用了不同的垃圾回收机制,在这些版本的IE中,把HTML元素保存在某个闭包的作用域中,就相当于宣布该元素永远不会被销毁。比如:

function handle() {
    let ele = document.getElementById('id');
    ele.onclick = ()=> console.log(ele.id);
}

内嵌的匿名函数,通过handle的活动对象,拿到ele的引用,形成了循环引用。改动如下:

function handle() {
    let ele = document.getElementById('id');
    let id = ele.id;
    ele.onclick = ()=> console.log(id);
    ele = null;
}