WebGL Tutorial
and more

函数

撰写时间:2023-10-15

修订时间:2024-11-20

参数

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值。

值为nullundefined的形参均可用!语句来判断:

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);

内嵌函数

内嵌函数是JavaScript的一大特色。

function aFunc() { let a = sum(3, 5); console.log(a); function sum(a, b) { return a + b; } }

如果某函数只在一个地方使用,而又想与其他局部变量相互影响,则可像上面一样创建一个内嵌函数。它的好处是,对外部不可见,而又与内部的其他变量相互隔离。

自调用函数

基本形式

在声明函数时,可立即调用该函数。

( 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

call, applybind均是与在被调用的函数内如何设置神奇的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();

如果thisa一样,则应都是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);

personcar作为参数,调用函数utils的方法call,这样,在utils中,this将被赋值为此参数。

终端输出:

{name: "Sarkuya", age: 25} {year: "1950", speed: 150}

注意这里的参数传递机制。

function utils() { console.log(this); } utils(person); utils(car);

函数utils没有任何形参,但却可以通过固定命名的方式,使用专门的、隐藏的变量this来接收不同的实参!

但并不是所有的函数调用都有这种能与神秘的this取得联系的特权。只有函数的callapply方法才有这种特权。这就是它们存在的意义所在。

函数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方法的原型如下:

*call
  • ObjectthisArg
  • ... *args
thisArg
在被调用函数的内部,this应指向的对象。
... args
被调用函数的其他参数。

apply

applycall的作用一样,只不过是其他参数不是可变长的参数,而是打包进一个数组中。

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方法的原型如下:

*call
  • ObjectthisArg
  • *[]args
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

直接调用objgetName方法,没有任何问题。

现在,声明一个指向objgetName方法的引用,或称函数指针。然后再调用它。

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,此时thisundefined

此时,可调用funcPointerbind方法,为该函数绑定一个在其内部若出现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方法?

  1. 当我们声明了一个变量,该变量指向一个函数
  2. 这个函数内部使用了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构造器的参数均为字符串,最后一个参数是函数体内的内容,其前面是可变长的函数参数。上面ab为参数的名称。

使用字符串作为函数体的内容,意味着在我们调用该函数时,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>

num1num2是全局变量,访问没问题。

访问局部变量

<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);

注意,上面的ab并不代表两个字符串,而是代表参数列表中共有2个参数,其参数名称分别为absrc作为函数体的字符串表示,直接引用了这两个参数名称。

小结一下:在函数体内所需要引用的对象名称,需出现在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是我们抽象后得出的函数,在该函数中,将paramsargs通过...展开符展开为可变长的变量,再投喂给Function构造器即可。

小结

正常情况下,我们很少会编写Function构造器的代码。但存在这样的场景:我们需要将页面上特定文本框中的内容取出来,作为JavaScript的代码来执行。此时,最直接的方式就是调用eval函数。但该函数充满风险,效率也极其低下。此时,使用Function构造器来实现目标则可成为一种很好的选择。此时,了解并掌握Function构造器的相关细节就很有必要了。

参考资源

  1. ECMA 262: Execution Contexts