跳到主要内容

6. 函数与函数式编程

1 函数

1.1 arguments 对象

arguments 是一个对应于 传递给函数的参数类数组(array-like) + 可迭代对象

  • array-like 意味着它一个对象,不是数组:
    • 拥有数组的一些特性:length,index 下标访问;
    • 没有数组的一些方法:forEach、map 等遍历方法,但是可以用 [].call(arguments) 调用。
  • iterable 可迭代意味着它拥有一个 Symbol.iterator 属性:
    • 可以调用 Symbol.iterator 实现迭代。
  • arguments 有三个参数
    • length:传递给函数的参数数量
    • callee:指向参数所属的当前执行的函数
    • 返回一个新的 Array迭代器 对象,可遍历所有参数。

注:

  1. 箭头函数没有 arguments;
  2. arguments 已经逐渐被遗弃,如果需要拿到所有参数,使用扩展运算符 ...args

Arguments 的特性:

function foo(x, y, z) {
console.log(arguments); // arguments: [10, 20, 30, length: 3, callee: ƒ, Symbol(Symbol.iterator): ƒ, [[Prototype]]: Object]
// arguments 的三个属性
console.log(arguments.length); // 3
console.log(arguments.callee); // 函数本身 ƒ foo(x, y, z) { ... }
console.log(arguments[Symbol.iterator]); // 迭代器 f
// 类数组对象
console.log(arguments[0]); // 10

// 可迭代对象
for (const val of arguments)
console.log(val); // 10,20,30

// 手动迭代
const iterator = arguments[Symbol.iterator]();
while (true) {
const elem = iterator.next();
console.log(elem);
// {value: 10, done: false}
// {value: 20, done: false}
// {value: 30, done: false}
// {value: undefined, done: true}
if (elem.done) break;
}
}

foo(10, 20, 30);

1.1.1 arguments 转换为数组

// 方法一:遍历+push
const arr1 = [];
for (const val of arguments) arr1.push(val);

// 方法二:所有Array可返回新数组的API:
// contact() 将传入的数组或者元素与原数组合并,组成一个新的数组并返回
// slice() 连接两个或多个数组
// join() 将数组中的所有元素连接成一个字符串
// indexOf() 用于查找元素在数组中第一次出现时的索引,如果没有,则返回-1
// lastIndexOf() 用于查找元素在数组中最后一次出现时的索引,如果没有,则返回-1
// includes() ES7 判断当前数组是否包含某个指定的值
const arr2 = Array.prototype.slice.call(arguments);
const arr3 = [].slice.call(argumtnts);

// 方式三:扩展运算符、Array api
const arr4 = [...arguments];
const arr5 = Array.from(arguments);

1.1.2 对比:类数组对象、可迭代对象

类数组和可迭代在遍历功能上非常相似,都可以方便的方式内部元素,但是二者仍然有明显的区别:

  • iterable 可迭代对象:实现了Symbol.iterator的对象;

  • array-like 类数组对象:具有数字索引,并且有length属性;

1.1.3 附:Array.from 用法

  1. Array.from() 可以把定义的类数组对象转化为一个真正的数组;

  2. Array.from() 在 leetcode 的背包问题 / dp 问题中,常常需要声明一个二维数组:

const dp = Array.from(new Array(nums.length), () => new Array(2).fill(0));
  • 第一个参数,声明一个固定长度的数组;
  • 第二个参数,对数组中每个成员都执行一遍回调函数;

返回的 dp 就是一个二维数组,每个成员都是一个长度为 2,赋值为 0 的子数组。

1.2 其他待补充

JavaScript 函数有两个不同的内部方法:[[Call]] 和 [[Construct]]。当通过 new 关键字调用函数时,执行的是 [[Construct]] 函数,它负责创建一个通常被称作实例的新对象,然后再执行函数体,讲 this 绑定到实例上:如果不通过 new 关键字调用函数,则执行 [[Call]] 函数,从而直接执行代码中的函数体。

具有 [[Construct]] 方法的函数被统称为构造函数。

—— 第三章《函数》P54

元属性(Metaproperty)new.target

为了解决判断函数是否通过 new 关键字调用的问题,ECMAScript6 引入了 new.target 这个元属性。

元属性是指非对象的属性,其可以提供非对象目标的补充信息(例如 new)。当调用函数的 [[Construct]] 方法时,new.target 被赋值为 new 操作符的目标,通常是新创建对象实例,也就是函数体内 this 的构造函数;如果调用 [[Call]] 方法,则 new.target 的值为 undefined。

有了这个元属性,可以通过检查 new.target 是否被定义过,从而安全低检测一个函数是否是通过 new 关键字调用的,就像这样:

function Person(name) {
if (typeof new.target !== "undefined") {
this.name = name;
} else {
throw new Error("必须通过 new 关键字来调用 Person");
}
}

let person = new Person("Moxy"); //
let person2 = Person.call(person, "Moxy") // 报错,没有用 new,new.target值为 undefined

—— 第三章《函数》P55

2. 函数式编程

2.1 纯函数

JavaScript 符合函数式编程的范式,函数式编程中有一个非常重要的概念叫 纯函数

  • 确定的入参(输入),一定会返回确定的结果(输出)。
    • 同样的输入内容,不论执行多少次,返回的结果必须都是一定的。
  • 函数在执行过程中,不能产生副作用
    • 不能有事件绑定,比如绑定触发事件监听等等;
    • 不能创建/修改,当前函数作用域以外的任何参数、变量,尤其注意不能直接修改入参。
    • 不能修改有外部存储,产生新的 I/O 输入/输出操作。

在 react 中的应用:

  • react 中的 函数组件 必须是一个纯函数;
  • redux 中的 reducer 必须是一个纯函数;

在 js 中的应用:

  • 不会原地修改数组的 API,就是纯函数(splice 等,上文 argument 有总结)。
    • slice:截取数组时,不会对原数组进行任何操作,而是生成一个新的数组;
    • splice:截取数组时,会返回截断的数组,也会对原数组进行修改;

纯函数的意义:

提高项目的解耦:

  • 自己编写的组件 / 函数 / API 不会对项目中其他部分造成影响。程序员只需要关心自己的业务逻辑,而不用关心传入的内容是如何获得的,或者传入的内容是否会依赖其他的外部变量。

2.2 Currying 柯里化

currying:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩余的参数。

  1. 把一个接收多个参数的函数改造,变成接受一个单一参数的新函数。
  2. 这个新函数只接受第一个参数,然后新函数也会返回一个函数。
  3. 返回的这个函数接受余下的参数,然后旧函数的逻辑在这里集中完成。
// 未柯里化
function add1(x, y, z) {
return x + y + z;
}
// 柯里化1
function add2(x) {
return function(y) {
return function(z) {
return x + y + z;
}
}
}
// 柯里化2
const add3 = x => y => z => {
return x + y + z;
}
// test
add1(10, 20, 30);
add2(10)(20)(30);
add3(10)(20)(30);

柯里化的意义1:单一职责原则(SRP single responsibility principle)

  1. 让每一个函数处理的问题尽可能单一,而不是将一大堆的处理过程交给一个函数来处理。
  2. 类似于异步编程的思想,解决完问题一,再解决问题二,接着解决问题三。让问题一步步处理。

柯里化的意义2:复用参数逻辑

  1. 形成一个树状结构,最外层函数是根节点,往下可以展开为多个节点。
function add(x, y, z) {
x = x + 2; // 这个逻辑可能要10行
y = y * 2; // 50行
z = z * z; // 80行
return x + y + z;
}

add(10, 20, 30);

// add 函数中包含了3个自逻辑,currying 把职责单一

function sum(x) {
x = x + 2;

return function(y) {
y = y * 2;

return function(z) {
z = z * z;

return x + y + z
}
}
}

// 柯里化用法1:职责单一
sum(10)(20)(30);

// 柯里化用法2:复用,最后一步计算可以直接复用
const square = sum(10)(20);
square(3);
square(10);
square(20);

例如,日志打印:

// log 来日志打印
function log(date, type, message) {
console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]: [${message}]`)
}

// test,每次使用 log 都需要重复输入非常多内容,用柯里化优化:
log(new Date(), "DEBUG", "查找到轮播图的bug")
log(new Date(), "DEBUG", "查询菜单的bug")
log(new Date(), "DEBUG", "查询数据的bug")

// 柯里化的优化
var log = date => type => message => {
console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]: [${message}]`)
}

// 如果我现在打印的都是当前时间
var nowLog = log(new Date())
nowLog("DEBUG")("查找到轮播图的bug")
nowLog("FETURE")("新增了添加用户的功能")

// 如果都是bug问题:
var nowAndDebugLog = log(new Date())("DEBUG")
nowAndDebugLog("查找到轮播图的bug-1")
nowAndDebugLog("查找到轮播图的bug-2")
nowAndDebugLog("查找到轮播图的bug-3")
nowAndDebugLog("查找到轮播图的bug-4")

// 如果都是新特新问题:
var nowAndFetureLog = log(new Date())("FETURE")
nowAndFetureLog("添加新功能~")
  • 项目中用 log 打印日志,用柯里化实现了不同模块的输出:
log: 规则引擎-index-func1-10;
log: 规则引擎-index-func2-10;

log: 规则引擎-新增规则-foo1-10;
....

面试题:currying 实现

把一个普通函数转化为 curry 柯里化函数。

function currying(fn) {
// 递归 curried,接受所有参数
function curried(...args) {
// base case
if (fn.length <= args.length) return fn.call(this, ...args);
// 递归
return function(...args2) {
return curried.call(this, ...args, ...args2);
}
}
return curried;
}



// 详细注释
function currying(fn){
// 返回已柯里化函数
function curried(...args){
// args.length 传入参数的个数
// fn.length 函数形参的个数
// 判断当前已经接受到的参数个数,是否和函数本身需要的参数一致了
if (args.length >= fn.length) {
return fn.call(this, ...args); // 防止外部调用fn时绑定过this,用.call调用,不丢失this指向
}
// 参数不够,返回一个新函数接受剩余参数
return function(...otherArgs) {
// 递归调用curried,并把之前已经接收的参数添加
// 这里要return,因为并不知道当前参数是否足够,如果不够还会返回一个函数接受剩余的参数
return curried.call(this, ...args, ...otherArgs);
}
}
return curried;
}


// test
function add(x, y, z) {
return x + y + z;
}

// 假如 curryAdd 最多有3个参数,但传递方式可以有如下情况:
const curryAdd = currying(add);
curryAdd(10, 20, 30);
curryAdd(10, 20)(30);
curryAdd(10)(20)(30);

3. 组合函数

把两个逻辑相连的函数组合起来,按顺连接。

function double(num) {
return num * 2;
}

function square(num) {
return num ** 2;
}

// 分开调用,可以简化为一个函数:
const result = square(double(19));


// 定义组合函数
function composeFn(fn1, fn2) {
return function(count) {
return fn1(fn2(count));
}
}

// 组合调用:
const newFn = composeFn(double, square);
const result = newFn(19);

如果实现通用组合函数,也就是多个函数组合为一个函数:

function compose(...fns) {
const len = fns.length; // 统计共有几个函数需要组合
for (let i = 0; i < len; i++) {
if (typeof fns[i] !== 'function') {
throw new TypeError("Expected arguments are function");
}
}
// 递归执行所有fns
return function (...args) {
let index = 0;
// 如果没有传入任何函数,则直接返回参数当作结果。
let result = len ? fns[index].call(this, ...args) : args;
while (++index < len) {
result = fns[index].call(this, result);
}
return result;
}
}


// 测试:
function add(num1, num2) {
return num1 + num2;
}

function double(num) {
return num * 2;
}

function square(num) {
return num ** 2;
}

const newFn = compose(add, double, square);
const result = newFn(1, 2); // 36

4. with 和 eval

4.1 with

with语句 扩展一个语句的作用域链。

不建议使用with语句,因为它可能是混淆错误和兼容性问题的根源。

var obj = {
name: "hello world",
age: 22
}

with(obj) {
console.log(name); // hello world
console.log(age); // 22
}

4.2 eval

是一个特殊的函数,它可以将传入的字符串当做JavaScript代码来运行。

问题:

  • eval 代码的可读性非常的差(代码的可读性是高质量代码的重要原则);

  • eval是一个字符串,那么有可能在执行的过程中被刻意篡改,那么可能会造成被攻击的风险;

  • eval 的执行必须经过 JS 解释器,不能被 JS 引擎优化;

var evalString = `var message = "hello world"; console.log(message)`
eval(evalString);

console.log(message);

5. Strict Mode

严格模式,ES5 提出的标准。在严格模式中,有以下限制:

  • 严格模式通过 抛出错误 来消除一些原有的 静默错误(silent fail); 原本错误语法,被认为也是可以正常被解析的,严格模式下会抛出错误;
    • 左查询时,无法创建全局变量:a = "123"
  • 严格模式让 JS 引擎在执行代码时可以进行更多的优化(不需要对一些特殊的语法进行处理);
    • eval 不再为上层引用变量;
    • with 不允许使用;
    • this 绑定不会默认转化为包装对象;
  • 严格模式禁用了在 ECMAScript 未来版本中可能会定义的一些语法;

严格模式在全局/ 函数作用域中开启:

"use strict"