《Effective JS》的 68 条准则「十八至二十九条」- 使用函数

523 阅读18分钟

起因

阅读学习《Effective JavaScript》,以自身阅读和理解,着重记录内容精华部分以及对内容进行排版,便于日后自身回顾学习以及大家交流学习。

因内容居多,分为每个章节来进行编写文章,每章节的准条多少不一,故每篇学习笔记的文章以章节为准。

适合碎片化阅读,精简阅读的小友们。争取让小友们看完系列 === 看整本书的 85+%。

前言

内容总览

  • 第一章让初学者快速熟悉 JavaScript,了解 JavaScript 中的原始类型、隐式强制转换、编码类型等几本概念;
  • 第二章着重讲解了有关 JavaScript 的变量作用域的建议,不仅介绍了怎么做,还介绍了操作背后的原因,帮助读者加深理解;
  • 第三章和第四章的主题涵盖函数、对象及原型三大方面,这可是 JavaScript 区别于其他语言的核心;
  • 第五章阐述了数组和字典这两种容易混淆的常用类型及具体使用时的建议,避免陷入一些陷阱;
  • 第六章讲述了库和 API 设计;
  • 第七章讲述了并行编程,这是晋升为 JavaScript 专家的必经之路

第 3 章「使用函数」

函数在 JavaScript 中既给程序员提供了主要的抽象功能,又提供了实现机制。函数也可以独自模拟实现出其他语言中的多个不同的特性,如过程、方法、构造函数、类和模块。因此在不同的环境中高效地使用函数是在所难免的。

第 18 条:理解函数调用、方法调用及构造函数调用之间的不同

在 JavaScript 中,它们只是单个构造对象的三种不同的使用模式。

函数调用

function hello(username) {
  return "hello" + username;
}
hello("Ling Mu");	// "hello Ling Mu"

简单调用 hello 函数并将给定的实参绑定到 name 形参

方法调用

const obj = {
  username: "Ling Mu",
  hello: function() {
    return "hello" + this.username;
  }
};
obj.hello();	// "hello Ling Mu"

表达式 obj.hello() 在 obj 对象中查找名为 hello 的属性恰巧是 obj.hello 函数,也就是方法调用。

this 变量的绑定是由表达式自身来确定,绑定到 this 变量的对象称为调用接收者。如下例子

const obj2 = {
  username: "Ling Mu2",
  hello: obj.hello
};
obj2.hello();	// "hello Ling Mu2"

表达式 obj2.hello() 在 obj2 对象中查找名为 hello 的属性,恰巧正是 obj.hello 函数,但是接收者是 obj2 对象。通常,通过某个对象调用方法将查找该方法并将该对象作为该方法的接收者。

若是非方法函数调用则会将全局对象作为接收者:

function hello() {
  return "hello" + this.username;
}
hello();	// "hello undefined"

全局对象中没有 name 的属性,则产生 undefined。而在全局作用域下将 this 变量绑定到全局对象也是有问题的,所以 ES5 严格模式将 this 变量的默认绑定值设置为 undefined

function hello() {
  "use strict";
  return "hello" + this.username;
}
hello();	// error: cannnot read property "usename" of undefined

构造函数调用

函数通过构造函数使用,就像方法和纯函数一样,也是 function 运算符定义。

function User(name, password) {
  this.name = name;
  this.password = password;
}
const u = new User("Ling Mu", "123456");
u.name; 	// "Ling Mu"

与函数调用和方法调用不同的是,构造函数调用将一个全新的对象作为 this 变量的值,并隐式返回这个新对象作为调用结果。构造函数的主要职责是初始化该新对象。

总结

  • 方法调用将被查找方法属性的对象作为调用接收者。
  • 函数调用将全局对象(处于严格模式下则为 undefined)作为其接收者,一般很少使用函数调用语法来调用方法。
  • 构造函数需要通过 new 运算符调用,并产生一个新的对象作为其接收者。

第 19 条:熟练掌握高阶函数

高阶函数

开发简洁优雅的函数通常可以使代码更加简单明了,高阶函数则是将函数作为参数或返回值的函数,是一种强大且富有表现力的惯用法,也在 JavaScript程序中被大量使用。

如简单的转换字符串数组的操作,使用循环:

const names = ["Lin", "Mu", "Qiqiu"];
const upper = [];
for (let i = 0, n = names.length; i < n; i++) {
  upper[i] = names[i].toUpperCase();
}
upper;	// ["LIN", "MU", "QIQIU"];

高阶函数与之实现为:

const names = ["Lin", "Mu", "Qiqiu"];
const upper = names.map(() => name.toUpperCase(););
upper;	// ["LIN", "MU", "QIQIU"];

使用数组遍历的 map 方法,可以完全消除循环,仅仅使用一个局部函数就可以实现对元素的逐个转换。

例子

需要引入高阶函数抽象的信号是出现重复或相似的代码。

例如,假设我们发现程序的部分代码段使用英文字母构造一个字符串。

const aIndex = 'a'.charCodeAt(0);		// 97
let alphabet = '';
for (let i = 0; i < 26; i++) {
  alphabet += String.fromCharCode(aIndex + i);
}
alphabet;	// "abcdefghijklmnopqrstuvwxyz"

同时,程序的另一部分代码段生成一个包含数字的字符串。

let digits = '';
for (let i = 0; i < 10; i++) {
	digits += i;
}
digits;		// "0123456789"

此外,程序的其他地方还存在创建一个随机字符串。

const aIndex = 'a'.charCodeAt(0);		// 97
let random = '';
for (let i = 0; i < 8; i++) {
	random += String.fromCharCode(Math.floor(Math.random() * 26) + aIndex);
}
random;		// 随机字符串

每个例子创建了一个不同的字符串,但它们都有着共同的逻辑。每个循环通过链接每个独立部分的计算结果来创建一个字符串。我们可以提取出公用的部分,移到单个工具函数里。

function buildString(n, callback) {
  let result = '';
  for (let i = 0; i < n; i++) {
		result += callback(i);
	}
  return result;
}

buildString 函数实现包含了每个循环的所有共用部分,并使用参数来替代变化部分。如循环迭代次数由变量 n 替代,每个字符串片段的构造由 callback 函数替代。因此我们可以使用如下高阶函数:

const alphabet = buildString(26, (i) => String.fromCharCode(aIndex + i););
alphabet;	// "abcdefghijklmnopqrstuvwxyz"

const digits = buildString(10, (i) => i);
digits;		// "0123456789"

而常见高阶函数抽象的实现中存在一些棘手的部分,如正确地获取循环边界条件,它们可以被正确地放置在高阶函数的实现中。

记得给高阶函数抽象一个清晰的名称,这样能使读者更清晰地了解该代码能做什么,而无须深入实现细节。

当发现自己在重复地写一些相同的模式时,学会借助于一个高阶函数可以使代码更简洁、更高效、更可读。留意一些常见的模式并将它们移到高阶的工具函数中是一个重要的开发习惯。

总结

  • 高阶函数时那些将函数作为参数或返回值的函数。
  • 熟悉掌握现有库中的高阶函数。
  • 学会发现可以被高阶函数所取代的常用的编码模式。

第 20 条:使用 call 方法自定义接收者来调用方法

接收者

通常情况下,函数或方法的接收者(即绑定到特殊关键字 this 的值)是由调用者的语法决定的。如方法调用语法将方法被查找的对象绑定到 this 变量,有时需要使用自定义接收者来调用函数。

因为该函数可能并不是期望的接收者对象的属性,我们可以将方法作为一个新的属性添加到接收者对象中。

obj.temp = f;
const res = obj.temp(arg1, arg2);
delete obj.temp;

修改 obj 对象往往是不可取的,甚至有时不可能修改。当使用自定义属性时,都可能与 obj 中已由的属性重名。

此外,某些对象可能被冻结或密封以防止添加任何属性。因此对于不是自己常见的对象,随意给对象添加属性是一种不好的实践。

call 方法

函数对象具有一个内置的方法 call 来自定义接收者。可能通过函数对象的 call 方法来调用其自身。

f(arg1, arg2);
// 类似等价于
f.call(obj, arg1, arg2)

call 方法第一个参数提供了一个显式的接收者对象。当调用的方法已经被删除、修改或者覆盖时,call 方法也可以派上用场了。

hasOwnProperty 方法可被任意的对象调用,甚至该对象是一个字典对象。在字典对象中,查找 hasOwnProperty 属性会得到该字典对象的属性值,而不是继承过来的方法。

dict.hasOwnProperty = 1;
dict.hasOwnProperty("foo");		// error: 1 is not a function

使用 hasOwnProperty 方法的 call 方法使调用字典对象中的方法成为可能。即便我们手动删除了该对象。

const hasOwnProperty = {}.hasOwnProperty;
const dict.foo = 1;
delete dict.hasOwnProperty;
// 调用 dict 中的属性
hasOwnProperty.call(dict, "foo");		// true
// 无该属性
hasOwnProperty.call(dict, "hasOwnProperty");		// false

高阶函数

高阶函数的一个惯用法是接收一个可选的参数作为调用该函数的接收者。例如,表示键值对列表的对象可能提供名为 forEach 的方法。

const table = {
  entries: [],
  addEntry: function(key, value) {
    this.entries.push({ key, value });
  },
  forEach: function(f, thisArg) {
    const entries = this.entries;
    for (let i = 0, n = entries.length; i < n; i++) {
      const entry = entries[i];
      f.call(thisArg, entry.key, entry.value, i);
    }
  }
};

上述例子允许 table 对象的使用者将一个方法作为 table.forEach 的回调函数 f,并未该方法提供一个合理的接收者。例如,可以方便地将一个 table 的内容复制到另一个中。

table1.forEach(table2.addEntry, table2);

这段代码从 table2 中提取 addEntry 方法(甚至可以从 Table.prototype 或者 table1 中提取),forEach 方法将 table2 作为接收者,并反复调用该 addEntry 方法。

虽然 addEntry 方法只期望两个参数,但 forEach 方法调用它时却传递给它按个参数。多余的索引参数将被 addEntry 方法简单地忽略掉。

总结

  • 使用 call 方法自定义接收者来调用函数。
  • 使用 call 方法可以调用在给定的对象中不存在的方法。
  • 使用 call 方法定义高阶函数允许使用者给回调函数指定接收者。

第 21 条:使用 apply 方法通过不同数量的参数调用函数

apply 方法

在调用 call 方法中,当回调函数可以接收任意数量的参数,被称为可变参数或可变元的函数,但需让调用者预先明确地知道提供了多少个参数。

倘若我们传递的是一个数组参数,那么将使用函数对象内置的 apply 方法,它与 call 方法非常类似,并且是为了固定元数传递数组参数而设计的。

apply 方法指定第一个参数绑定到被调用函数的 this 变量,若调用函数中没有引用 this 变量,我们可以简单地传递 null 值。

const scores = [1, 2, 3];
average.apply(null, scores);

// 以上代码的行为等效于
average.apply(scores[0], scores[1], scores[2]);

可变参数方法

apply 方法也可用于可变参数方法。例如 buffer 对象包含一个可变参数的 append 方法,该方法添加元素到函数内部的 state 数组中。

const buffer = {
  state: [],
  append: function() {
    for (let i = 0, n = arguments.length; i < n; i++) {
      this.state.push(arguments[i]);
    }
  }
}

// 可变参数调用
buffer.append("hello, ");
buffer.append(firstName, "", lastName, "!");

借助于 apply 方法的 this 参数,我们也可以指定一个可计算的数组调用 append 方法:

buffer.append.apply(buffer, getInputStrings());

apply 第一个参数,如果我们传递一个不同的对象,那么 append 方法将尝试修改该错误独享的 state 属性。

总结

  • 使用 apply 方法指定一个可计算的参数数组来调用可变参数的函数。
  • 使用 apply 方法的第一个参数给可变参数的方法提供一个接收者。
  • 利用函数内置的 arguments 局部变量来调用 apply 方法用于可变参数方法中。

第 22 条:使用 arguments 创建可变参数的函数

arguments

可变参数没有定义任何显式的形参。它利用了 JavaScript 给每个函数都隐式地提供了一个名为 arguments 的局部变量对象,给实参提供了一个类似数组的接口。它为每个实参提供了一个索引属性,还包含一个 length 属性用来指示参数的个数。

function average() {
  for (let i = 0, sum = 0, n = arguments.length; i < n; i++) {
    sum += arguments[i];
  }
  return sum / n;
}

便利固定元数方法

可变参数函数提供了灵活的接口,不同的调用者可使用不同数量的参数来调用它们。如果使用者想使用计算的数组参数来调用可变参数的函数,只能使用在第 21 条中描述的 apply 方法。

而我们可以提供一个便利的可变参数的函数,也最好提供一个需要显示指定数组的固定元数的版本。

function average() {
  return averageOfArray(arguments);
}

编写一个轻量级的封装,并委托给固定元数的版本实现可变参数的函数。这样一来,函数的使用者就无需借助 apply 方法。

apply 方法会降低可读性而且经常导致性能损失。

总结

  • 使用隐式的 arguments 对象实现可变参数的函数。
  • 考虑对可变参数的函数提供一个额外的固定元数的版本,从而使使用者无需借助 apply 方法,避免其缺陷。

第 23 条:永远不要修改 arguments 对象

arguments 别名

arguments 是一个类数组而非数组。例如,当我们想在 arguments 对象上调用 shift() 方法来删除数组的第一个元素并逐个移动所有后续的元素。因为 arguments 对象上没有 shift() 方法,我们不能直接调用。

可以通过 apply 尝试从数组中提取出 shift 方法,来调用 arguments 对象。

function callMethod(obj, method) {
	const shift = [].shift;
  shift.call(arguments);
  shift.call(arguments);
  return obj[method].apply(obj, arguments);
}

const obj = {
  add: function(x, y) { return x + y; }
};
callMethod(obj, "add", 17, 25);		// error: cannot read property "apply" of undefined

上述方法出错的原因是 arguments 对象并不是函数参数的副本,所有命名参数都是 arguments 对象中对应索引的别名。因此,即使通过 shift 方法移除 arguments 对象中的元素之后,obj 仍然是 arguments[0] 的别名,method 仍然是 argument[1] 的别名,均已被 shift() 删除掉。

因此我们提取的 obj["add"],实际上是在提取 17[25]。由于 JavaScript 的自动强制转换规则,引擎将 17 转换为 Number 对象并提取 25 属性,产生 undefined。最后试图提取 undefined 的 apply 属性调用,则报错。

严格模式

而在严格模式下,函数参数不支持对其 arguments 对象取别名。下列例子通过编写一个更新 arguments 对象某个元素的函数来说明这个差异。

function strict(x) {
  "use strict";
  arguments[0] = "modified";
  console.log(arguments[0], x);
  return x === arguments[0];
}

function nonstrict(x) {
  arguments[0] = "modified";
  console.log(arguments[0], x);
  return x === arguments[0];
}

strict("unmodified");		// false
nonstrict("unmodified");	// true

strict 中,严格模式下,arguments 不是函数参数别名,因此 modified === unmodified 为 false。nonstrict 中,通过别名改变了参数,modified === modified 为 true。

复制 arguments 对象

因此,永远不要直接修改 arguments 对象。可以通过一开始复制参数重的元素到一个真正数组的方法,来避免这个意外。如浅拷贝:const args = [...arguments];

因此通过 slice 方法来获取 callMethod 的第三、四个参数,进而调用我们第一个例子方法:

function callMethod(obj, method) {
	const shift = [].shift;
  const arg = [...arguments].slice(2);
  return obj[method].apply(obj, arg);
}

const obj = {
  add: function(x, y) { return x + y; }
};
callMethod(obj, "add", 17, 25);		// 42

总结

  • 永远不要修改 arguments 对象。
  • 将 arguments 对象复制到一个真正的数组中再进行函数参数的操作。

第 24 条:使用变量保存 arguments 的引用

迭代器

迭代器是一个可以顺序存取数据集合的一些,其中 next 方法可以获取序列中的下一个值。

const it = values(1, 4, 1, 4, 2, 1, 3, 5, 6);
it.next();	// 1
it.next();	// 4
it.next();	// 1

我们希望写一个便利的函数,可以接收任意数量的参数,并为这些值建立一个迭代器。由于 values 函数必须能够接收任意数量的参数,所以我们可以构造迭代器对象来遍历 arguments 对象的元素。

function values() {
  let i = 0;
  const = arguments.length;
  return {
    hasNext: function() {
      return i < n;
    },
    next: function() {
      if (i >= n) {
        throw new Error("end of iteration");
      }
      return arguments[i++];		// wrong arguments
    };
  };
};

const it = values(1, 4, 1, 4, 2, 1, 3, 5, 6);
it.next();	// undefined
it.next();	// undefined
it.next();	// undefined

这段代码中,一个新的 arguments 变量被隐式地绑定到每个函数体内。迭代器的 next 方法含有自己的 arguments 变量,而非 values 函数的变量,故返回的 arguments[i++] 均为 undefined。

局部保存

我们只需简单地在我们感兴趣的 arguments 对象作用域内绑定一个新的局部变量,并确保嵌套函数只能引用这个显示命名的变量。

function values() {
  let i = 0;
  const n = arguments.length, arr = arguments;
  return {
    hasNext: function() {
      return i < n;
    },
    next: function() {
      if (i >= n) {
        throw new Error("end of iteration");
      }
      return arr[i++];		// wrong arguments
    };
  };
};

const it = values(1, 4, 1, 4, 2, 1, 3, 5, 6);
it.next();	// 1
it.next();	// 4
it.next();	// 1

总结

  • 当引用 arguments 时,当心函数嵌套层级。
  • 绑定一个明确作用域的引用到 arguments 变量,从而可以在嵌套的函数中引用它。

第 25 条:使用 bind 方法提取具有确定接收者的方法

函数接收者

由于方法与对象值为函数的属性没有区别,因此很容易提取对象的方法并将提取出的函数作为回调函数直接传递给高阶函数。这样很容易忘记将提取出的函数的接收者绑定到该函数被提取出的对象上。

const buffer = {
  entries: [],
  add: function(s) {
    this.entries.push(s);
  }
}
const source = ["867", "-", "5309"];
source.forEach(buffer.add);		// error: entries is undefined

在 forEach 调用的 buffer.add 中的接收者并不是 buffer 对象。函数的接收者取决于它是如何被调用的,而我们将它传递给了 forEach 方法。forEach 方法的实现使用全局对象作为默认的接收者,全局对象没有 entries 属性,因此抛出 undefined 错误。

forEach 方法允许调用者提供一个可选的参数作为回调函数的接收者:

const buffer = {
  entries: [],
  add: function(s) {
    this.entries.push(s);
  }
}
const source = ["867", "-", "5309"];
source.forEach(buffer.add, buffer);
buffer.join();		// "867-5309"

显示调用

若是高阶函数没有为使用者提供其回调函数的接收者,另一个好的解决方法是创建使用适当的方法调用语法来调用 buffer.add 的一个局部函数。

const source = ["867", "-", "5309"];
source.forEach(function(s) {
  buffer.add(s);
})
buffer.join();		// "867-5309"

这里创建一个显式地以 buffer 对象方法的方式调用 add 的封装函数。不管封装函数如何被调用,它总能确保其参数推送到接收者中。

bind

创建一个函数用来实现绑定其接收者到一个指定对象是非常常见的,因此 ES5 标准库采用函数对象的 bind 方法。该方法需要接受一个接收者对象,并产生一个以该接收者对象方法调用的方式调用原来的函数的封装函数。

const source = ["867", "-", "5309"];
source.forEach(buffer.add.bind(buffer));
buffer.join();		// "867-5309"

**buffer.add.bind(buffer) 创建了一个新函数而不是修改了 buffer.add 函数。**新函数的行为就像原来函数的行为,但它的接收者绑定到了 buffer 对象,而原有函数的接收者保持不变。

buffer.add === buffer.add.bind(buffer); // false

因此这意味着调用 bind 方法是安全的,即使是一个可能在程序的其他部分被共享的函数,这对于原型对象上的共享方法尤其重要。当在任何的原型后代中调用共享方法时,该方法仍能正常工作。

总结

  • 要注意,提取一个方法不会将方法的接收者绑定到该方法的对象上。
  • 当给高阶函数传递对象方法时,使用匿名函数在适当的接收者上调用该方法。
  • 使用 bind 方法创建绑定到适当接收者的函数。

第 26 条:使用 bind 方法实现函数柯里化

函数柯里化

将函数与其参数的一个子集绑定的技术称为函数柯里化,以逻辑学家 Haskell Curry 的名字命名。比起显式地封装函数,函数柯里化是一种简洁的、使用更少引用来实现函数委托的方式。

而函数对象的 bind 方法除了具有将方法绑定到接收者的用途外,还可以自动构造匿名函数。如有一个装配 URL 字符串的简单函数:

function simpleURL(protocol, domain, path) {
  return protocol + "://" + domain + "/" + path;
}

程序可能需要将特定站点的路径字符串构建为绝对路径 URL,一种自然的方式是对数组使用 ES5 提供的 map 方法来实现:

const urls = paths.map(function(path) {
  return simpleURL("http", siteDomain, path);
})

上述列子中的匿名函数对 map 方法的每次迭代使用相同的协议字符串和网站域名字符串。传给 aimpleURL 函数的前两个参数对于每次迭代都是固定的,仅第三个参数是变化的。我们可以通过调用 simpleURL 函数的 bind 方法来自动构造该匿名函数

const urls = paths.map(simpleURL.bind(null, "http", siteDomain));

simpleURL.bind 的调用产生了一个委托到 simpleURL 的新函数,bind 方法的第一个参数提供了接收者的值。由于 simpleURL 不需要引用 this 变量,所以可以使用 null 来替代。

simpleURL.bind 的其余参数和提供给新函数的所有参数共同组成了传递给 simpleURL 的参数。使用单个参数 path 调用 simpleURL.bind,则该执行结果是一个委托到 simpleURL("http", siteDomain, path) 的函数

总结

  • 使用 bind 方法实现函数柯里化,即创建一个固定需求参数子集的委托函数。
  • 传入 null 或 undefined 作为接收者的参数来实现函数柯里化,从而忽略其接收者。

第 27 条:使用闭包而不是字符串来封装代码

字符串封装

函数是一种将代码作为数据结构存储的便利方式,这些代码可以随后被执行,也是 JavaScript 异步 I/O 方法的核心。与此同时,也可以将代码表示为字符串的形式传递给 eval 函数以达到同样的功能。

而我们应该将代码表示为函数而非字符串,其不够灵活的一个重要原因是:它们不是闭包。假设有一个简单的多次重复用户提供的动作的函数:

function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    eval(action);
  }
}

该函数在全局作用域会工作得很好,因为 eval 函数会将出现在字符串中的所有变量引用作为全局变量来解释。而若简单地将代码移到函数中来调用,那么其变量期望为局部变量,而非全局变量。

function benchmark() {
  const start = [], end = [], timings = [];
  repeat(1000, "start.push(Date.now()); f(); end.push(Date.now());");
  for (let i = 0, n = start.length; i < n; i++) {
    timings[i] = end[i] - start[i];
  }
  return timings;
}

该函数会导致 repeat 函数引用全局的 start 和 end 变量。将会发生意料不到的情况,全局变量未定义,抛出 ReferenceError 异常。代码恰好绑定到 start 和 end 全局对象的 push 方法,程序将变得无法预测。

闭包封装

因此我们应该接受闭包封装而非字符串。

function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    eval(action);
  }
}

function benchmark() {
  const start = [], end = [], timings = [];
  repeat(1000, function() {
   	start.push(Date.now()); 
    f(); 
    end.push(Date.now());
  });
  for (let i = 0, n = start.length; i < n; i++) {
    timings[i] = end[i] - start[i];
  }
  return timings;
}

相较于 eval 的另一个问题是,通常一些高性能的引擎很难优化字符串中的代码,因为编译器不能尽可能早地获得源代码来及时优化代码。而函数表达式在其代码出现的同时就能被编译,更适合标准化编译和优化。

总结

  • 当将字符串传递给 eval 函数以执行它们的 API 时,绝不要在字符串中包含局部变量引用。
  • 接受闭包优于使用 eval 函数执行字符串。

第 28 条:不要信赖函数对象的 toString 方法

JavaScript 函数可以将其源代码重现为字符串的能力,聪明的黑客偶然会通过巧妙的方法用到它:

(function(x){
  return x + 1;
}).toString();		// "function(x) {\n return x + 1;\n}"

然而 ECMAScript 标准对函数对象的 toString 方法的返回结果并没有任何要求,这意味这不同的 JavaScript 引擎将产生不同的字符串,甚至与该函数并不相关。

(function(x) {
  return x + 1;
}).bind(16).toString();		// "function(x) {\n	[native code]\n}"

该例子失败的原因是使用了由宿主环境的内置库提供的函数,而非纯 JavaScript 实现。在许多宿主环境中,bind 函数是由其他编译语言实现的,宿主环境提供的是一个编译后的函数,在此环境下啊函数没有 JavaScript 源代码供 toString 显示。

其次,由于标准允许浏览器引擎改变 toString 方法的输出,这很容易使编写的程序在一个 JavaScript 系统中正确运行,在其他 JavaScript 中却无法正确运行。

程序对函数的源代码字符串的具体细节也很敏感,即使 JavaScript 的实现有一点细微的变化(如空格格式化)都可能破坏代码。

(function(x) {
  return function (y) {
    return x + y;
  }
})(42).toString();	// "function(y) {\n return x + y;\n}"

而且由 toString 方法生产的源代码并不展示闭包中保存的与内部变量引用相关的值。尽管函数实际上是一个绑定 x 为 42 的闭包,但结果字符串仍然包含一个引用了 x 的变量。

因此 toString 方法的这些局限使其用来提取函数源代码并不是特别有用和值得信赖,应该避免使用它。对提取函数源代码相当复杂的使用应当采用精心制作的 JavaScript 解释器和处理库。

最后,将 JavaScript 函数看作一个不该违背抽象是最稳妥的。

总结

  • 当调用函数的 toString 方法时,并没有要求 JavaScript 引擎能够精确地获取到函数的源代码。
  • 在不同的引擎下调用 toString 方法的结果可能不同。
  • toString 方法的执行结果并不会暴露存储在闭包中的局部变量值。
  • 避免使用函数对象的 toString 方法。

第 29 条:避免使用非标准的栈检查属性

栈检查属性

调用栈是指当前正在执行的活动函数链,曾经许多 JavaScript 环境都提供检查调用栈的功能。在某些旧的宿主环境中,每个 arguments 对象都含有两个额外的属性:arguments.calleearguments.caller。前者指向使用该 arguments 对象被调用的函数,后者指向调用该 arguments 对象的函数。

arguments.callee 除了允许匿名函数递归地引用其自身之外,就没有更多的用途了。

const factorial = (function(n) {
  return (n <= 1) ? 1 : (n * arguments.callee(n-1));
}) 
// 等价于
function factorial(n) {
  return (n <= 1) ? 1 : (n * factorial(n - 1));
}

arguments.caller 属性指向的是使用该 arguments 对象调用函数的函数,处于安全考虑,大多数环境已经移除了此特性。许多 JavaScript 环境也提供了一个相似的函数对象属性 caller 属性,指向函数最近调用者。

function revealCaller() {
  return revealCaller.caller;
}
function start() {
  return revealCaller();
}
start() === start;	// true

栈跟踪

栈跟踪是一个题哦那个当前调用栈快照的数据结构,使用该属性来获取栈跟踪是很有诱惑力。

function getCallStack() {
  const stack = [];
  for (let f = getCallStack.caller; f; f = f.caller) {
    stack.push(f);
  }
  return stack;
}

对于简单的调用栈,getCallStack 函数可以很好地工作。

function f1() {
  return getCallStack();
}
function f2() {
  return f1();
}
const trace = f2();
trace;		// [f1, f2]

如果某个函数在调用栈中出现了不止一次,那么栈检查逻辑将会陷入循环。

function f(n) {
  return n === 0 ? getCallStack() : f(n - 1);
}
const trace = f(1);	// infinite loop

由于函数 f 递归地调用其自身,因此其 caller 属性会自动更新,指回到函数 f。所以函数 getCallStack 会陷入无限地查找函数 f 的循环之中,因此该栈跟踪出错。

这些栈检查属性都是非标准的,在移植性或适用性上很受限制,并且在 ES5 的严格函数中,是被禁止使用的。

function f() {
  "use strict";
  return f.caller;
}
f();	// error: caller may not be accessed on strict functions

最好的策略是避免栈检查,如果为了调试而检查栈,那么更为可能的方式是使用交互式的调试器。

总结

  • 避免使用非标准的 arguments.caller 和 arguments.callee 栈检查属性,因为它们不具备良好的移植性。
  • 避免使用非标准的函数对象 caller 属性,在包含全部栈信息方面,它是不可能靠的。
  • 在严格模式下禁用栈检查属性。

后言

以上为 第三章内容 学习了 18~29 条规则 着重于熟悉 JavaScript 函数的具体细节规则

链接