[JS] You Don't Know JavaScript [Scope & Closures] -

前言

目前为止我们都专注在解释辞法範围,以及他会对程式中的变量与使用产生什么影响,本章节会将角度转移到closure,回顾一下Limiting Scope Exposure ?中所提到的POLE原则,我们应该使用块状作用域(或函式作用域)来减少变量的洩露,这有助于程式的安全性与可维护性并可以避免命名冲突等等的错误,而closure就是建立在这个观念之上,可以将变量封装不让他们洩露到外部/全域中,并且保留内部函式的访问权限,函式会通过closure记住引用的作用域变量。

我们在Limiting Scope Exposure ?提到的範例中(factorial(...)),我们尝试了在自身作用域中访问了外部的callback function这就是closure,closure是对于编成发明中重要的特徵之一,他是主要编成範例的基础,它包含function programming(FP),module,class-oriented design

See the Closure

closure是function的行为,object与class都不具有closure只有function才有。

// outer/global scope: RED(1)function lookupStudent(studentID) {    // function scope: BLUE(2)    var students = [        { id: 14, name: "Kyle" },        { id: 73, name: "Suzy" },        { id: 112, name: "Frank" },        { id: 6, name: "Sarah" }    ];    return function greetStudent(greeting){        // function scope: GREEN(3)        var student = students.find(            student => student.id == studentID        );        return `${ greeting }, ${ student.name }!`;    };}var chosenStudents = [    lookupStudent(6),    lookupStudent(112)];// accessing the function's name:chosenStudents[0].name; // greetStudentchosenStudents[0]("Hello"); // Hello, Sarah!chosenStudents[1]("Howdy"); // Howdy, Frank!

上面的程式中,在最外层定义了一个lookupStudent(...)并且在他的内部return了greetStudent(...);呼叫了lookupStudent(...)两次并将他的结果都存放在chosenSrudents阵列中。

通过使用.name发现其实lookupStudent(...)返回的是一个greetStudents(...)的实例,正常来说对于一个function的呼叫结束后,会将此funciton中的内部的变量都丢弃(garbage collected);但是对于这里来说有些不同,greetStudent他内部中有使用到了外部(lookupStudent(...))的变量(studentID,students),对于这个内部函式引用到外部函式变量的行为就称为closure。

closure允许greetStudent(...)继续访问外部函式作用域的变量,儘管他已经被完成呼叫(garbage collected)students和studentID的实例不会进到GC'd而是被保留在记忆体中,所以在greetStudent(...)在调用这些变量实体的时候他们都仍然存在,所以说如果JS中没有closure那们在lookupStudet(...)执行完成后会立即会立即清除他的作用域并将studentIDstudents收近GC,就因为这个特性才能让我们在呼叫choseStudents[1]("Hello")的时候不会因为studentIDstudents不存在而导致ReferenceError。

Adding Up Closrues

function adder(num1) {    return function addTo(num2){        return num1 + num2;    };}var add10To = adder(10);var add42To = adder(42);add10To(15);    // 25add42To(9);     // 51

内部的addTo(...) closing 外部(adder(...))的num1,所以在adder(...)执行结束后 访问得到num1的值,所以add10To会保留着num1 = 10;add10To(15);可以调用到存在在记忆体中的num1 = 10;,让结果可以输出10+15 = 25。

对于每一个adder(...)来说他内部都各自定义了自己内部的addTo(...),因此每一个都是各自独立的closure,所以上面程式中的add10To(..)和add42To(..)他们其实都有各自独立的closure,即使closure是基于编译时期处理的lexcial scope但是他仍被视为函数实例运行的特徵。

Live Link,Not a Snapshot

很多人认为closure的这个可以读取到保留变量的行为,是读取到这个保留值的参照,但这其实是错误的,因为closure的这个行为是是确实是保留对这个变量的访问,除此之外不限于只读取这个值,还可以对这个值进行操作,对于closure的function我们可以任意的使用这个变量(读/写)并且在整个函数中都可以使用,这就是closure强大的地方。

function makeCounter() {    var count = 0;    return function getCurrent() {        count = count + 1;        return count;    };}var hits = makeCounter();hits();     // 1hits();     // 2hits();     // 3

const closed over 内部的getCurrent(...)这样他可以被保存在记忆体中不会被GC,所以当调用hits的时候会不断返回更新后的count值(递加)。

虽然closure通常都来自于函式,但是实际上是不一定需要的,只要外部作用域中有一个内部函式就可以

var hits;{   // an outer scope (but not a function)    let count = 0;    hits = function getCurrent(){        count = count + 1;        return count;    };}hits();     // 1hits();     // 2hits();     // 3

在一个块状作用域中宣告一个function,这样就可以形成一个closure,但是由于FiB的关係,所以使用function expression而不是function declaration。

对于closure还有一个令人误会的地方,closure他保存的是变量而不是变数

var studentName = "Frank";var greeting = function hello() {    // we are closing over `studentName`,    // not "Frank"    console.log(        `Hello, ${ studentName }!`    );}studentName = "Suzy";greeting(); // Hello, Suzy!

上面的程式中,很多人会以为closure保存的事studentName的值(Frank),但实际是他保留的是studentName这个变量,所以当后面重新assignment给studentName的时候值就会发生变化,除了这个之外还有一个经典的错误,for loop。

var keeps = [];for (var i = 0; i < 3; i++) {    keeps[i] = function keepI(){        // closure over `i`        return i;    };}keeps[0]();   // 3 -- WHY!?keeps[1]();   // 3keeps[2]();   // 3

你可能会认为keeps[0]();应该要return 0因为他是在i = 0的时候赋予给他的,但是这要是不对的,记住closure保存的是变量而不是变量里面的值,由于for loop的结构会让我们误以为他的每次迭代都会宣告一个新的i,但由于i是由var宣告的所以整个迴圈中都只会有一个var(参考The (Not So) Secret Lifecycle of Variables),虽然三个keeps(...)都有各自的closures,但是本质上他们共享一个i,所以当迴圈结束时三个function才都return 3,所以每一个变量都只能存取一个值,所以我们可以尝试在每个迴圈中都新增一个各自的变量。

var keeps = [];for (var i = 0; i < 3; i++) {    // new `j` created each iteration, which gets    // a copy of the value of `i` at this moment    let j = i;    // the `i` here isn't being closed over, so    // it's fine to immediately use its current    // value in each loop iteration    keeps[i] = function keepEachJ(){        // close over `j`, not `i`!        return j;    };}keeps[0]();   // 0keeps[1]();   // 1keeps[2]();   // 2

在每个迴圈中都宣告一个属于自己的变量(let宣告),然后赋予它当前i的值,因为j是定义在个迴圈中的变量所以所以不会被重新赋值,closure的对象就从i变为各自定义的j,所以输出的值就会是预期的。

如果我们在迴圈中使用非同步的行为(setTimeout(...),keepEachJ(...)...)也会出现这个情况,在The (Not So) Secret Lifecycle of Variables中提到如果在loop中使用let宣告,他会在每一次的迭代中都创建一个新的变量,这正是我们希望它发生的。

var keeps = [];for (let i = 0; i < 3; i++) {    // the `let i` gives us a new `i` for    // each iteration, automatically!    keeps[i] = function keepEachI(){        return i;    };}keeps[0]();   // 0keeps[1]();   // 1keeps[2]();   // 2

What If I Can't See It?

如果我们在操作function的时候,我们需要产生一个closure,当我们确实产生了一个closure后他将需要的变量保存下来,但我们却没有去访问过这么变数,那么这个closure就不会存在。

function lookupStudent(studentID) {    return function nobody(){        var msg = "Nobody's here yet.";        console.log(msg);    };}var student = lookupStudent(112);student(); // Nobody's here yet.

虽然closure将studentID保存下来,但是在内部的nodody(...)却只使用自身宣告的变数msg而没有用到外部的studentID,这样会让JS引擎知道在lookupStudent(...)执行完之后没有东西需要使用到studentID,便会将它从记忆体中清除。

如果没有去呼叫内部的函式,那么我们也无法观测到closure的存在。

function greetStudent(studentName) {    return function greeting(){        console.log(            `Hello, ${ studentName }!`        );    };}greetStudent("Kyle");// nothing else happens

外部的function他确实被呼叫并且有return一个内部的function,这虽然是一个会产生closure的行为,但是由于内部的function并没有被呼叫所以就算以技术上来说JS确实为她创造了一个closure,但是无法观察也没以意义。

Observable Definition

Closure is observed when a function uses variable(s) from outer scope(s) even while running in a scope where those variable(s) wouldn't be accessible.

对于closure定义而言有一些关键的部分:

function必须要被呼叫必须引用一个外部作用域的变量Must be invoked in a different branch of the scope chain from the variable(s)

The Closure Lifecycle and Garbage Collection (GC)

由于closure的本质与function息息相关,所以只要仍有对该function的引用,则closure则会持续存在,换句话说如果有10个function都closure同一个变量,随着时间流逝其余九个都不在使用这个变量,就算只剩下一个那么这个closure依然会继续存在,等到最后一个funciton都不在使用这个变量,那么这个closure就会消失。

如果没有这个机制的话,若在记忆体中无论是否有用到都不断保存着我们之前所使用过的变量,那么总有一天会导致记忆体内存不足。

Per Variable or Per Scope?

对于closure来说,他是保留引用的外部变量还是将整个作用域以及所有变量?以技术上来说closure保存的是变量而不是作用域,但实际的情况会更为複杂。

function manageStudentGrades(studentRecords) {    var grades = studentRecords.map(getGrade);    return addGrade;    // ************************    function getGrade(record){        return record.grade;    }    function sortAndTrimGradesList() {        // sort by grades, descending        grades.sort(function desc(g1,g2){            return g2 - g1;        });        // only keep the top 10 grades        grades = grades.slice(0,10);    }    function addGrade(newGrade) {        grades.push(newGrade);        sortAndTrimGradesList();        return grades;    }}var addNextGrade = manageStudentGrades([    { id: 14, name: "Kyle", grade: 86 },    { id: 73, name: "Suzy", grade: 87 },    { id: 112, name: "Frank", grade: 75 },    // ..many more records..    { id: 6, name: "Sarah", grade: 91 }]);addNextGrade(81);addNextGrade(68);// [ .., .., ... ]

上面的程式中grades通过closure保留在addGrade(...)之中,所以在每次呼叫addNextGrade(...)时,都可以再次访问到grades并对他从新排列,但是记住,这个closure是对于grades这个变量而不是里面的数值。

除了grades被closure之外,由于addGrade(...)中呼叫的sortAndTrimGradesList(...),这代表就算manageStudentGrades(...)已经结束了但sortAndTrimGradesList(...)还是被closure给保留了下来以便addGrade(...)可以持续调用,

根据closure的定义,由于内部函数没有使用到getGrade(...)studentRecords所以不会对他们进行closure,在manageStudentGrades(...)结束后他们就会被清除。

但是凡事都有例外

function storeStudentInfo(id,name,grade) {    return function getInfo(whichValue){        // warning:        //   using `eval(..)` is a bad idea!        var val = eval(whichValue);        return val;    };}var info = storeStudentInfo(73,"Suzy",87);info("name"); // Suzyinfo("grade"); // 87

上面的程式码中,我们使用了eval做了一些小动作,就算在后面的info(...)中没有使用到所有的变量,但是JS依然将所有的变量都保存起来。

在与许多现在的JS引擎中,他们都对于删除从未明确引用的closure範围中变量这件事不断的进行优化,但是正如我们上面所使用的方法(eval),在某些情况下JS并不会如预期的那样操作,因为会有这些意外事件发生,所以作者建议不应该随便高估他的适用性,如果有一个较大的值被closure给保留在记忆体中但他却是不被需要的(特殊状况),这样会对整个程式产生记忆体不够的威胁,所以作者认为手动丢弃这些大量且不被需要的值是比较安全的。

function manageStudentGrades(studentRecords) {    var grades = studentRecords.map(getGrade);    // unset `studentRecords` to prevent unwanted    // memory retention in the closure    studentRecords = null;    return addGrade;    // ..}

虽然在我们分析closure的时候,我们知道因为studentRecords没有被调用到,所以JS自动的将它清除(不产生closure),但是作者希望如果开发者知道这个变量不会再被使用,那么可以手动的将它的值清除以降低错误的发生,虽然技术上来说getGrade(...)也是不会再被使用到的,如果这个function也会消耗大量内存那么也建议手动将它清除,但是在上面的例子中getGrade(...)并不需要做这样的处理。

所以了解closure在程式中出现的位置以及他保存了哪些变量是很重要的,应该仔细管理这些保存的值以便让他使用最低限度的需求而不浪费内存。


Why Closure?

若我们不使用closure的情况下建立一个按钮,在点击他后透过AJAX发送一些数据。

var APIendpoints = {    studentIDs:        "https://some.api/register-students",    // ..};var data = {    studentIDs: [ 14, 73, 112, 6 ],    // ..};function makeRequest(evt) {    var btn = evt.target;    var recordKind = btn.dataset.kind;    ajax(        APIendpoints[recordKind],        data[recordKind]    );}// <button data-kind="studentIDs">//    Register Students// </button>btn.addEventListener("click",makeRequest);

makeRequest(...)接收一个来自clich事件的evt物件,他需要从目标按钮中检索data-kind attribute并使用用这个值去决定AJAX需要请求哪些数据,虽然这么做事可以的,但是由于每一次都需要去读取DOM的attrubute,所以会导致效率低下,所以我们可以试着使用closure的方式改写。

var APIendpoints = {    studentIDs:        "https://some.api/register-students",    // ..};var data = {    studentIDs: [ 14, 73, 112, 6 ],    // ..};function setupButtonHandler(btn) {    var recordKind = btn.dataset.kind;    btn.addEventListener(        "click",        function makeRequest(evt){            ajax(                APIendpoints[recordKind],                data[recordKind]            );        }    );}// <button data-kind="studentIDs">//    Register Students// </button>setupButtonHandler(btn);

在呼叫setupButtonHandler的时候就检索了data-kind attraubute并将他赋予给recordKind,然后由内部的makeRequest(...)使用其值来发送将对应的Request,通过closure我们将recordKind储存让内部的makeRequest(...)可以随时使用它,我们也可以将request的值利用closure保存下来。

function setupButtonHandler(btn) {    var recordKind = btn.dataset.kind;    var requestURL = APIendpoints[recordKind];    var requestData = data[recordKind];    btn.addEventListener(        "click",        function makeRequest(evt){            ajax(requestURL,requestData);        }    );}

将requestURL与requestData透过closure保存起来,这样更容易理解而且效率也更好。

Summary

本章节中介绍了什么事closure,他的定义与他所带来的好处。

Closure的精神:

Observational:closure是一个函数实例,即使将这个函数传递给其他作用域并且在其他作用域中调用,他也会记住其外部的变量(储存至记忆体中)。Implementational:closure is a function instance and its scope environment preserved in-place while any references to it are passed around and invoked from other scopes.

Closure的好处:

通过保存之前确定的数据而不必每次都重新计算,closure可以提高效率。closrue可以通过将变量封装还提高可读性与限制作用域的暴露,也同时保障了这个变量可以在将来使用,由于不需要在每次调用都重新传递保留的信息,所以更有利于function之间的互动。

参考文献:
You Don't Know JavaScript -2nd


关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章