函數語言程式設計前菜

幻靈爾依發表於2019-08-15

最近對函數語言程式設計產生了興趣,於是複習了下關於函式的相關知識點,一起學習把~~

小剛老師

函式的簡介

函式是可以通過外部程式碼呼叫的一個“子程式”。

在 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 專題之函式柯里化

這裡是柯里化的一種實現方式:

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"]
複製程式碼

這段看起來比較困難,建議程式碼複製到瀏覽器中加斷點除錯下。

常用的工具函式就介紹到這裡,下篇函數語言程式設計主食敬請期待~

相關文章