参数
arguments
在函数方法体内,有一特殊的变量arguments,用于接收所有传入的实参,即使没有相应的形参。
testParams(10, 20);
function testParams(param1) {
console.log(arguments.length); // 2
console.log(arguments[0]); // 10
console.log(arguments[1]); // 20
}
在非严格环境下,arguments有一名为callee的属性,表示arguments所属的当前执行函数。
testParams();
function testParams() {
console.dir(arguments.callee); // function testParams
}
但该属性已被废弃,因此尽量不要使用。
判断空值
当向函数参数传递时,可能会出现几种情况。
第一种情况:有形参,但未传入实参。
testParams();
function testParams(param) {
console.log(param); // undefined
}
此时在函数体的形参为undefined
。
第二种情况:有形参,传入null
值。
testParams(25, null, 30);
function testParams(param1, param2, param3) {
console.log(param2); // null
}
共有2个形参,但向第2个形参显式地传入null
值,表示没有该值。
此时在函数体的形参为null
。
注意:当实参后面有需要传入的实参时,该实参不能省略,即不能为testParams(25, , 30)
的形式,而应在该位置显式地传入null
值。
值为null
或undefined
的形参均可用!
语句来判断:
testParams(25, null, 30);
function testParams(param1, param2, param3, param4) {
console.log(!param2); // true
console.log(!param4); // true
}
对于数组来讲,有下列3种情况:
testParams(5);
testParams(5, null, 8);
testParams(5, []);
function testParams(a, arr, b) {
if (arr === null) {
console.log('null');
} else if (arr === undefined) {
console.log('undefined');
} else if (arr.length === 0) {
console.log('length of 0');
}
}
结果分别为:
undefined
null
[] (0)
但对于空数组,不能使用!
语句来判断其是否为空:
testParams([]);
function testParams(param1) {
console.log(!param1); // false
}
而应使用:
testParams([]);
function testParams(arr) {
if (arr.length === 0) {
console.log('Empty array.');
}
}
如果函数中类型为数组的参数有默认值时:
testParams(5);
testParams(5, null, 8);
testParams(5, []);
function testParams(a, arr = [1, 2, 3], b) {
console.log(arr);
}
结果为:
[1, 2, 3]
null
[]
即,只有在值为undefined
时,默认值才会生效。而如果传入null
值,则默认值不会生效。
因此,对于类型为数组的形参,最好不要设置默认值。相反,应在函数体中进行判断:
testParams(5);
testParams(5, null, 8);
testParams(5, []);
function testParams(a, arr, b) {
if (arr === null || arr === undefined || arr.length === 0) {
arr = [1, 2, 3];
}
console.log(arr);
}
上面的代码,则将3种情况下的数组都进行了统一的初始化。
导致出现上面各种复杂情况的原因在于,我们希望尽可能简化客户端的代码,即,应允许客户端灵活地进行调用:
testParams(5);
testParams(5, null, 8);
testParams(5, []);
而为了应付这些灵活的调用情况,就不得不在函数体中进行全面的判断。
可变长参数
JavaScript的函数支持可变长参数,这种函数称为参数可变长函数(variadic function)。
let a = 5;
let b = 10;
let c = 25;
let d = sum(a, b, c);
console.log(d); // 40
function sum(...nums) {
let total = 0;
for (let value of nums) {
total += value;
}
return total;
}
形参...nums将所有可变长的实参a, b, c
都打包为一个数组。
在JavaScript中,形参...nums被称为rest parameter(剩余参数)。
rest parameter的含义是指,排在其前面的参数可明确指出,剩余的部分可自动收纳进来。
let a = 5;
let b = 10;
let c = 15;
let d = 20;
listArgs(a, b, c, d);
function listArgs(a, b, ...nums) {
console.log(a); // 5
console.log(b); // 10
console.log(nums); // [15, 20]
}
形参前面2个参数是固定的,而最后的...nums则将剩余的实参都打包在一起(不包含前面两个固定的参数)。
注意,当与有固定的参数混用时,rest parameter必须放在最后面。
下面是一个容易犯晕的例子。
function aFunc(...args) {
pc.log(Array.isArray(args));
pc.log(args);
console.log(...args);
}
aFunc(1, 2, 3);
在函数原型声明中,形参...args是可变长参数,而在该函数体中,args是一个数组。如果我们希望以args作为参数调用console.log方法,由于该方法的参数也是可变长参数,因此我们不能直接调用:
console.log(args); // Wrong. args is an Array
我们须将此数组使用展开操作符...展开为多个独立的个体后,再调用:
console.log(...args);
自调用函数
基本形式
在声明函数时,可立即调用该函数。
(
function testFunc() {
console.log('hello');
}
)();
上面,先声明了一个名为testFunc的函数,然后,将该函数的定义部分全部用()
包围起来,最后,像调用其他函数一样,在该函数名后面再加上()
来调用它。
因为我们无需使用函数名称来调用它,因此也可以采用匿名函数的方式:
(
function() {
console.log('hello');
}
)();
更通用的写法如下所示:
(function () {
console.log('hello');
})();
或者,改用lambda方式:
(() => {
console.log('hello');
})();
自调用函数的变体
我们可以将一个自调用函数赋值给一个变量。
let aFunc = function() {
}();
当使用这种方式时,可以省略第一组()
。
初始化静态变量
我们可以利用自调用函数的特点,实现静态变量的初始化。看下面的的代码:
let genId = function() {
let id = 1;
return function() {
return id++;
};
}();
变量genId是一个自调用函数。在其函数体中,声明了一个名为id的变量,然后,返回一个函数,此函数返回id的值后,再将其值加1。
当完成声明后,类似于以下的代码:
let genId;
{
let id = 1;
genId = function () {
return id++;
};
}
上面,以{}
包围起来的部分称为闭包(closure)。闭包定义了一个上下文环境,闭包内的变量只对闭包内的对象可见,而闭包外的对象则不可以访问闭包内的变量。
无论是使用自调用函数,还以使用闭包,都可以实现上面的功能。但闭包是独立的、没有封装性的,如果使用太多的闭包,则维护代码时,我们很容易迷失。而使用自调用函数的格式,具备了很好的封装性。
将变量id进行封装后,genId所返回的函数并未重新初始化该变量,因此,变量id成了名副其实的静态变量。
调用代码:
let id1 = genId(); // 1
let id2 = genId(); // 2
自调用函数的参数
自调用函数也可具备形参,且实际传入实参。
(function (a, b) {
console.log(a + b); // 50
})(20, 30);
许多开源的JavaScript框架在初始化时都使用了类似于此的特性:
let MyFramework = function(global) {
if (global === window) { // web environment
// ...
} else {
// ...
}
}(globalThis);
立即执行异步函数
有时如果需要立即执行异步函数,可使用async
来实现。但其又需要在函数签名之前及调用相应的异步函数时使用await
关键字,较为啰唆。此时,使用自调用函数的方式,可大大简化编码。
let obj = await (async () =>
await fetch('MyObject.json')
.then(res => Promise.resolve(res.json()))
)();
console.log(obj);
上面代码,加载、解析、并赋值于变量obj后,才执行下一行语句,因此,下一行语句可放心地访问obj了。
call, apply and bind
每个Function都有以下方法:
call, apply及bind均是与在被调用的函数内如何设置神奇的this
有关的方法。
call
call的基本机制
先看下面的代码:
<script type="module">
function utils() {
console.log(this); // undefined
}
utils();
</script>
在函数utils内部,打印this
的值。
哪来的this?因此终端中输出undefined
不足为奇。但再加一行代码就可看出端倪:
function utils() {
console.log(this); // undefined
console.log(a); // ReferenceError Exception
}
utils();
如果this与a一样,则应都是ReferenceError异常的错误。可见,这两个变量情况还不一样。this这个变量是存在的,但只是尚未传进来而已。
在JavaScript中,每个函数,如同上面的utils,都有一个名为call的方法。当调用某个函数的call方法时,其第1个参数将成为该函数内部的this
。
function utils() {
console.log(this);
}
let person = {
name: 'Sarkuya',
age: 25
};
let car = {
year: 1950,
speed: 150
};
utils.call(person);
utils.call(car);
代码:
utils.call(person);
utils.call(car);
以person及car作为参数,调用函数utils的方法call,这样,在utils中,this将被赋值为此参数。
终端输出:
{name: "Sarkuya", age: 25}
{year: "1950", speed: 150}
注意这里的参数传递机制。
function utils() {
console.log(this);
}
utils(person);
utils(car);
函数utils没有任何形参,但却可以通过固定命名的方式,使用专门的、隐藏的变量this来接收不同的实参!
但并不是所有的函数调用都有这种能与神秘的this
取得联系的特权。只有函数的call及apply方法才有这种特权。这就是它们存在的意义所在。
函数utils中this的意义
函数utils中这个特殊的变量this可不是一个普通的变量。它是可以变魔术的,可立即变为所有任意传进来的对象自身。
这意味着,utils这个环境已经立即转变为this所指向的对象的自身的环境。或者说,它已经被神灵附体了,被所传进的对象这个神灵附体了。它变成了这个神灵自己。
先以person为参数调用call:
function utils() {
console.log(this.name); // Sarkuya
console.log(this.age); // 25
}
let person = {
name: 'Sarkuya',
age: 25
};
utils.call(person);
utils的环境变成了person自己所在的环境。
再以car为参数调用call:
function utils() {
console.log(this.year); // 1950
console.log(this.speed); // 150
}
let car = {
year: 1950,
speed: 150
};
utils.call(car);
utils的环境变成了car自己所在的环境。
即,如果我们调用任意一个函数的call方法,则这个函数内部将立即变为一块神灵圣地。
鉴于其特点,我们可在utils函数中设计一些通用的算法:
function utils() {
for (let propName in this) {
console.log(propName);
}
}
utils.call(person);
utils.call(car);
可变长的其他参数
call方法的参数中,从第2个参数开始为可变长的参数。可用这个可变长的参数来投喂utils函数所声明的形参。
function utils(a, b) {
console.log(a);
console.log(b);
for (let propName in this) {
console.log(propName);
}
}
utils.call(person, 5, 25);
utils.call(car, 2, 3);
call方法的原型如下:
- thisArg
- 在被调用函数的内部,
this
应指向的对象。
- ... args
- 被调用函数的其他参数。
apply
apply与call的作用一样,只不过是其他参数不是可变长的参数,而是打包进一个数组中。
function utils(a, b) {
console.log(a);
console.log(b);
for (let propName in this) {
console.log(propName);
}
}
let person = {
name: 'Sarkuya',
age: 25
};
let car = {
year: 1950,
speed: 150
};
utils.apply(person, [5, 25]);
utils.apply(car, [2, 3]);
apply方法的原型如下:
- thisArg
- 在被调用函数的内部,
this
应指向的对象。
- args[]
- 被调用函数的其他参数。
应该是,apply方法是JavaScript早期就出现的方法,而随着JavaScript的演化,后面支持了可变长的参数,就又多了一个call方法。这两个方法功能一样,但只是参数的组织形式不同。使用call更加方便。
bind
先看一个例子:
let obj = {
name: 'Sarkuya',
getName: function () {
return this.name;
}
};
console.log(obj.getName()); // Sarkuya
要点:obj的方法getName方法中通过this
来访问自己的属性name。
直接调用obj的getName方法,没有任何问题。
现在,声明一个指向obj的getName方法的引用,或称函数指针。然后再调用它。
let funcPointer = obj.getName;
console.log(funcPointer());
程序将抛出TypeError异常:
Uncaught TypeError: Cannot read properties of undefined (reading 'name')
at getName
意为当执行到getName方法中的this.name
时,this
的值为undefined,因而不能访问其name属性。
两种调用方法的区别在于运行环境不同。当JavaScript遇到obj.
时,其运行环境是obj,此时可访问this.getName
;而当JavaScript遇到funcPointer.
时,其运行环境是全局的window,此时this
为undefined。
此时,可调用funcPointer的bind方法,为该函数绑定一个在其内部若出现this
时,this
应指向的对象。
let funcPointer = obj.getName;
let boundPointer = funcPointer.bind(obj);
console.log(boundPointer()); // Sarkuya
上面,代码
let boundPointer = funcPointer.bind(obj);
表示,在boundPointer这个运行环境中,将funcPointer内部的this
绑定到obj上面。这样就可以直接调用boundPointer函数了。
何时使用bind方法?
- 当我们声明了一个变量,该变量指向一个函数
- 这个函数内部使用了
this
函数类
创建函数对象实例
看一个函数:
function sum(a, b) {
return a + b;
}
我们声明了一个名为sum的函数。这是我们所熟悉的方式。除此之外,我们还可以通过Function构造器来创建这个函数。
let sum = new Function('a', 'b', 'return a + b;');
JavaScript中,使用构造器来创建对象的前面的new
可以省略,因此也可写成:
let sum = Function('a', 'b', 'return a + b;');
console.log(sum(3, 5)); // 8
Function构造器的参数均为字符串,最后一个参数是函数体内的内容,其前面是可变长的函数参数。上面a
与b
为参数的名称。
使用字符串作为函数体的内容,意味着在我们调用该函数时,JavaScript将解析此字符串、将此字符串转化为代码并运行,这为我们避免直接调用eval()
提供了另一种较好的解决方案。
自动运行字符串
let src = `console.log("Hello");`;
let func = Function(src);
func(); // "Hello"
上面代码因为Function构造器只有一个字符串参数,它将成为函数体的内容。运行上面的代码,成功地运行了src的内容。
其效果等于:
let src = `console.log("Hello");`;
eval(src);
简化它:
let src = `console.log("Hello");`;
Function(src)();
访问变量
可以在函数体内访问变量。
访问全局变量
因为src的内容是个字符串,我们可以使用字符串模板来访问变量。
<script type="module">
let num1 = 2;
let num2 = 3;
let src = `console.log(${num1} + ${num2});`;
Function(src)(); // 5
</script>
num1及num2是全局变量,访问没问题。
访问局部变量
<script type="module">
func();
function func() {
let num1 = 2;
let num2 = 3;
let src = `console.log(${num1} + ${num2});`;
Function(src)(); // 5
}
</script>
在函数中访问全局变量
<script type="module">
let num1 = 2;
let num2 = 3;
func();
function func() {
let src = `console.log(${num1} + ${num2});`;
Function(src)(); // 5
}
</script>
在函数中照样可以访问全局导入的模块。
<script type="module">
import {GLColors} from '/js/esm/GLColors.js';
func();
function func() {
let src = `console.log(${GLColors.FromShortHex('#333')});`;
Function(src)(); // [0.2, 0.2, 0.2, 1.0]
}
</script>
访问对象
由于安全机制的原因,当访问对象时,将有点问题。
function func() {
let obj = {color: 'green'};
let src = `console.log(${obj});`;
Function(src)();
}
将出现语法异常:
SyntaxError: Unexpected identifier 'Object'. Expected either a closing ']' or a ',' following an array element.
将${}
去掉也不行:
let obj = {color: 'green'};
let src = `console.log(obj);`;
Function(src)();
这回是引用对象错误的异常:
ReferenceError: Can't find variable: obj
对于第一种错误,这是Function构造器出于安全机制的考虑而不同于eval
的地方,Function构造器不允许随便引用其他对象。
对于第二种错误,虽然在函数体内可以安全地引用对象,但目前函数体并未出现obj的身影。传入参数即可引用,详见下节。
传入参数
基本形式
使用下面的代码传入参数:
let src = `console.log(a + b);`;
Function('a', 'b', src)(2, 3); // 5
等同于:
function sum(a, b) {
console.log(a + b);
}
sum(2, 3);
注意,上面的a
与b
并不代表两个字符串,而是代表参数列表中共有2个参数,其参数名称分别为a与b。src作为函数体的字符串表示,直接引用了这两个参数名称。
小结一下:在函数体内所需要引用的对象名称,需出现在Function构造器的参数列表中,且该名称用单引号或双引号括起来。
现在,解决上一节所出现的问题:
let obj = {color: 'green'};
let src = `console.log(obj);`;
Function('obj', src)(obj); // {color: "green"}
多个参数
let obj1 = {};
let obj2 = {};
let obj3 = {};
let src = `console.log(obj1, obj2, obj3);`;
Function('obj1', 'obj2', 'obj3', src)(obj1, obj2, obj3);
有点晃眼,不好理解。
抽象为通用的函数
对于需要传入多个参数的,我们可将其抽象为一个通用的函数。
init();
function init() {
let obj1 = {};
let obj2 = {};
let obj3 = {};
let params = ['obj1', 'obj2', 'obj3'];
let body = `console.log(obj1, obj2, obj3);`;
let args = [obj1, obj2, obj3];
safeEval(params, body, args);
}
function safeEval(params, body, args) {
Function(...params, body)(...args);
}
将代表形参的params与代表函数体的body放在一起,很容易就可在大脑中脑补出这个函数的原型。形参params与实参args都打包为数组。
函数safeEval是我们抽象后得出的函数,在该函数中,将params与args通过...
展开符展开为可变长的变量,再投喂给Function构造器即可。
小结
正常情况下,我们很少会编写Function构造器的代码。但存在这样的场景:我们需要将页面上特定文本框中的内容取出来,作为JavaScript的代码来执行。此时,最直接的方式就是调用eval函数。但该函数充满风险,效率也极其低下。此时,使用Function构造器来实现目标则可成为一种很好的选择。此时,了解并掌握Function构造器的相关细节就很有必要了。