作用域和闭包 | 青训营笔记

123 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 9 天

作用域和闭包

作用域和自由变量

作用域表示某个变量的可访问范围,JavaScript 中的作用域是词法作用域,也就是说作用域是由书写代码时函数声明的位置来决定的。

  • 全局作用域

全局作用域中声明的变量可以在整个程序中访问,它是最外层的作用域。

let a = 1;
function foo(){
    console.log(a);
}
foo(); // 1
  • 函数作用域

函数作用域中声明的变量只能在函数内部访问,它是最内层的作用域。

function foo(){
    let a = 1;
    console.log(a);
}
foo(); // 1
console.log(a); // ReferenceError: a is not defined
  • 块级作用域

块级作用域中声明的变量只能在块级作用域内部访问,它是最内层的作用域。

{
    let a = 1;
    console.log(a);
}
console.log(a); // ReferenceError: a is not defined
  • 自由变量

一个变量在当前作用域中没有定义,但是被使用了,向上级作用域查找,直到找到该变量的定义,这个变量就是自由变量。

let a = 1;
function foo(){
    console.log(a);
}
foo(); // 1

如果到全局作用域都没有找到该变量的定义,就会报错。

function foo(){
    console.log(a);
}
foo(); // ReferenceError: a is not defined

闭包

闭包是作用域应用特殊的一种情况,有两种表现:

  • 函数作为参数传递
function foo(fn){
    const a = 1;
    fn();
}
const a = 2;    // √
foo(function(){
    console.log(a);
}); // 2

在这里,函数定义在全局作用域中,这时函数内部的变量 a 就是自由变量,它会在函数被定义的地方向上级作用域查找,直到找到该变量的定义,也就是全局作用域中的 a,这时就会打印出 2。

  • 函数作为返回值返回
function foo(){
    let a = 1;  // √
    return function(){
        console.log(a);
    }
}
let fn = foo();
const a = 2;
fn();   // 1

在这里,函数定义在了 foo 函数中,这时函数内部的变量 a 就是自由变量,它会在函数被定义的地方向上级作用域查找,直到找到该变量的定义,也就是 foo 函数中的 a,这时就会打印出 1。

即函数被定义的作用域不是函数被调用的作用域

:::tip 因此,闭包的作用就是可以让函数访问并操作函数定义时所在的作用域中的变量。 :::

this

this 是 JavaScript 中的一个关键字,它的值取决于函数的调用方式。this 的值在函数被调用时才会确定,而不是在函数被定义时确定。

function foo(){
    console.log(this);
}
foo();  // window

foo.call({}); // {}

const fn1 = foo.bind({ x: 1 });
fn1();  // { x: 1 }

试看下面一段代码:

const Zhangsan = {
    name: '张三',
    sayHi: function(){
        console.log(this);
    }
    wait(){
        setTimeout(function(){
            console.log(this);
        }, 1000);
    }
}
Zhangsan.sayHi();   // { name: '张三' }
Zhangsan.wait();    // window

setTimeout 中,我们新创建了一个函数,这个执行的函数和对象中的函数是不同的,所以 this 指向的是 window。

const Zhangsan = {
    name: '张三',
    sayHi: function(){
        console.log(this);
    }
    wait(){
        setTimeout(() => {
            console.log(this);
        }, 1000);
    }
}
Zhangsan.sayHi();   // { name: '张三' }
Zhangsan.wait();    // { name: '张三' }

但如果使用箭头函数,this 指向的是对象本身。原因是箭头函数没有自己的 this,它的 this 是继承外层函数的 this。

实例

手写 bind

bind 函数的作用是将函数绑定到某个对象上,这样函数中的 this 就会指向这个对象。

应用举例:

function fn1(a,b){
    console.log('this'+this);
    console.log(a,b);
    return "this is fn1";
}

const fn2 = fn1.bind({ x: 1 },10,20);
const result = fn2();
console.log(result);    // this{ x: 1 } 10 20  this is fn1

那手写 bind 函数的话,就是将 fn1 函数绑定到 { x: 1 } 对象上,这样 fn1 中的 this 就会指向 { x: 1 } 对象。函数是一个对象,所以我们需要用到上面的原型链知识。

// fn1.bind(...) 
Function.prototype.mybind = function(context){    // fn1 是 Function 的实例
    arguments = Array.prototype.slice.call(arguments);    // 将列表转换为数组
    // bind 的第一个参数为 this
    const t = arguments.shift(); // shift() 方法用于把数组的第一个元素从其中删除,并返回第一个元素的值。
    const self = this;  // this 就是 fn1
    return function(){
        return self.apply(t, arguments);    // apply 用于执行一个函数,它的第一个参数为 this,第二个参数为数组
    }
}

手写 call

call 函数的作用是将函数绑定到某个对象上,这样函数中的 this 就会指向这个对象。

应用举例:

function fn1(a,b){
    console.log('this'+this);
    console.log(a,b);
    return "this is fn1";
}

const result = fn1.call({ x: 1 },10,20);
console.log(result);    // this{ x: 1 } 10 20  this is fn1

那手写 call 函数的话,就是将 fn1 函数绑定到 { x: 1 } 对象上,这样 fn1 中的 this 就会指向 { x: 1 } 对象。函数是一个对象,所以我们需要用到上面的原型链知识。

// fn1.call(...)
Function.prototype.mycall = function(context){    // fn1 是 Function 的实例
    arguments = Array.prototype.slice.call(arguments);    // 将列表转换为数组
    // call 的第一个参数为 this
    const t = arguments.shift(); // shift() 方法用于把数组的第一个元素从其中删除,并返回第一个元素的值。
    const self = this;  // this 就是 fn1
    return self.apply(t, arguments);    // apply 用于执行一个函数,它的第一个参数为 this,第二个参数为数组
}

bindcall 的区别是 bind 返回的是一个函数,而 call 返回的是函数的执行结果。

手写 apply

apply 函数的作用是将函数绑定到某个对象上,这样函数中的 this 就会指向这个对象。

应用举例:

function fn1(a,b){
    console.log('this'+this);
    console.log(a,b);
    return "this is fn1";
}

const result = fn1.apply({ x: 1 },[10,20]);
console.log(result);    // this{ x: 1 } 10 20  this is fn1

手写:

// fn1.apply(...)
Function.prototype.myapply = function(context){    // fn1 是 Function 的实例
    arguments = Array.prototype.slice.call(arguments);    // 将列表转换为数组
    // apply 的第一个参数为 this
    const t = arguments.shift(); // shift() 方法用于把数组的第一个元素从其中删除,并返回第一个元素的值。
    const self = this;  // this 就是 fn1
    return self.apply(t, arguments[0]);    // apply 用于执行一个函数,它的第一个参数为 this,第二个参数为数组
}

applycall 的区别是 apply 的第二个参数是一个数组,而 call 的第二个参数是一个列表。

闭包的应用

  • 隐藏数据

如需要编写一个缓存的插件,只提供 API 但是隐藏内部的数据。

function createCache(){
    const data = {};
    return {
        set: function(key, value){
            data[key] = value;
        },
        get: function(key){
            return data[key];
        }
    }
}

const c = createCache();
c.set('a', 100);
c.get('a'); // 100

这样做之后,我们如果像不使用 setget 方法,而是直接访问 data 对象,那么就没法做到,实现了隐藏数据的目的。

作用域

手写十个 a 标签,要求点击的时候弹出对应的序号。

试看下面的代码:

let i, a;
for(i=0; i<10; i++){
    a = document.createElement('a');
    a.innerHTML = i + '<br>';
    a.addEventListener('click', function(e){
        e.preventDefault(); // 阻止默认行为
        alert(i);
    })
    document.body.appendChild(a);
}

这样写的话,点击每个 a 标签的时候,弹出的都是 10,因为 i全局变量,循环结束之后,i 的值为 10。

解决方法:

let a;
for(let i=0; i<10; i++){
    a = document.createElement('a');
    a.innerHTML = i + '<br>';
    a.addEventListener('click', function(e){
        e.preventDefault(); // 阻止默认行为
        alert(i);
    })
    document.body.appendChild(a);
}

i 改为 let 声明的局部变量,这样每次循环的时候,都会创建一个新的 i,这样就不会出现上面的问题了。