五、《JavaScript高级程序设计》笔记

104 阅读11分钟

函数

闭包

匿名函数经常被误认为是闭包, 其实闭包是指引用了另一个函数作用域内变量的函数叫闭包

站在作用域的角度看, 闭包继承(copy)了外部函数的作用域链

function createComparisonFunction(propertyName) {
   return function (object1, object2) {
      let value1 = object1[propertyName];
      let value2 = object2[propertyName];
      if (value1 < value2) {
         return -1;
      } else if (value1 > value2) {
         return 1;
      } else {
         return 0;
      }
   };
}

在上面的代码中, 会发现这两行代码:

let value1 = object1[propertyName];
let value2 = object2[propertyName];

作用域链

这个代码比较特殊, createComparisonFunction函数虽然最后返回了个匿名函数, 但是却用到了外部函数的变量propertyName, 按道理来说, 如果我们调用内部的匿名函数时, 外部函数已经没了才对, 他的变量应该也没了, 但是实际上却能够用到

这就是闭包的魅力, 内部函数在调用时, 其实是拥有了外部函数的作用域, 如果外部函数还有外部函数, 那么还会有外外部函数的作用域, 这专业点叫作用域链, 该链条的顺序是至内到外串连在一起, 此时最内部的函数可以引用外部作用域链的所有变量(最终到全局作用域)

在函数执行时,要从作用域链中查找变量,以便读、写值, 来看下下面这段代码:

function compare(value1, value2) {
   if (value1 < value2) {
      return -1;
   } else if (value1 > value2) {
      return 1;
   } else {
      return 0;
   }
}
let result = compare(5, 10);

此时定义的compare函数在全局上下文中被调用, 在初次调用该函数时, 会为他创建arguments, value1value2活动对象, 这些作用域链的第一层对象, 然后才是全局作用域的this, resultcompare对象, 这是作用域链的第二层对象

只有在函数执行期间才存在的对象(函数局部上下文中的对象)叫活动对象也可以叫局部变量对象, 在全局作用域内的对象(全局上下文内的对象)叫 全局变量对象

  • 在定义compare函数时, 会收集全局上下文中的变量并为其创建作用域链, 保存在内部的[[Scope]]
  • 在调用compare函数时, 会为compare创建执行上下文然后通过赋值[[Scope]]来创建其作用域链, 最后创建函数的活动对象并将其存入作用域链的首位

image.png

函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了

在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中, 因此,在createComparisonFunction()函数中,匿名函数的作用域链中实际上包含 createComparisonFunction()的活动对象

image.png

注意看下上面的图片中的两个作用域链, 不是只有一个作用域链哦

我称之为作用域链的继承和copy

匿名函数继承并copy了外部函数createComparisonFunction的活动对象和全局的变量对象并创建了一条新的作用域链, 然后外部函数createComparisonFunction的作用域链被销毁, 所以在匿名函数的变量没有被销毁之前, 外部的活动对象还会存在

// 创建比较函数
let compareNames = createComparisonFunction('name');
// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;

这里,创建的比较函数被保存在变量 compareNames 中。把 compareNames 设置为等于 null 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁

因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

闭包的 this 对象

闭包中使用this比较复杂, 如果内部函数不是箭头函数的话, 则this对象在运行时, 会被绑定到执行函数上下文中, 如果是全局函数调用则this在非严格模式下会被绑定到window对象上, 如果在严格模式下则为undefined

闭包的this就是谁调用的它, 就是谁的this

虽然看起来很好理解, 但实际上真的是这样么?

window.identity = 'The Window';
let object = {
   identity: 'My Object',
   getIdentityFunc() {
      return function() {
         return this.identity;
      };
   }
};
console.log(object.getIdentityFunc()()); // 'The Window'

看上面的打印 The Window

为什么呢? 其实可以试试, 在闭包函数内是否能够访问到外部函数的 thisarguments, 答案是不能的

thisarguments这两变量只有在函数调用时才会产生, 所以在匿名函数调用时, 才会产生 thisarguments, 回到源码我们发现 console.log(object.getIdentityFunc()()); // 'The Window'这里调用getIdentityFunc函数的对象是object, 但后面调用匿名函数的对象却是window全局变量, 所以匿名函数的 this 就是window对象

注意: window.identity 定义的对象将会是全局变量identity

当然如果想让外部函数的 this 给内部函数使用, 我们可以这么用:

window.identity = 'The Window';
let object = {
   identity: 'My Object',
   getIdentityFunc() {
      let that = this;
      return function () {
         return that.identity; // <============
      };
   }
};
console.log(object.getIdentityFunc()()); // 'My Object'

注意上面的 that 变量, let that = this, 然后传递给内部的匿名变量是可以的

thisarguments 都是不能直接在内部函数中访问的。如果想访问包含作用域中的 arguments 对象,则同样需要将其引用先保存到闭包能访问的另一个变量中

window.identity = 'The Window';
let object = {
   identity: 'My Object',
   getIdentity() {
      return this.identity;
   }
};
object.getIdentity(); // 'My Object'
(object.getIdentity)(); // 'My Object'
(object.getIdentity = object.getIdentity)(); // 'The Window'

闭包存在的问题(内存泄露)

由于 IE 在 IE9 之前对 JScript 对象和 COM 对象使用了不同的垃圾回收机制,所以闭包在这些旧版本 IE 中可能会导致问题。在这些版本的 IE 中,把 HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁

function assignHandler() {
   let element = document.getElementById('someElement');
   element.onclick = () => console.log(element.id);
}

在上面的那个闭包中, 如果获得的element变量将会被闭包加入到作用域链中, 此时只要我们的匿名函数没有被销毁, element变量都会在, 而且 element对象可能很大

解决方法:

function assignHandler() {
   let element = document.getElementById('someElement');
   const id = element.id;
   element.onclick = () => console.log(id);
   element = null;
}

立即调用的函数表达式(IIFE Immediately Invoked Function Expression)

说白了就是写个匿名函数后面加上()直接运行起来了

(function() {
// 块级作用域
})();

位于函数体作用域的变量就像是在块级作用域中一样, 不会泄露到外部作用域中

(function () {
   for (var i = 0; i < count; i++) {
      console.log(i);
   }
})();
console.log(i); // 抛出错误

在 ECMAScript 5.1 及以前,为了防止变量定义外泄,IIFE 是个非常有效的方式

IIFE在ES6之后就变得不那么重要了

// 内嵌块级作用域
{
   let i;
   for (i = 0; i < count; i++) {
      console.log(i);
   }
}
console.log(i); // 抛出错误
// 循环的块级作用域
for (let i = 0; i < count; i++) {
   console.log(i);
}
console.log(i); // 抛出错误

再举个例子:

let divs = document.querySelectorAll('div');
// 达不到目的!
for (var i = 0; i < divs.length; ++i) {
   divs[i].addEventListener('click', function() {
      console.log(i);
   });
}

因为 i 不会被限制在 for 循环中, 在渲染到页面上之后显示的是 最终的 i 值, 也就是点击每个<div>都会弹出元素总数, 这是因为在执行单击处理程序时,迭代变量的值是循环结束时的最终值,即元素的个数, 而且,这个变量 i 存在于循环体外部,随时可以访问。

这时候就需要 IIFE 来实现了

let divs = document.querySelectorAll('div');
for (var i = 0; i < divs.length; i++) {
   divs[i].addEventListener("click", (function (index) {
      return function () {
         console.log(index);
      };
   })(i));
}

但是到了ES6, 就不需要这样了

let divs = document.querySelectorAll('div');
for (let i = 0; i < divs.length; i++) {
   divs[i].addEventListener("click", (function () {
      console.log(i);
   }));
}

但要注意,如果把变量声明拿到 for 循环外部,那就不行了。下面这种写法会碰到跟在循环中使用 var i = 0 同样的问题

let divs = document.querySelectorAll('div');
// 达不到目的!
let i;
for (i = 0; i < divs.length; ++i) {
   divs[i].addEventListener('click', function() {
      console.log(i);
   });
}

如果我们不知道es5怎么使用 IIFE 来规避 var i 的问题, 其实很简单, 在typescript下使用 let:

let divs = document.querySelectorAll('div');
for (let i = 0; i < divs.length; ++i) {
   divs[i].addEventListener('click', function() {
      console.log(i);
   });
}

然后在编译成JavaScript, tsc xxxx.ts -t es5, 指定为 es5 编译就行

var divs = document.querySelectorAll('div');
var _loop_1 = function (i) {
    divs[i].addEventListener('click', function () {
        console.log(i); // 0 1 2 3 4
    });
};
for (var i = 0; i < divs.length; ++i) {
    _loop_1(i);
}

然后你就会发现, 它用了另一种方式, 和我们用的 IIFE 一个效果

这是一种偷懒的方法, 很多情况下, 我们都可以编写typescript, 然后将其编译成自己想要的JavaScript版本, 看来学习 typescript 得提上日程了

私有变量

JavaScript没有私有成员却可以有私有变量

function add(num1, num2) {
   let sum = num1 + num2;
   return sum;
}

上面的 num1 num2sum 都是外部不可访问的, 对于 add 函数来说这三个变量都是私有变量

私有变量包括函数参数、局部变量,以及函数内部定义的其他函数

但是则三个私有变量目前只能在 add 函数中被使用上, 不能在外部使用, 此时我们可以考虑使用闭包, 把 add 作用域作为该闭包的作用域链成员之一, 我们把这种拥有访问函数私有变量(私有函数)能力的公共方法叫做特权方法

function MyObject() {
   // 私有变量和私有函数
   let privateVariable = 10;
   function privateFunction() {
      return false;
   }
   // 特权方法
   this.publicMethod = function() {
      privateVariable++;
      return privateFunction();
   };
}
let myObject = new MyObject();
console.log(myObject.publicMethod())

这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。

说白了就是构建一个构造函数, 构造函数中局部变量或者函数, 然后向外部暴露一个公共方法, 然后外部可以借助这个公共方法访问和操作局部变量

但是构造函数模式有个缺点, 就是每创建一个对象都会有新的方法生成, 这是弊端, 不过我们可以用静态私有变量实现特权方法来避免该问题

静态私有变量

特权方法也可以通过使用私有作用域定义私有变量和函数来实现

前面我们知道protypeof原型对象, 在原型对象上的方法都算静态函数, 所有对象共享那些函数

然后我们知道要让一个变量变成私有变量的方法是将其变成局部变量或者参数, 这样外部上下文就不能够访问到我们的变量, 这样我们的变量就成为了私有变量, 但是私有变量是会消失的, 只要函数执行完毕, 局部变量就会被销毁

那我们只要有个对象(可以是变量, 也可以是闭包), 能够穿透这个上下文, 让该对象能够继承该上下文, 将其放入作用域链中就行了

先不管这么多, 试着实现看看

let Person
function func() {
   // 私有变量
   let name = "";
   // 构造函数
   Person = function (value) {
      name = value
   }
   // 添加特权函数: 拥有访问私有变量的函数
   Person.prototype.getName = function () {
      return name;
   }
   // 添加特权函数: 拥有访问私有变量的函数
   Person.prototype.setName = function (value) {
      name = value;
   }
}
// 把配置函数执行下, 生成 Person 构造函数
func()

let person1 = new Person();
console.log(person1.getName()) // undefined
person1.setName(10)
console.log(person1.getName()) // 10
console.log(person1.name) // undefined

看起来好像不错, 但是很多地方可以精简 比如: func()

let Person
(function func() {
   // 私有变量
   let name = "";
   // 构造函数
   Person = function (value) {
      name = value
   }
   // 添加特权函数: 拥有访问私有变量的函数
   Person.prototype.getName = function () {
      return name;
   }
   // 添加特权函数: 拥有访问私有变量的函数
   Person.prototype.setName = function (value) {
      name = value;
   }
})()

这里也可以精简let Person1, 直接删除掉就行了, JavaScript会把它提升到全局变量的

(function func() {
   // 私有变量
   let name = "";
   // 构造函数
   Person = function (value) {
      name = value
   }
   // 添加特权函数: 拥有访问私有变量的函数
   Person.prototype.getName = function () {
      return name;
   }
   // 添加特权函数: 拥有访问私有变量的函数
   Person.prototype.setName = function (value) {
      name = value;
   }
})()

完整代码:

(function () {
   let name = "";
   Person = function (value) {
      name = value
   }
   Person.prototype.getName = function () {
      return name;
   }
   Person.prototype.setName = function (value) {
      name = value;
   }
})();

let person = new Person();
console.log(person.getName()); // undefined
person.setName("haha");
console.log(person.getName()); // haha
console.log(person.name); // undefined

使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。

现在我们使用 typescript 试试看, 它用什么方式

// ts 代码
class Person {
   private _name: String;
   
   constructor(name) {
      this._name = name
   }
   
   get name(): String {
      return this._name;
   }
   
   set name(value: String) {
      this._name = value;
   }
}

let person = new Person("x");
console.log(person.name)
person.name = "xxx";
console.log(person.name)

编译之后:

var Person = /** @class */ (function () {
    function Person(name) {
        this._name = name;
    }
    Object.defineProperty(Person.prototype, "name", {
        get: function () {
            return this._name;
        },
        set: function (value) {
            this._name = value;
        },
        enumerable: false,
        configurable: true
    });
    return Person;
}());
var person = new Person("x");
// 我们手动加上下面这段代码
console.log(person._name); // x
console.log(person.name); // x
person.name = "xxx";
console.log(person.name); // xxx

看上面的代码, 我们手动加入了新的代码, 还是会被打印出来

说明 typescript 编译器根本就没有解决私有变量不能访问的问题

毕竟如果我们在 ts 文件下, 根本无法person._name, 这样会红字, 报错

所以还是我们自己写的代码那种模式比较靠谱

在后面版本的 ES 中, 更新了私有变量的方式 比如: #name 这就是私有的

class Person {
   #_name;
   constructor(name) {
      this.#_name = name;
   }
   getName() {
      return this.#_name;
   }
   setName(value) {
      this.#_name = value;
   }
}
let person = new Person("x");
console.log(person.getName())
person.setName("xx");
console.log(person.getName());
// console.log(person.#_name) // Private field '#_name' must be declared in an enclosing class

不过不保证所有浏览器都支持该语法

模块模式

创建特权方法的方式还有模块模式, 对单例对象的隔离和封装, 通过作用域链来关联私有变量和特权方法

let singleton = function () {
   let privateVariable = 10;
   return {
      publicProperty: "public",
      getPrivateVariable() {
         return this.privateVariable
      },
      setPrivateVariable(value) {
         this.privateVariable = value
      }
   };
}
let s = singleton();
console.log(s.getPrivateVariable())
s.setPrivateVariable(100);
console.log(s.getPrivateVariable());
console.log(s.publicProperty);
let application = function() {
    let components = new Array();
    components.push(new BaseComponent());
    return {
        getComponentCount() {
            return this.components.length;
        }
    },
    registerComponent(component) {
        if (typeof component == 'object') {
            components.push(component);
        }
    }
}

模块增强模式

let application = function () {
// 私有变量和私有函数
   let components = new Array();
// 初始化
   components.push(new BaseComponent());
// 创建局部变量保存实例
   let app = new BaseComponent();
// 公共接口
   app.getComponentCount = function () {
      return components.length;
   };
   app.registerComponent = function (component) {
      if (typeof component == "object") {
         components.push(component);
      }
   };
// 返回实例
   return app;
}();