函数式编程前菜

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

小刚老师

函数的简介

函数是能够经过外部代码调用的一个“子程序”。前端

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

函数的属性

  • length

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

  • name

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

  • prototype

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

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 专题之函数柯里化

这里是柯里化的一种实现方式:

function sub_curry(fn) {
  var args = [].slice.call(arguments, 1);
  return function() {
    return fn.apply(this, args.concat([].slice.call(arguments)));
  };
}
function curry(fn, length) {
  length = length || fn.length;
  var slice = Array.prototype.slice;
  return function() {
    if (arguments.length < length) {
      var combined = [fn].concat(slice.call(arguments));
      return curry(sub_curry.apply(this, combined), length - arguments.length);
    } else {
      return fn.apply(this, arguments);
    }
  };
}
var fn = curry(function(a, b, c) {
  return [a, b, c];
});
fn("a", "b", "c") // ["a", "b", "c"]
fn("a", "b")("c") // ["a", "b", "c"]
fn("a")("b")("c") // ["a", "b", "c"]
fn("a")("b", "c") // ["a", "b", "c"]
复制代码

这段看起来比较困难,建议代码复制到浏览器中加断点调试下。

经常使用的工具函数就介绍到这里,下篇函数式编程主食敬请期待~

相关文章
相关标签/搜索