阅读 1121

深入理解JavaScript闭包之闭包的使用场景

本篇文章是上一篇 深入理解JavaScript闭包之什么是闭包文章的下篇,闭包的使用场景。

基础概念

1.函数作用域

定义在函数中的参数和变量在函数外部是不可见的。

2.块级作用域(私有作用域)

任何一对花括号中的语句都属于一个快,在这之中的所有变量在代码块外都是不可见的,我们称之为块级作用域。大多数类C语言都拥有块级作用域,JS却没有,比如在for循环中定义的i,出了for循环还是有这个i变量。

3.私有变量

私有变量包括函数的参数,局部变量和函数内部定义的其他函数。

4.静态私有变量

私有变量是每个实例都是独立的,而静态私有变量是共用的。

5.特权方法

有权访问私有变量的方法称为特权方法。

6.单例模式

确保一个类只有一个实例,即多次实例化该类,也只返回第一次实例化后的实例对象。该模式不仅能减少不必要的内存开销,并且可以减少全局的函数和变量冲突。 可以来看一个简单的例子:

let userInfo = {
    getName() {},
    getAge() {},
}
复制代码

上面代码中,使用对象字面量创建的一个获取用户信息的对象。全局只暴露了一个 userInfo 对象,比如获取用户名,直接调用 userInfo.getName()。userInfo对象就是单例模式的体现。如果把 getName 和 getAge 定义在全局,很容易污染全局变量。命名空间也是单例模式的体现。平时开发网站中的登录弹窗也是一个很典型的单例模式的应用,因为全局只有一个登录弹窗。更多的可以看从ES6重新认识JavaScript设计模式(一): 单例模式这边文章。

7.构造函数模式

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayName = function() {
        console.log(obj.name);
    }
}

const person1 = new Person('litterstar', 18);
console.log(person1);
复制代码

特点:

  1. 可以使用 constructor 或 instanceof识别对象实例的类型
  2. 使用 new 来创建实例

缺点:

  1. 每次创建实例时,每个方法都要被创建一次

8.原型模式

function Person() {}

Person.prototype.name = 'litterstar';
Person.prototype.age = 18;
Person.prototype.sayName = function () {
    console.log(this.name);
}
const person1 = new Person();
复制代码

特点: 方法不会被重复创建

缺点:

  1. 不能初始化实例参数
  2. 所有的属性和方法都被实例共享

构造函数模式 和 原型模式

闭包的应用场景

1. 模仿块级作用域

比如我们可以使用闭包能使下面的代码按照我们预期的进行执行(每隔1s打印 0,1,2,3,4)。

for(var i = 0; i < 5; i++) {
    (function(j){
        setTimeout(() => {
            console.log(j);
        }, j * 1000);
    })(i)
}
复制代码

我们应该尽量避免往全局作用域中添加变量和函数。通过闭包模拟的块级作用域

2. 私有变量

JavaScript中没有私有成员的概念,所有属性都是公有的。但是有私有变量的概念,任何在函数中定义的变量,都可以认为是私有变量,因为在函数的外部不能访问这些变量。私有变量包括函数的参数,局部变量和函数内部定义的其他函数。

来看下面这个例子

function add(a, b) {
    var sum = a + b;
    return sum;
}
复制代码

add 函数内部,有3个私有变量,a, b, sum。只能在函数内部访问,函数外面是访问不到它们的。但是如果在函数内部创建一个闭包,闭包可以通过自己的作用域链就可以访问这些变量。所以利用闭包,我们就可以创建用于访问私有变量的公有方法(也称为特权方法)

有两种在对象上创建特权的方法。 第一种,在构造函数中定义特权方法

function MyObject() {
    // 私有变量和私有函数
    var privateVariable = 10;
    function privateFunction() {
        return false;
    }
    // 特权方法
    this.publicMethod = function() {
        privateVariable++;
        return privateFunction;
    }
}
复制代码

这个模式在构造函数内部定义了私有变量和函数,同时创建了能够访问这些私有成员的特权方法。能够在构造函数中定义特权方法,是因为特权方法作为闭包有权访问在构造函数中定义的所有变量和函数。 上面代码中,变量 privateVariable 和函数 privateFunction() 只能通过特权方法 publicMethod()来访问。在创建 MyObject 实例后,只能使用 publicMethod来访问 变量 privateVariable 和函数 privateFunction()

第二种,利用私有和特权成员,可以隐藏哪些不应该被直接修改的数据。

function Foo(name){
    this.getName = function(){
        return name;
    };
};
var foo = new Foo('luckyStar');
console.log(foo.name); //  => undefined
console.log(foo.getName()); //  => 'luckyStar'
复制代码

上面代码的构造函数中定义了一个特权方法 getName(),这个方法可以在构造函数外面使用,可以通过它访问内部的私有变量name。因为该方法是在构造函数内部定义的,作为闭包可以通过作用域链访问name。私有变量 nameFoo的每个实例中都不一样,因此每次调用构造函数都会重新创建该方法。

在构造函数中定义特权方法的缺点就是你必须使用构造函数模式。之前一篇文章 JavaScript的几种创建对象的方式 中提到构造函数模式会针对每个实例创建同样一组新方法,使用静态私有变量实现特权可以避免这个问题。

3. 静态私有变量

创建特权方法也通过在私有作用域中定义私有变量或函数来实现。

(function() {
    var name = '';
    //
    Person = function(value) {
        name = value;
    }
    Person.prototype.getName = function() {
        return name;
    }
    Person.prototype.setName = function(value) {
        name = value;
    }
})()

var person1 = new Person('xiaoming');
console.log(person1.getName()); // xiaoming
person1.setName('xiaohong');
console.log(person1.getName()); // xiaohong

var person2 = new Person('luckyStar');
console.log(person1.getName()); // luckyStar
console.log(person2.getName()); // luckyStar

复制代码

上面代码通过一个匿名函数实现块级作用域,在块级作用域中 变量 name 只能在该作用域中访问,同样的通过闭包(作用域链)的方式实现 getNamesetName 来访问 name, 而 getNamesetName 又是原型对象的方法,所以它们成了 Person 实例的共享方法。 这种模式下,name 就变成了一个静态的、由所有实例共享的属性。在一个实例上调用 setName() 会影响所有的实例。

4. 模块模式

模块模式是为单例创建私有变量和特权方法。单例(singleton),指的是只有一个实例的对象。

var singleton = {
    name: value,
    method: function() {},
}
复制代码

上面使用对象字面量的方式来创建单例对象,这种适用于简单的应用场景。复杂点的,比如改对象需要一些私有变量和私有方法

模块模式通过单例添加私有变量和特权方法能够使其增强。

var singleton = function() {
    var privateVarible = 10;
    function privateFunction() {
        return false;
    }

    return {
        publicProperty: true,
        publicMethod: function() {
            privateVarible++;
            return privateFunction();
        }
    }
}
复制代码

模块模式使用了一个返回对象的匿名函数。在这个匿名函数内部,首先定义了私有变量和函数.

增强模块模式

var singleton = function() {
    var privateVarible = 10;
    function privateFunction() {
        return false;
    }

    var object = new CustomType();
    object.publicProperty = true;
    object.publicMethod = function() {
        privateVarible++;
        return privateFunction();
    }
    // 返回这个对象
    return object;
}
复制代码

在返回对象之前加入对其增强的代码。这种增强的模块模式适合单例必须是某种类型的实例。

Vue源码中的闭包

1. 数据响应式Observer中使用闭包(省略闭包之外的相关逻辑)

function defineReactive(obj, key, value) {
    return Object.defineProperty(obj, key, {
        get() {
            return value;
        },
        set(newVal) {
            value = newVal;
        }
    })
}

复制代码

value 还函数中的一个形参,属于私有变量,但是为什么在外部使用的时候给value赋值,还是能达到修改变量的目的呢。

这样就形成了一个闭包的结构了。根据闭包的特性,内层函数可以引用外层函数的变量,并且当内层保持引用关系时外层函数的这个变量,不会被垃圾回收机制回收。那么,我们在设置值的时候,把newVal保存在value变量当中,然后get的时候再通过value去获取,这样,我们再访问 obj.name时,无论是设置值还是获取值,实际上都是对value这个形参进行操作的。

2. 结果缓存

Vue源码中经常能看到下面这个cached函数(接收一个函数,返回一个函数)。

/**
* Create a cached version of a pure function.
*/
function cached (fn) {
var cache = Object.create(null);
return (function cachedFn (str) {
    var hit = cache[str];
    return hit || (cache[str] = fn(str))
})
}
复制代码

这个函数可以读取缓存,如果缓存中没有就存一下放到缓存中再读。闭包正是可以做到这一点,因为它不会释放外部的引用,从而函数内部的值可以得以保留。

现在再看源码或者现在再看自己写的代码的时候,就会发现,不经意间其实我们已经写过和见过很多闭包了,只是之前可能不太认识而已。比如这篇文章 记忆化技术介绍——使用闭包提升你的 React 性能也提到了闭包。

React Hooks中闭包的坑

我们先来看一下使用 setState 的更新机制:

ReactsetState函数实现中,会根据一个变量isBatchingUpdates 判断是直接更新this.state还是放到 队列中回头再说。而isBatchingUpdates 默认是false,也就表示setState会同步更新this.state。但是,有一个函数 batchedUpdates, 这个函数会把isBatchingUpdates修改为true,而当React在调用事件处理函数之前就会调用这个batchedUpdates,造成的后果,就是由React控制的事件处理程序过程setState不会同步更新this.state

知道这些,我们下面来看两个例子。

下面的代码输出什么?

class Example extends React.Component {
   constructor() {
     super();
     this.state = {
       val: 0
     };
   }
   
   componentDidMount() {
     this.setState({val: this.state.val + 1});
     console.log(this.state.val);    // 第 1 次 log
 
     this.setState({val: this.state.val + 1});
     console.log(this.state.val);    // 第 2 次 log
 
     setTimeout(() => {
       this.setState({val: this.state.val + 1});
       console.log(this.state.val);  // 第 3 次 log 1 
 
       this.setState({val: this.state.val + 1});
       console.log(this.state.val);  // 第 4 次 log 2
     }, 0);
   }
 
   render() {
     return null;
   }
 };
复制代码

打印结果是: 0, 0, 2, 3。

  1. 第一次和第二次都是在react自身生命周期内,触发 isBatchingUpdates 为true, 所以并不会直接执行更新state, 而是加入了 dirtyComponents,所以打印时获取的都是更新前的状态 0
  2. 两次setState时,获取到 this.state.val 都是 0,所以执行时都是将0设置为1,在react内部会被合并掉,只执行一次。设置完成后 state.val值为1。
  3. setTimeout中的代码,触发时 isBatchingUpdates为false,所以能够直接进行更新,所以连着输出 2, 3

上面代码改用react hooks的话

import React, { useEffect, useState } from 'react';

const MyComponent = () => {
    const [val, setVal] = useState(0);

    useEffect(() => {
        setVal(val+1);
        console.log(val);

        setVal(val+1);
        console.log(val);

        setTimeout(() => {
            setVal(val+1);
            console.log(val);

            setVal(val+1);
            console.log(val);
        }, 0)
    }, []);
    return null
};

export default MyComponent;
复制代码

打印输出: 0, 0, 0, 0。

更新的方式没有改变。首先是因为 useEffect 函数只运行一次,其次setTimeout是个闭包,内部获取到值val一直都是 初始化声明的那个值,所以访问到的值一直是0。以例子来看的话,并没有执行更新的操作。

在这种情况下,需要使用一个容器,你可以将更新后的状态值写入其中,并在以后的 setTimeout中访问它,这是useRef的一种用例。可以将状态值与refcurrent属性同步,并在setTimeout中读取当前值。

关于这部分详细内容可以查看 React useEffect的陷阱。React Hooks 的实现也用到了闭包,具体的可以看 超性感的React Hooks(二)再谈闭包

总结

当在函数内部定义了其他函数,就创建了闭包。闭包有权访问包含函数内部的所有变量,原理如下:

  • 在后台执行环境中,闭包的作用域链包含它自己的作用域链、包含函数的作用域和全局作用域
  • 通常,函数的作用域及其所有变量都会在函数执行结束后销毁
  • 但是,当函数返回来了一个闭包,这个函数的作用域将一直在内存中保存在闭包不存在为止。

使用闭包可以在JavaScript中模仿块级作用域(JavaScript本身没有块级作用域的概念),要点如下:

  • 创建并立即调用一个函数,这样既可以执行其中的代码,又不会在内存中留下对该函数的引用
  • 结果就是函数内部的所有变量都会被销毁 -- 除非将某些变量赋值给了包含作用域(即外部作用域)中的变量

闭包还可以用于在对象中创建私有变量,相关概念和要点如下。

  • 即使JavaScript中没有正式的私有对象属性的概念,但可以使用闭包来实现公有方法,而通过公有方法可以访问在包含作用域中定义的变量
  • 可以使用构造函数模式,原型模式来实现自定义类型的特权方法也可以使用模块模式、增强的模块模式来实现单例的特权方法

参考

其他

最近发起了一个100天前端进阶计划,主要是深挖每个知识点背后的原理,欢迎关注 微信公众号「牧码的星星」,我们一起学习,打卡100天。同时也会分享一些自己学习的一些心得和想法,欢迎大家一起交流。