JavaScript 之函數式編程

同步發佈於 https://github.com/xianshanna...前端

是個程序員都知道函數,可是有些人不必定清楚函數式編程的概念。jquery

應用的迭代使程序變得愈來愈複雜,那麼程序員頗有必要創造一個結構良好、可讀性好、重用性高和可維護性高的代碼。git

函數式編程就是一個良好的代碼方式,可是這不表明函數式編程是必須的。你的項目沒用到函數式編程,不表明項目很差。程序員

什麼是函數式編程(FP)?

函數式編程關心數據的映射,命令式編程關心解決問題的步驟。

函數式編程的對立面就是命令式編程github

函數式編程語言中的變量也不是 命令式編程語言中的變量,即存儲狀態的單元,而是代數中的變量,即一個值的名稱。 變量的值是不可變的(immutable),也就是說不容許像 命令式編程語言中那樣屢次給一個變量賦值。

函數式編程只是一個概念(一致編碼方式),並無嚴格的定義。本人根據網上的知識點,簡單的總結一下函數式編程的定義(本人總結,或許有人會不一樣意這個觀點)。編程

函數式編程就是純函數的應用,而後把不一樣的邏輯分離爲許多獨立功能的純函數(模塊化思想),而後再整合在一塊兒,變成複雜的功能。redux

什麼是純函數?

一個函數若是輸入肯定,那麼輸出結果是惟一肯定的,而且沒有反作用,那麼它就是純函數。

通常符合上面提到的兩點就算純函數:設計模式

  • 相同的輸入一定產生相同的輸出
  • 在計算的過程當中,不會產生反作用

那怎麼理解反作用呢?數組

簡單的說就是變量的值不可變,包括函數外部變量和函數內部變量。瀏覽器

所謂 反作用,指的是函數內部與外部互動(最典型的狀況,就是修改全局變量的值),產生運算之外的其餘結果。

這裏說明一下不可變不可變指的是咱們不能改變原來的變量值。或者原來變量值的改變,不能影響到返回結果。不是變量值原本就是不可變。

純函數特性對比例子

上面的理論描述對於剛接觸這個概念的程序員,或許很差理解。下面會經過純函數的特色一一舉例說明。

輸入相同返回值相同

純函數

function test(pi) {
  // 只要 pi 肯定,返回結果就必定肯定。
  return pi + 2;
}
test(3);

非純函數

function test(pi) {
  // 隨機數返回值不肯定
  return pi + Math.random();
}

test(3);

返回值不受外部變量的影響

非純函數,返回值會被其餘變量影響(說明有反作用),返回值不肯定。

let a = 2;
function test(pi) {
  // a 的值可能中途被修改
  return pi + a;
}
a = 3;
test(3);

非純函數,返回值受到對象 getter 的影響,返回結果不肯定。

const obj = Object.create(
  {},
  {
    bar: {
      get: function() {
        return Math.random();
      },
    },
  }
);

function test(obj) {
  // obj.a 的值是隨機數
  return obj.a;
}
test(obj);

純函數,參數惟一,返回值肯定。

function test(pi) {
  // 只要 pi 肯定,返回結果就必定肯定。
  return pi + 2;
}
test(3);

輸入值是不能夠被改變的

非純函數,這個函數已經改變了外面 personInfo 的值了(產生了反作用)。

const personInfo = { firstName: 'shannan', lastName: 'xian' };

function revereName(p) {
  p.lastName = p.lastName
    .split('')
    .reverse()
    .join('');
  p.firstName = p.firstName
    .split('')
    .reverse()
    .join('');
  return `${p.firstName} ${p.lastName}`;
}
revereName(personInfo);
console.log(personInfo);
// 輸出 { firstName: 'nannahs',lastName: 'naix' }
// personInfo 被修改了

純函數,這個函數不影響外部任意的變量。

const personInfo = { firstName: 'shannan', lastName: 'xian' };

function reverseName(p) {
  const lastName = p.lastName
    .split('')
    .reverse()
    .join('');
  const firstName = p.firstName
    .split('')
    .reverse()
    .join('');
  return `${firstName} ${lastName}`;
}
revereName(personInfo);
console.log(personInfo);
// 輸出 { firstName: 'shannan',lastName: 'xian' }
// personInfo 仍是原值

那麼大家是否是有疑問,personInfo 對象是引用類型,異步操做的時候,中途改變了 personInfo,那麼輸出結果那就可能不肯定了。

若是函數存在異步操做,的確有存在這個問題,的確應該確保 personInfo 不能被外部再次改變(能夠經過深度拷貝)。

可是,這個簡單的函數裏面並無異步操做,reverseName 函數運行的那一刻 p 的值已是肯定的了,直到返回結果。

下面的異步操做才須要確保 personInfo 中途不會被改變:

async function reverseName(p) {
  await new Promise(resolve => {
    setTimeout(() => {
      resolve();
    }, 1000);
  });
  const lastName = p.lastName
    .split('')
    .reverse()
    .join('');
  const firstName = p.firstName
    .split('')
    .reverse()
    .join('');
  return `${firstName} ${lastName}`;
}

const personInfo = { firstName: 'shannan', lastName: 'xian' };

async function run() {
  const newName = await reverseName(personInfo);
  console.log(newName);
}

run();
personInfo.firstName = 'test';
// 輸出爲 tset naix,由於異步操做的中途 firstName 被改變了

修改爲下面的方式就能夠確保 personInfo 中途的修改不影響異步操做:

// 這個纔是純函數
async function reverseName(p) {
  // 淺層拷貝,這個對象並不複雜
  const newP = { ...p };
  await new Promise(resolve => {
    setTimeout(() => {
      resolve();
    }, 1000);
  });
  const lastName = newP.lastName
    .split('')
    .reverse()
    .join('');
  const firstName = newP.firstName
    .split('')
    .reverse()
    .join('');
  return `${firstName} ${lastName}`;
}

const personInfo = { firstName: 'shannan', lastName: 'xian' };

// run 不是純函數
async function run() {
  const newName = await reverseName(personInfo);
  console.log(newName);
}

// 固然小先運行 run,而後再去改 personInfo 對象。
run();
personInfo.firstName = 'test';
// 輸出爲 nannahs naix

這個仍是有個缺點,就是外部 personInfo 對象仍是會被改到,但不影響以前已經運行的 run 函數。若是再次運行 run 函數,輸入都變了,輸出固然也變了。

參數和返回值能夠是任意類型

那麼返回函數也是能夠的。

function addX(y) {
  return function(x) {
    return x + y;
  };
}

儘可能只作一件事

固然這個要看實際應用場景,這裏舉個簡單例子。

兩件事一塊兒作(不太好的作法):

function getFilteredTasks(tasks) {
  let filteredTasks = [];
  for (let i = 0; i < tasks.length; i++) {
    let task = tasks[i];
    if (task.type === 'RE' && !task.completed) {
      filteredTasks.push({ ...task, userName: task.user.name });
    }
  }
  return filteredTasks;
}
const filteredTasks = getFilteredTasks(tasks);

getFilteredTasks 也是純函數,可是下面的純函數更好。

兩件事分開作(推薦的作法):

function isPriorityTask(task) {
  return task.type === 'RE' && !task.completed;
}
function toTaskView(task) {
  return { ...task, userName: task.user.name };
}
let filteredTasks = tasks.filter(isPriorityTask).map(toTaskView);

isPriorityTasktoTaskView 就是純函數,並且都只作了一件事,也能夠單獨反覆使用。

結果可緩存

根據純函數的定義,只要輸入肯定,那麼輸出結果就必定肯定。咱們就能夠針對純函數返回結果進行緩存(緩存代理設計模式)。

const personInfo = { firstName: 'shannan', lastName: 'xian' };

function reverseName(firstName, lastName) {
  const newLastName = lastName
    .split('')
    .reverse()
    .join('');
  const newFirstName = firstName
    .split('')
    .reverse()
    .join('');
  console.log('在 proxyReverseName 中,相同的輸入,我只運行了一次');
  return `${newFirstName} ${newLastName}`;
}

const proxyReverseName = (function() {
  const cache = {};
  return (firstName, lastName) => {
    const name = firstName + lastName;
    if (!cache[name]) {
      cache[name] = reverseName(firstName, lastName);
    }
    return cache[name];
  };
})();

函數式編程有什麼優勢?

實施函數式編程的思想,咱們應該儘可能讓咱們的函數有如下的優勢:

  • 更容易理解
  • 更容易重複使用
  • 更容易測試
  • 更容易維護
  • 更容易重構
  • 更容易優化
  • 更容易推理

函數式編程有什麼缺點?

  • 性能可能相對來講較差

    函數式編程可能會犧牲時間複雜度來換取了可讀性和維護性。可是呢,這個對用戶來講這個性能十分微小,有些場景甚至可忽略不計。前端通常場景不存在很是大的數據量計算,因此你儘可放心的使用函數式編程。看下上面提到個的例子(數據量要稍微大一點纔好對比):

    首先咱們先賦值 10 萬條數據:

    const tasks = [];
    for (let i = 0; i < 100000; i++) {
      tasks.push({
        user: {
          name: 'one',
        },
        type: 'RE',
      });
      tasks.push({
        user: {
          name: 'two',
        },
        type: '',
      });
    }

    兩件事一塊兒作,代碼可讀性不夠好,理論上時間複雜度爲 o(n),不考慮 push 的複雜度

    (function() {
      function getFilteredTasks(tasks) {
        let filteredTasks = [];
        for (let i = 0; i < tasks.length; i++) {
          let task = tasks[i];
          if (task.type === 'RE' && !task.completed) {
            filteredTasks.push({ ...task, userName: task.user.name });
          }
        }
        return filteredTasks;
      }
    
      const timeConsumings = [];
    
      for (let k = 0; k < 100; k++) {
        const beginTime = +new Date();
        getFilteredTasks(tasks);
        const endTime = +new Date();
    
        timeConsumings.push(endTime - beginTime);
      }
    
      const averageTimeConsuming =
        timeConsumings.reduce((all, current) => {
          return all + current;
        }) / timeConsumings.length;
    
      console.log(`第一種風格平均耗時:${averageTimeConsuming} 毫秒`);
    })();

    兩件事分開作,代碼可讀性相對好,理論上時間複雜度接近 o(2n)

    (function() {
      function isPriorityTask(task) {
        return task.type === 'RE' && !task.completed;
      }
      function toTaskView(task) {
        return { ...task, userName: task.user.name };
      }
    
      const timeConsumings = [];
    
      for (let k = 0; k < 100; k++) {
        const beginTime = +new Date();
        tasks.filter(isPriorityTask).map(toTaskView);
        const endTime = +new Date();
    
        timeConsumings.push(endTime - beginTime);
      }
    
      const averageTimeConsuming =
        timeConsumings.reduce((all, current) => {
          return all + current;
        }) / timeConsumings.length;
    
      console.log(`第二種風格平均耗時:${averageTimeConsuming} 毫秒`);
    })();

    上面的例子屢次運行得出耗時平均值,在數據較少和較多的狀況下,發現二者平均值並無多大差異。10 萬條數據,運行 100 次取耗時平均值,第二種風格平均多耗時 15 毫秒左右,至關於 10 萬條數據多耗時 1.5 秒,1 萬條數多據耗時 150 毫秒(150 毫秒用戶基本感知不到)。

    雖然理論上時間複雜度多了一倍,可是在數據不龐大的狀況下(會有個臨界線的),這個性能相差其實並不大,徹底能夠犧牲瀏覽器用戶的這點性能換取可讀和可維護性。

  • 極可能被過分使用

    過分使用反而是項目維護性變差。有些人可能寫着寫着,就變成別人看不懂的代碼,本身以爲挺高大上的,可是你肯定別人能快速的看懂不? 適當的使用纔是合理的。

應用場景

概念是概念,實際應用倒是五花八門,沒有實際應用,記住了也是死記硬背。這裏總結一些經常使用的函數式編程應用場景。

簡單使用

有時候不少人都用到了函數式的編程思想(最簡單的用法),可是沒有意識到而已。下面的列子就是最簡單的應用,這個不用怎麼說明,根據上面的純函數特色,都應該看的明白。

function sum(a, b) {
  return a + b;
}

當即執行的匿名函數

匿名函數常常用於隔離內外部變量(變量不可變)。

const personInfo = { firstName: 'shannan', lastName: 'xian' };

function reverseName(firstName, lastName) {
  const newLastName = lastName
    .split('')
    .reverse()
    .join('');
  const newFirstName = firstName
    .split('')
    .reverse()
    .join('');
  console.log('在 proxyReverseName 中,相同的輸入,我只運行了一次');
  return `${newFirstName} ${newLastName}`;
}

// 匿名函數
const proxyReverseName = (function() {
  const cache = {};
  return (firstName, lastName) => {
    const name = firstName + lastName;
    if (!cache[name]) {
      cache[name] = reverseName(firstName, lastName);
    }
    return cache[name];
  };
})();

JavaScript 的一些 API

如數組的 forEach、map、reduce、filter 等函數的思想就是函數式編程思想(返回新數組),咱們並不須要使用 for 來處理。

const arr = [1, 2, '', false];
const newArr = arr.filter(Boolean);
// 至關於 const newArr = arr.filter(value => Boolean(value))

遞歸

遞歸也是一直經常使用的編程方式,能夠代替 while 來處理一些邏輯,這樣的可讀性和上手度都比 while 簡單。

以下二叉樹全部節點求和例子:

const tree = {
  value: 0,
  left: {
    value: 1,
    left: {
      value: 3,
    },
  },
  right: {
    value: 2,
    right: {
      value: 4,
    },
  },
};

while 的計算方式:

function sum(tree) {
  let sumValue = 0;
  // 使用列隊方式處理,使用棧也能夠,處理順序不同
  const stack = [tree];

  while (stack.length !== 0) {
    const currentTree = stack.shift();
    sumValue += currentTree.value;

    if (currentTree.left) {
      stack.push(currentTree.left);
    }

    if (currentTree.right) {
      stack.push(currentTree.right);
    }
  }

  return sumValue;
}

遞歸的計算方式:

function sum(tree) {
  let sumValue = 0;

  if (tree && tree.value !== undefined) {
    sumValue += tree.value;

    if (tree.left) {
      sumValue += sum(tree.left);
    }
    if (tree.right) {
      sumValue += sum(tree.right);
    }
  }

  return sumValue;
}

遞歸會比 while 代碼量少,並且可讀性更好,更容易理解。

鏈式編程

若是接觸過 jquery,咱們最熟悉的莫過於 jq 的鏈式便利了。如今 ES6 的數組操做也支持鏈式操做:

const arr = [1, 2, '', false];
const newArr = arr.filter(Boolean).map(String);
// 輸出 "1", "2"]

或者咱們自定義鏈式,加減乘除的鏈式運算:

function createOperation() {
  let theLastValue = 0;
  const plusTwoArguments = (a, b) => a + b;
  const multiplyTwoArguments = (a, b) => a * b;

  return {
    plus(...args) {
      theLastValue += args.reduce(plusTwoArguments);
      return this;
    },
    subtract(...args) {
      theLastValue -= args.reduce(plusTwoArguments);
      return this;
    },
    multiply(...args) {
      theLastValue *= args.reduce(multiplyTwoArguments);
      return this;
    },
    divide(...args) {
      theLastValue /= args.reduce(multiplyTwoArguments);
      return this;
    },
    valueOf() {
      const returnValue = theLastValue;
      // 獲取值的時候須要重置
      theLastValue = 0;
      return returnValue;
    },
  };
}
const operaton = createOperation();
const result = operation
  .plus(1, 2, 3)
  .subtract(1, 3)
  .multiply(1, 2, 10)
  .divide(10, 5)
  .valueOf();
console.log(result);

固然上面的例子不徹底都是函數式編程,由於 valueOf 的返回值就不肯定。

高階函數

高階函數(Higher Order Function),按照維基百科上面的定義,至少知足下列一個條件的函數

  • 函數做爲參數傳入
  • 返回值爲一個函數

簡單的例子:

function add(a, b, fn) {
  return fn(a) + fn(b);
}
function fn(a) {
  return a * a;
}
add(2, 3, fn); // 13

還有一些咱們平時經常使用高階的方法,如 map、reduce、filter、sort,以及如今經常使用的 redux 中的 connect 等高階組件也是高階函數。

柯里化(閉包)

柯里化(Currying),又稱部分求值(Partial Evaluation),是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,而且返回接受餘下的參數並且返回結果的新函數的技術。

柯里化的做用如下優勢:

  • 參數複用
  • 提早返回
  • 延遲計算/運行
  • 緩存計算值

柯里化實質就是閉包。其實上面的當即執行匿名函數的例子就用到了柯里化。

// 柯里化以前
function add(x, y) {
  return x + y;
}

add(1, 2); // 3

// 柯里化以後
function addX(y) {
  return function(x) {
    return x + y;
  };
}

addX(2)(1); // 3

高階組件

這是組件化流行後的一個新概念,目前常常用到。ES6 語法中 class 只是個語法糖,實際上仍是函數。

一個簡單例子:

class ComponentOne extends React.Component {
  render() {
    return <h1>title</h1>;
  }
}

function HocComponent(Component) {
  Component.shouldComponentUpdate = function(nextProps, nextState) {
    if (this.props.id === nextProps.id) {
      return false;
    }
    return true;
  };
  return Component;
}

export default HocComponent(ComponentOne);

深刻理解高階組件請看這裏。

無參數風格(Point-free)

其實上面的一些例子已經使用了無參數風格。無參數風格不是沒參數,只是省略了多餘參數的那一步。看下面的一些例子就很容易理解了。

範例一:

const arr = [1, 2, '', false];
const newArr = arr.filter(Boolean).map(String);
// 有參數的用法以下:
// arr.filter(value => Boolean(value)).map(value => String(value));

範例二:

const tasks = [];
for (let i = 0; i < 1000; i++) {
  tasks.push({
    user: {
      name: 'one',
    },
    type: 'RE',
  });
  tasks.push({
    user: {
      name: 'two',
    },
    type: '',
  });
}
function isPriorityTask(task) {
  return task.type === 'RE' && !task.completed;
}
function toTaskView(task) {
  return { ...task, userName: task.user.name };
}
tasks.filter(isPriorityTask).map(toTaskView);

範例三:

// 好比,現成的函數以下:
var toUpperCase = function(str) {
  return str.toUpperCase();
};
var split = function(str) {
  return str.split('');
};
var reverse = function(arr) {
  return arr.reverse();
};
var join = function(arr) {
  return arr.join('');
};

// 現要由現成的函數定義一個 point-free 函數toUpperCaseAndReverse
var toUpperCaseAndReverse = _.flowRight(
  join,
  reverse,
  split,
  toUpperCase
); // 自右向左流動執行
// toUpperCaseAndReverse是一個point-free函數,它定義時並沒有可識別參數。只是在其子函數中操縱參數。flowRight 是引入了 lodash 庫的組合函數,至關於 compose 組合函數
console.log(toUpperCaseAndReverse('abcd')); // => DCBA

無參數風格優勢?

參風格的好處就是不須要費心思去給它的參數進行命名,把一些現成的函數按需組合起來使用。更容易理解、代碼簡小,同時分離的回調函數,是能夠複用的。若是使用了原生 js 如數組,還能夠利用 Boolean 等構造函數的便捷性進行一些過濾操做。

無參數風格缺點?

缺點就是須要熟悉無參數風格,剛接觸不可能就能夠用得駕輕就熟的。對於一些新手,可能第一時間理解起來沒那沒快。

參考文章

相關文章
相關標籤/搜索