天天看點

函數式程式設計的實用場景寫在最後

首發原文連結: https://juejin.im/post/6878941871259779085

前言

在學習

javaScript

中你覺得最難的是什麼?? 兩鍊一包? 還是this? 個人認為最難的是面向對象,因為它不像原型,閉包隻要了解了其機制就能形成自己的了解進而去了解這塊知識點,面向對象它是一個很抽象的大類,屬于程式設計範式的一種,而本文要講的函數式程式設計它也屬于一種程式設計範式。函數式程式設計涉及的點比較多如果往全了的講得扯到數學裡的範疇論,本文主要還是從實際編碼角度來了解一些比較晦澀難懂的點。

目錄👇

函數式程式設計的實用場景寫在最後

線上卑微,如果覺得這篇文章對你有幫助的話歡迎大家點個贊👻

何叫函數式程式設計

函數式程式設計技術主要基于數學函數和它的思想所誕生的一種程式設計範式,本質上是一種數學運算。

個人了解: 在函數式程式設計中,函數就是一個流水線,傳遞進去的資料(參數)就像是要加工的産品,我們可以通過用不同的加工裝置加工出不一樣的産品,但是這條流水線始終不會出現其他産線的任務産品。

上面這句話其實就是函數式程式設計最重要的一個概念:純函數

純函數

純函數的條件:

  • 函數内部不會依賴和影響外部的任何變量
  • 相同的輸入,永遠會得到相同的輸出

來個🌰

條件一: 函數内部不會依賴和影響外部的任何變量

let percentValue = 5;
let calculateTax = (value) => { return value/100 * (100 + percentValue) }
           

上面定義了一個計稅函數在調用

calculateTax

傳遞一個參數會給我們傳回計算後的稅值,這個計稅函數并沒有達到條件,計算函數内部使用了全局變量

percentValue

。是以它就不能被稱為純函數

下面我們改動一下

改動過後的函數接受資料加工後傳回資料整個過程中并沒有依賴和影響到外部的東西(函數内部不會依賴和影響外部的任何變量)

條件二: 相同的輸入,永遠會得到相同的輸出

我們用數組内置實作的方法舉個例子

let BeiGe = [1,2,3,4,5];

// 純函數
BeiGe.slice(0,3); // => [1,2,3]
BeiGe.slice(0,3); // => [1,2,3]
BeiGe.slice(0,3); // => [1,2,3]

// 不純的
BeiGe.splice(0,3); // => [1,2,3]
BeiGe.splice(0,3); // => [4,5]
BeiGe.splice(0,3); // => []
           

從上面例子可以看出來,

slice

方法的實作就是一個純函數,而

splice

方法就不是一個純函數。多次調用它并沒有輸出相同的值。

純函數的好處

  • 可緩存
  • 可測試
  • 可并發

可緩存

結果可以被緩存,因為相同的輸入總會獲得相同的輸出
// 假設這是一個耗時擷取資料的方法
let getData = (url) => { 
	// 一大段擷取資料前的參數拼接
    // ..發送請求邏輯
} 

           

上面函數就是一個擷取資料的純函數,對于給定的輸入,它總會傳回相同的輸出那我們不就可以将請求的結果緩存下來嘛

let ret = getData(`api/detail/${id}`)
// 傳回指定id的詳情的資料: [{id: 1, name: Beige ...}]

retItem.hasOwnProperty(id) ? retIpItem[id] : retIpItem = getIpData(id)
// 當使用者操作的是同一條資料我們就可以用之前緩存的資料,如果不是我們就可以再次發送請求來擷取指定id的詳情資料
           

可測試

更加容易被測試,因為它們唯一的職責就是根據輸入計算輸出
let percentValue = 5;
let calculateTax = (value) => { return value/100 * (100 + percentValue) } 

// 測試代碼假設全部通過,但是上面函數用到了外面環境中的資料,就會出現問題
calculateTax(5) === 5.25
calculateTax(6) === 6.3
calculateTax(7) === 7.3500000000000005


calculateTax(5) === 5.25
// percentValue 被其他函數改成 2
calculateTax(6) === 6.3 // 這條測試能通過嗎?
           

是以非純函數代碼是很難測試的,像下面的代碼就可以很好的用于測試

可多并發

let global = 'globalVar'
let function1 = (input) => {
    // 處理 input
    // 改變 global
	global = "somethingElse"
}
let function2 = (global) => {
	if(global === "something"){
		// 業務邏輯
	}
}
           

上面的代碼在順序執行的情況下是沒有問題的,但是如果函數執行不一樣的情況就會出現問題。

let global = 'globalVar'
let function1 = (input, global) => {
    // 處理 input
    // 改變 global
	global = "somethingElse"
}
let function2 = (global) => {
	if(global === "something"){
		// 業務邏輯
	}
}
           

好,在認識完純函數之後我們對上面說的那句話就應該有所體會了

在函數式程式設計中,函數就是一個流水線,傳遞進去的資料(參數)就像是要加工的産品,我們可以通過用不同的加工裝置加工出不一樣的産品,但是這條流水線始終不會出現其他産線的任務産品。

對于這句話相信大家都可以了解一半了,但是加粗的這段文字是什麼意思呢?

聲明式程式設計

函數式程式設計就是屬于聲明式程式設計範式,這種範式會描述一系列的操作,但并不會暴露它們是如何實作的或是資料流如何傳過它們。

// 指令式方式: 強調做什麼
let arr = [0, 1, 2, 3]
for(let i = 0; i < arr.length; i++) {
    array[i] = Math.pow(array[i], 2)
}

arr; // [0, 1, 4, 9]

// 聲明式方式: 強調如何做, 通過将邏輯封裝抽離出來, 達到抽象的目的
[0, 1, 2, 3].map(num => Math.pow(num, 2)) // [0, 1, 4, 9]
           

可以看出聲明式的代碼并沒有将内部的實作暴露出來,相反指令式的方式通過内部循環,而循環是一種重要的指令控制結構,但很難重用,并且很難插入其他操作中。而函數式程式設計旨在盡可能的提高代碼的無狀态性和不變性。也就是要使用純函數的方式實作

引用透明

引用透明是定義一個純函數較為正确的方法。純度在這個意義上表示一個函數的參數和傳回值之間映射的純的關系。如果一個函數對于相同的輸入始終産生相同的結果,那麼我們就說它是引用透明。

// 非引用透明
let counter = 0

function increment() {
    return ++counter
}

// 引用透明
let increment = (counter) => counter + 1
// => 上面的函數有了引用透明這個特性之後, 我們知道當我們傳遞一個數去它隻會給我們傳回這個數+1, 我們可以把它看做一個恒等式

let sum = 23 * 12 + increment(6) + increment(3) + increment(2)
// 上面的這個increment(6)、(3)、(2)  完全可以看成 -> x + 1
           

不可變資料

對于資料的不可變主要還是對象類型的資料,因為在js中的基本類型在不進行二次指派是改變不了原始資料的。但是對象就不一樣了,它屬于引用資料類型。

let Str = '123456789'

let changeStr = (str) => str.split('').reverse().join('') // '987654321'
log(Str) // 123456789
           

上面這個純函數在接受一個字元串之後對其做了一系列的“計算”但都沒有去改變資料原有性。

那如果是對象類型呢?

let sortDesc = (arr) => arr.sort((a, b) => a - b);

var arr = [1, 3, 2]
sortDesc(arr) // [1, 2, 3]
arr // [1, 2, 3]
// => 上面這個"純函數對資料加工後卻改變了原有資料
           

對于引用類型的操作,不應該改變原有對象,隻需要映射一個新的對象用來承載新的資料狀态。

onst field = Symbol(1)

let columnData = {
  label: '北歌',
  property: 'name',
  filters: 'xxx',
  filterMultiple: 'xxx',
  sortable: 'xxx',
  index: 'xxx',
  formatter: 'xxx',
  className: 'xxx',
  labelClassName: 'xxx',
  showOverflowTooltip: 'xxx',
  field: '111'
}

// => 假設我們對某個單獨列操作加個樣式
let addClass = (column) => {
  return newObj = Reflect.ownKeys(column).reduce((newObj, key) => (newObj[key] = key, newObj), {})
}
// 通過映射出新對象達到不去改變原有資料的目的
addClass(columnData)
           

相信到了這裡對于我前面講的那段話大家也應該了解的差不多了吧。

補充:對于函數式程式設計的思想在數組的原生方法就有很好的展現

函數幾種用法

接下來我們講講函數的幾種用法

  • 高階函數
  • 遞歸函數
  • 柯理化函數

高階函數

  • 函數可以作為參數
  • 函數可以作為傳回值

凡是達到上面兩個條件的其中一個都叫高階函數

例:從擷取的資料從找到性别為男,年齡為18的學生

let list = [ 
  {sex: '男', age: 17 },
  {sex: '女', age: 17 },
  {sex: '女', age: 13 },
  {sex: '女', age: 23 },
  {sex: '男', age: 16 },
  {sex: '男', age: 18 }
]
           

不使用高階函數的情況

let student = [];
for (let i = 0; i < list.length; i++) {
    if (list[i].age === 18 && list[i].sex === 男) {
        student.push(list[i])
    }
}
           

使用高階函數的情況

這隻是一個簡單的用法,其實在很多地方我們都用到了高階函數,如節流函數,它就是高階函數實作的

function throttle(fn, wait) {
   if (typeof fn != "function") {
        throw new TypeError("Expected a function")
   }

    let timer,
      lastTime = 0;

  return function(...arr) {
    let nowTime = Date.now();
    if (nowTime - lastTime < wait) { // 利用時間戳來判斷函數的執行時機
      timer && clearTimeout(timer) // 通過定時器來控制函數的執行
      timer = setTimeout(() => {
        fn.apply(this, ...arr);
      }, wait)
    } else { // 在第一次執行的時候不做限制
      fn.apply(fn, ...arr);
      lastTime = nowTime;
    }
  }
}

// 限制快速連續不停的點選,按鈕隻會有規律的在每2s點選有效
button.addEventListener('click', throttle(() => {
  console.log('前端自學驿站')
}, 2 * 1000))
           

上面這個節流函數就同時達到了高階函數的兩條件,将函數當做參數,将函數當做傳回值。

遞歸函數

使用一個調用自身的函數來實作循環,遞歸一般情況下都會有打破循環的條件。

例:求一個數的階乘

/**
 * @階乘 從1到n的連續自然數相乘的積,叫做階乘,用符号n!表示 
 * 
 * 例 5!: 1 x 2 x 3 x 4 x 5 所得的積就是5的階乘
 *    4!: 1 x 2 x 3 x 4 所得的積就是4的階乘
 * 
 */
function factorial(n) {
  function tailFactorial(n, total) {
    if (n === 1) {
      return total
    }
    return tailFactorial(n - 1, n * total)
  }
  return tailFactorial(n, 1)
}
console.log(factorial(5))
           

柯理化函數

柯裡化是将使用多個參數的一個函數拆分成一系列使用一個參數的函數(将多元函數轉換為一進制函數)

使用柯裡化實作累加函數

function currying(fn, ...args1) {
  if (args1.length >= fn.length) {
    return fn(...args1);
  }
  return (...args2) => {
    return currying(fn, ...args1, ...args2);
  };
}

// 定義一個用于累加的函數
const add = (x, y) => x + y;

// 使用
const increment = currying(add, 1);
console.log(increment(2)); // 3
const addTen = currying(add, 10);
console.log(addTen(2)); // 12
           

這裡講解的函數幾種用法在函數式程式設計中都會展現,它們都有一個目的就是為了讓函數變的更“純”,通過将多元函數轉換為一進制函數。

函數式程式設計的展現

在真實項目開發中我們一般通過将純函數組合,拆解繁多的業務邏輯,這就是函數式程式設計的一種展現

  • 組合:執行順序是從右至左執行的
  • 管道:執行順序是從左至右執行的

這兩個本質上沒有任何差別,就像是

reduce

reduceRight

一樣

來個🌰

function fn1(a) {
  return a * 2
}

function fn2(b) {
  return b + 2
}
// 我們需要傳遞一個數讓他先乘後加 如: x = 3  => 3 * 2 + 2  
           

組合的方式

const pipe = (...fns) => (val) => fns.reduce((total, fn) => fn(total), val)
let myfn = pipe(fn1, fn2)
console.log(myfn(3)); // 8
           

管道的方式

const compose = (...fns) => (val) => fns.reduceRight((total, fn) => fn(total), val)
let myfn = compose(fn2, fn1)
console.log(myfn(3)); // 8
           

注意他們各自的執行順序,對于管道是從右向左來執行函數的,組合反之。

組合

下面舉個例子來看看組合(compose)函數,,假設我們需要從背景拿到了一堆資料,然後我們需要多次篩選出需要的一部分資料進行操作,下面我們用僞代碼實作
// 資料
const dataList = [
    {
      id: 1,
      name: 'Beige2',
      time: 123123,
      content: 21312,
      created: 1233123
    },
    {
      id: 2,
      name: 'Beige',
      time: 123123,
      content: 21312,
      created: 1233123
    },
    {
      id: 3,
      name: 'Beige2',
      time: 123123,
      content: 21312,
      created: 1233123
    },
    {
      id: 4,
      name: 'Beige2',
      time: 1,
      content: 21312,
      created: 1233123
    },
]
// 封裝好的請求方法
const http = require('@/src/http') 
           
通過将多個篩選資料的方法組合起來,依次執行組合中的方法達到目的,在這個過程中我們可以随意減少篩選的條件(函數)
const compose = (...fns) => (args) => fns.reduceRight((ret, fn, index) => {
  return [fn.call(null, ...ret, args[index])]
}, args[args.length - 1])


let filterId = (arr, term) => arr.filter(c => c[term[0]] > term[1] )

let filterName = (arr, term) => arr.filter(c => c[term[0]] === term[1])

let post = (str) => str && dataList // 模拟擷取資料

let cacheFn = compose(filterName, filterId, post)

let ret = cacheFn([
  ['name', 'Beige2'],
  ['id', 1],
  '/post/data',
])
console.log(...ret);
           

管道

上面講過組合和管道其實本質上沒什麼差別,我們還是通過一個例子來看看管道(pipe)函數,實作一個功能,字元串變成大寫,加上個感歎号,還要截取一部分,再在前面加上注釋
const compose = (...fns) => (...args) => fns.reduce((res, fn) => [fn.call(null, ...res)], args)[0];

const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const head = x => `slice is: ${x}`;
const reverse = x => x.slice(0, 7);

const shout = compose(reverse, head, toUpperCase, exclaim)
console.log(shout('my name is Beige'))
// => SLICE IS: MY NAME
           

這裡提個問題,小夥伴們可以回想看看學過的内置方法中有那些方法使用到了函數式程式設計呢?

真實項目中實用函數式程式設計

記得之前做過的一個管理系統的項目中在資料表格中涉及到了很多了篩選條件且很多的條件其實是同樣的邏輯,最後我通過函數式程式設計将之前寫的代碼進行重構,這裡寫個demo簡單分享一下,重在思路。

需要直接看代碼的可以去我的blog代碼倉庫https://github.com/it-beige/blog,歡迎大家點個start,日後的案列代碼都會放這

函數式程式設計的實用場景寫在最後

上圖這種情況相信大家都做過吧,對于這種篩選資料的邏輯其實大部分的情況下都是一樣的,除了提取通用方法我們可以試試用函數式程式設計的方式來實作,廢話不多說直接上代碼

  • 結構比較簡單大家可以直接去copy餓了嗎的元件
// 三個select
<el-select
  v-model="filterTerm.value3"
  clearable
  placeholder="按照金額排序"
  @change="filterNum($event, 'amount')"
  @clear="this.value3 = null"
>
  <el-option
    v-for="item in options3"
    :key="item.value"
    :label="item.label"
    :value="item.value"
  >
</el-option>
           
  • 資料部分
filterTerm: { // 用于儲存篩選的條件
  value1: null,
  value2: null,
  value3: null,
},
filterFns: [], // 篩選的函數
tableData: [], // 資料
options1: [ // 可篩選的項- 大家可以根據實際業務場景進行随意配置
  {
    value: 0,
    label: "廢除",
  },
  {
    value: 1,
    label: "正常"
  },
],
           

重要的部分來了!

async getData() { // 擷取資料的方法
  let list = []
  const res = await this.axios("/list")
  this.tableData = res.data.list;
  return res.data.list
},
           
  • 篩選條件的方法
// 合同狀态篩選
filterStatus(val) {
  if (this.isNull(val) || val === '') return
  let status = val ? "正常" : "廢除";
  this.filterFns.push((data) => {
  return data.filter(i => i.status === status)
})
    
// 升降序的篩選 
filterNum(val, field) {
	if (this.isNull(val)) return
		this.filterFns.push((data) => {
 		return data.sort((a, b) => val ? a[field] - b[field] : b[field] - a[field])
	})
},
    
// 還可以配置跟多的篩選條件方法
           
  • 兩個工具函數
// utils
isNull(val) {
	if (Object.prototype.toString.call(val) === "[object Null]") return true;
	return false;
},

isOwnProperty(obj, key) {
	if (Object.prototype.hasOwnProperty.call(obj, key) !== "[object Null]") return true;
	return false;
},
           
  • 點選篩選操作時的執行函數
async filterCompose() { // 組裝函數
  if (this.filterFns.length > 0) {
    const filterFn = this.pipe(...this.filterFns)
    this.tableData = filterFn(await this.getData())
    this.filterFns.length = 0; // 執行後清空方法
  } else {
	this.getData()
  }
           
  • 管道
pipe(...fns) {
   return (data) => {
     return fns.reduce((list, fn) => {
       return fn(list)
     }, data)
   }
}
           

最後向大家推薦一個函數式程式設計比較常用的庫

ramda

  • gitHub倉庫位址:https://github.com/ramda/ramda
  • 中文文檔:https://ramda.cn/

寫在最後

如果文章中有那塊寫的不太好或有問題歡迎大家指出,我也會在後面的文章不停修改。也希望自己進步的同時能跟你們一起成長。喜歡我文章的朋友們也可以關注一下

我會很感激第一批關注我的人。此時,年輕的我和你,輕裝上陣;而後,富裕的你和我,滿載而歸。

參考文章

《JavaScript ES6函數式程式設計入門經典》

函數式程式設計最佳實踐

函數式程式設計初探

往期文章

自學前端拿到offer的心路曆程

深入Vue-router最佳實踐

深入Vuex最佳實踐

【前端體系】從一道面試題談談對EventLoop的了解 (更新了四道進階題的解析)

【前端體系】從地基開始打造一座萬丈高樓

繼續閱讀