阅读 1083

函数式编程前菜

最近对函数式编程产生了兴趣,于是复习了下关于函数的相关知识点,一起学习把~~

小刚老师

函数的简介

函数是可以通过外部代码调用的一个“子程序”。

在 js 中,函数是一等公民(first-class),因为函数除了可以拥有自己的属性和方法,还可以被当作程序一样被调用。在 js 中,函数实际上就是对象,每个函数都是 Function 构造函数的实例,因此函数名/变量名实际上也是一个指向函数对象的指针,一个变量名只能指向一个内存地址。也正因如此 js 中函数没有重载,因为两个同名函数,后面的函数会覆盖前面的函数。

函数的属性

  • length

length 属性表示函数预期接收的命名参数的个数,未定义参数不计算在内。

  • name

name 属性返回函数的名称。如果有函数名,就返回函数名;如果没有函数名,就返回被赋值的变量名或对象属性名。

  • prototype

prototype 属性是函数的原型对象,一般用来给实例添加公共属性和方法,是保存它们实例方法的真正所在。

function SuperType(name){
  this.name = name
}
SuperType.prototype.sayName = function(){
  alert(this.name);
}
let instance1 = new SuperType('幻灵儿')
let instance2 = new SuperType('梦灵儿')
instance1.sayName === instance2.sayName // true
SuperType.prototype.constructor === SuperType // true
复制代码

两个实例拥有公共方法sayName。原型对象的constructor指向构造函数本身。

  • new.target

es6 加入了元属性new.target,用来判断函数是否通过 new 关键字调用。当函数是通过new关键字调用的时候,new.target的值为该构造函数;当函数不是通过new调用的时候,new.targetundefined

  • 形参和实

参数有形参(parameter)和实参(argument)的区别,形参相当于函数中定义的变量,实参是在运行时的函数调用时传入的参数。

函数声明与函数表达式

函数可以通过函数声明创建,也可以通过函数表达式创建:

// 函数声明
function bar() {}
console.log(bar) // ƒ bar() {}
// 函数表达式
var foo = function bar() {}
console.log(bar) // Uncaught ReferenceError: bar is not defined
// 立即调用函数表达式(IIFE)
(function bar(){})()
console.log(bar) // Uncaught ReferenceError: bar is not defined
复制代码

简单来说,函数声明是 function 处在声明中的第一个单词的函数,否则就是函数表达式。

函数表达式var foo = function bar() {}中的foo是函数的变量名,bar是函数名。函数名和变量名存在着差别:函数名不能被改变,但变量名却能够被重新赋值;函数名只能在函数体内使用,而变量名却能在函数所在的作用域中使用。其实,函数声明也是同时也创建了一个和函数名相同的变量名:(值得一提的是 es6 中的 class 表达式也是同样的设计)

function bar () {}
var foo = bar
bar = 1
console.log(bar) // 1
console.log(foo) // ƒ bar () {}
复制代码

可以看出,bar函数被赋值给变量foo,就算给变量bar重新赋值,foo变量仍然是ƒ bar () {}。所以,就算是函数声明,我们平常在函数外调用函数的时候也是使用变量名而不是函数名调用的。 平时绝对不要轻易修改函数声明的变量名,否则会造成语义上的理解困难。

函数声明和函数表达式的最重要的区别是,函数声明存在函数提升,而函数表达式只存在变量提升(用var声明的有变量提升,let const声明的没有变量提升)。函数提升会在引擎解析代码的时候,把整个函数提升到代码最顶层;变量提升只会把变量名提升到代码最顶层,此时变量名的值为undefined;并且函数提升优先于变量提升,也就是说如果代码中同时存在变量a和函数a,那么变量a会覆盖函数a

var a = 1
function a (){}
console.log(a) // 1
复制代码

构造函数

js 中除了箭头函数,所有的函数都可以作为构造函数。但按照惯例,构造函数的首字母应该为大写字母。js 的Object Array Function Boolean String Number都是构造函数。构造函数配合关键字new可以创造一个实例对象,如:let instance = new Object()便创造了一个对象,let instance = new Function()便创建了一个函数对象,let instance = new String()便创建了一个字符串包装对象等等等。构造函数除了用来生成一个对象,还可以用来模拟继承,有兴趣看这篇文章

函数的 es6 新特性

es6 是对 js 的一次大升级,使得 js 的使用舒适度大大提升。es6 对函数的扩展让函数的使用体验更加酸爽,其新功能如下:(本文中 es6 是指 ES2015 之后版本的统称)

箭头函数

es6 中新增了箭头函数,极大的提高了函数书写舒适度。

// es5 写法
let f = function (v) { return v }
// es6 写法
let f = (v) => { return v }
// 像这样只有一个参数或代码块只有一条语句的,可以省略括号或者大括号,此时箭头后面的是函数返回值
let f= v => v
// 如果不需要返回值,可以用 void 关键字
let f = (fn) => void fn()
// 如果没有形参,则需要括号
let f = () => { console.log('我的参数去哪了') }

// 函数参数是对象的话,可以使用变量的解构赋值
const full = function ({ first, last }){ return first + last }
full({first: '幻灵', last: '尔依'}) // 幻灵尔依
// 箭头函数使用解构赋值更简便,但此时参数必须用括号
const full = ({ first, last }) => first + last
full({first: '幻灵', last: '尔依'}) // 幻灵尔依
复制代码

箭头函数除了书写简便之外,还有如下特征:

  • 没有自己的 this、super、argumentsnew.target:箭头函数内部的这些值直接取自 定义时的外围非箭头函数,且不可改变;
  • 箭头函数的 this 值不受 call()、apply()、bind() 方法的影响:因为箭头函数根本没有自己的this
  • 不能用作构造函数:由于箭头函数没有自己的this,而构造函数需要有自己的this指向实例对象,所以如果通过 new 关键字调用箭头函数会抛错Uncaught TypeError: arrowFunction is not a constructor。又因为不能作为构造函数,所以箭头函数干脆也没有自己的prototype属性。即使我们手动给箭头函数添加了prototype属性,它也不能被用作构造函数;
  • 不支持重复的命名参数:无论是在严格还是非严格模式下,箭头函数都不支持重复的命名参数;而在非箭头函数的只有在严格模式下才不能有重复的命名参数。
  • 不可以使用yield命令:因此箭头函数不能用作 Generator 函数

没有自己的this是箭头函数最大的特点。因为这个特性,箭头函数不宜用作对象的方法,因为点调用和call/bind/ayyly绑定都无法改变箭头函数的this

let obj = {
  arrow: () => { return this.god },
  foo() { return this.god },
  god: '幻灵尔依'
}
obj.foo() // '幻灵尔依'
obj.arrow() // undefined
obj.arrow.call(obj) // undefined
复制代码

也正是因为这个特性,使得在vue等框架中使用this爽的畅快淋漓。因为这些框架一般都把vue实例对象绑定在钩子函数或methods中函数的this对象上,在这些函数中使用箭头函数方便我们在函数嵌套的时候直接使用this而不用老套又没有语法高亮的let _this = this

export default {
  data() {
    return {
      name: '幻灵尔依'
    }
  },
  created() {
    console.log(this.name) // '幻灵尔依'
    setTimeout(() => {
      this.name = '好人卡'
      console.log(this.name) // '好人卡'
      setTimeout(() => {
        this.name = '你妈叫你回家吃饭了'
        console.log(this.name) // '你妈叫你回家吃饭了'
      }, 1000)
    }, 1000)
  }
}
复制代码

可以看到,只要是箭头函数,无论嵌套多深,this永远都是外围非箭头函数created钩子函数中的那个this

函数参数默认值

函数参数的默认值对于一些需要参数有默认值的函数非常方便:

// 当参数设置默认值,就算只有一个参数,也必须用括号
let f = (v = '幻灵尔依') => v
// 参数是最后一个参数的话可以不填,此时使用默认值
f() // '幻灵尔依'
// 传入 undefined 则使用默认值
f(undefined) // '幻灵尔依'
// 传入 undefined 之外的值不会使用默认值
f(null) // null
复制代码

默认值可以和解构赋值一起使用:

let f = ({ x, y = 1 }) => { console.log(x, y) }
f({}) // undefined 1
f({ x: 2, y: 2 }) // 2 2
f({ x: 1 }) // 1 1
// 此时必须传入一个对象,否则会抛错
f() // Uncaught TypeError: Cannot destructure property `x` of 'undefined' or 'null'.

// 也可以再给对象一个默认参数
let f = ({ x = 1 , y = 1} = {}) => { console.log(x, y) }
// 此时调用可以不传参数,就相当于传了个空对象
f() // 1 1
复制代码

参数指定了默认值之后,函数的length属性将不计算该参数。如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。下文的 rest 参数也不会计入length属性。这是因为length属性的含义是,该函数预期传入的参数个数。

一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域。这就相当于使用了参数默认值之后,函数外面又包裹了一层参数用let声明的块级作用域:

var x = 1
function foo(x = x) {
  return x
}
foo() // Uncaught ReferenceError: Cannot access 'x' before initialization
// 其实上面代码相当于是这样的,由于存在暂时性死区,`let x = x`会报错
var x = 1
{
  let x = x
  function foo() {
    return x
  }
}
foo () // Uncaught ReferenceError: Cannot access 'x' before initialization

// 再看个例子
var x = 1
function foo(x, y = function() { x = 2; }) {
  x = 3
  y()
  console.log(x)
}
foo() // 2
x // 1
// 上面代码相当于
var x = 1
{
  let x
  let y = function() { x=2 }
  function foo() {
    x = 3
    y()
    console.log(x)
  }
}
foo() // 2
x // 1
复制代码

其实就把默认参数的括号想象成是let声明的块级作用域就行了。

剩余参数

剩余参数,顾名思义就是剩余的参数的集合,所以剩余参数后面不能再有参数。剩余参数就是扩展运算符+变量名:

// 剩余参数代替伪数组对象`arguments`
let f = function(...args) { return args } // 箭头函数写法更简单 let f = (...arg) => arg
let arr = f(1, 2, 3, 4) // [1, 2, 3, 4]
// 还可以用扩展运算符展开一个数组当作函数实参
f(...arr) // [1, 2, 3, 4]
复制代码

尾调用优化

尾调用(Tail Call)优化是指某个函数的最后一步是返回并调用另一个函数,所以函数执行的最后一步一定要是return一个函数调用:

function f(x){
  return g(x)
}
复制代码

函数调用会在执行栈创建一个“执行上下文”,函数中调用另一个函数则会创建另一个“执行上下文”并压在栈顶,如果函数嵌套过多,执行栈中函数的执行上下文堆叠过多,内存得不到释放,就可能会发生真正的stack overflow

但是如果一个函数调用是发生在当前函数中的最后一步,就不需要保留外层函数的执行上下文了,因为这时候要调用的函数的参数值已经确定,不再需要用到外层函数的内部变量了。尾调用优化就是当符合这个条件的时候删除外曾函数的执行上下文,只保留内部调用函数的执行上下文。

尾调用优化对递归函数意义重大(后面会将介绍递归)。

小确幸

  • ES2017 规定函数形参和实参结尾可以有逗号,之前,函数形参和实参结尾都不能有逗号。

  • ES2019 规定Function.prototype.toString()要返回一模一样的原始代码的字符串,之前返回的字符串会省略注释和空格。

  • ES2019 规定catch可以省略参数,现在可以这样写了:try{...}catch{...}

  • es6 还引入了 Promise 构造函数和 async 函数,使得异步操作变得更加方便。还引入了class继承,想了解的去看阮一峰ECMAScript 6 入门

es6 就介绍到这,都是从阮一峰哪学的。

常用高阶函数

高阶函数简介

高阶函数是指有以下特征之一的函数:

  1. 函数可以作为参数传递
  2. 函数可以作为返回值输出

js 内置了很多高阶函数,像forEach map every some filter reduce find findIndex等,都是把函数作为参数传递,即回调函数:

[1, 2, 3, 4].map(v => v * 2) // [2, 4, 6, 8] 返回二倍数组
[1, 2, 3, 4].filter(v => !(v % 2)) // [2, 4] 返回偶数组成的数组
[1, 2, 3, 4].findIndex(v=> v === 3) // 2  返回第一次值为3的项的下标
[1, 2, 3, 4].reduce((prev, cur) => prev + cur) // 10 返回数组各项之和
复制代码

像常用的节流防抖函数,都是即以函数为参数,又在函数中返回另一个函数:


// 防抖
function _debounce (fn, wait = 250) {
  let timer
  return function (...agrs) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, wait)
  }
}
// 节流
function _throttle (fn, wait = 250) {
  let last = 0
  return function (...args) {
    let now = Date.now()
    if (now - last > wait) {
      last = now
      fn.apply(this, args)
    }
  }
}
// 应用
button.onclick = _debounce (function () { ... })
input.keyup = _throttle (function () { ... })
复制代码

节流和防抖函数都是在函数中返回另一个函数,并利用闭包保存需要的变量,避免了污染外部作用域。

闭包

上面节流防抖函数用到了闭包。很长时间以来我对闭包都停留在“定义在一个函数内部的函数”这样肤浅的理解上。事实上这只是闭包形成的必要条件之一。直到后来看了kyle大佬的《你不知道的javascript》上册关于闭包的定义,我才豁然开朗:

当函数能够记住并访问所在的词法作用域时,就产生了闭包。

let single = (function(){
  let count = 0
  return {
    plus(){
      count++
      return count
    },
    minus(){
      count--
      return count
    }
  }
})()
single.plus() // 1
single.minus() // 0
复制代码

这是个单例模式,这个模式返回了一个对象并赋值给变量single,变量single中包含两个函数plusminus,而这两个函数都用到了所在词法作用域中的变量count。正常情况下count和所在的执行上下文会在函数执行结束时被销毁,但是由于count还在被外部环境使用,所以在函数执行结束时count和所在的执行上下文不会被销毁,这就产生了闭包。每次调用single.plus()或者single.minus(),就会对闭包中的count变量进行修改,这两个函数就保持住了对所在的词法作用域的引用。

闭包其实是一种特殊的函数,它可以访问函数内部的变量,还可以让这些变量的值始终保持在内存中,不会在函数调用后被垃圾回收机制清除。

看个经典安利:

// 方法1
for (var i = 1; i <= 5; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}
// 方法2
for (let i = 1; i <= 5; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}
复制代码

方法1中,循环设置了五个定时器,一秒后定时器中回调函数将执行,打印变量i的值。毋庸置疑,一秒之后i已经递增到了5,所以定时器打印了五次5 。(定时器中并没有找到当前作用域的变量i,所以沿作用域链找到了全局作用域中的i

方法2中,由于es6的let会创建局部作用域,所以循环设置了五个作用域,而五个作用域中的变量i分布是1-5,每个作用域中又设置了一个定时器,打印一秒后变量i的值。一秒后,定时器从各自父作用域中分别找到的变量i是1-5 。这是个利用闭包解决循环中变量发生异常的新方法。

递归

递归就是在函数中调用自身:

function factorial(n) {
  if (n === 1) return 1
  return n * factorial(n - 1)
}
复制代码

上面就是一个递归实现的阶乘,由于返回值中还有n,所以外层函数的执行环境理论上不能被销毁。但是 chrome 浏览器如此强大,factorial(10000)并没有爆栈。不过看到浏览器几千个调用栈也是吓了一跳:

上文中介绍了尾调用优化:指某个函数的最后一步是返回并调用另一个函数。在递归中使用尾调用优化成为尾递归。上面阶乘函数改写为尾递归如下:

function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}
复制代码

这样就符合尾调用优化的规则了,理论上现在应该只有一个调用栈了。然而经过测试,chrome 浏览器(版本 76.0.3809.100)目前并不支持尾调用优化(目前好像还没有浏览器支持):

反而因为执行上下文中存的变量多了个total,执行factorial(10000)会爆栈。

组合函数

参考「中高级前端必须了解的」彻底弄懂函数组合

组合(compose)函数会接收若干个函数作为参数,每个参数函数执行后的输出作为下一个函数的输入,直至最后一个函数的输出作为最终的结果。实现效果如下:

function compose(...fns){ ... }
compose(f,g)(x) // 相当于 f(g(x))
compose(f,g,m)(x) // 相当于 f(g(m(x))
compose(f,g,m)(x) // 相当于 f(g(m(x))
compose(f,g,m,n)(x) // 相当于 f(g(m(n(x))
···
复制代码

组合函数的实现很简单:

function compose (...fns) {
  return function (...args) {
    return fns.reduceRight((arg , fn, index) => {
      if (index === fns.length - 1) {
        return fn(...arg)
      }
      return fn(arg)
    }, args)
  }
}
复制代码

注意reduceRight第三个参数index也是倒序的。

开闭原则:软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

开闭原则是我们编程中的基本原则之一,我们前端近些年发展的组件化 模块化 颗粒化的也暗合了开闭原则。基于这个原则,利用组合函数能够帮我们实现适用性更强、更易扩展的代码。

假如我们有个应用要做各种字符串处理,为了方便调用,我们可以将一些字符串要用到的方法封装成纯函数:

function toUpperCase(str) {
    return str.toUpperCase()
}
function split(str){
  return str.split('');
}
function reverse(arr){
  return arr.reverse();
}
function join(arr){
  return arr.join('');
}
function wrap(...args){
  return args.join('\r\n')
}
复制代码

如果我们要将一个字符串let str = 'emosewa si nijeuj'转化成大写,然后逆序,可以这样写:join(reverse(split(toUpperCase(str))))。然后我们又要转换另一个字符串let str2 = 'dlrow olleh',又得写:join(reverse(split(toUpperCase(str2))))这样一长串。现在有了组合函数,我们可以简单写:

let turnStr = compose(join, reverse, split, toUpperCase)
turnStr(str) // JUEJIN IS AWESOME
turnStr(str2) // HELLO WORLD
// 还可以传多个参数,见 turnStr2
let turnStr2 = compose(join, reverse, split, toUpperCase, wrap)
turnStr2(str, str2) // HELLO WORLD  JUEJIN IS AWESOME
复制代码

还有一种管道函数从左至右处理数据流,即把组合函数的参数倒着传,感觉上比较符合传参的逻辑,但是从右向左执行更加能够反映数学上的含义。所以更推荐组合函数,就不介绍管道了,避免你的选择困难症。

函数柯里化

柯里化,是把多参函数转换为一系列单参函数的技术。具体实现就是柯里化函数会接收若干参数,然后不会立即求值,而是继续返回一个新函数,将传入的参数通过闭包的形式保存,等到被真正求值的时候,再一次性把所有传入的参数进行求值。

关于柯里化,这里有篇深度好文,我写不出来的那种:JavaScript 专题之函数柯里化

最近在学习函数式编程,大家有好的学习资料来一起分享下哈~

关注下面的标签,发现更多相似文章
评论