精读《函数式编程》小册(下)

Produce 工作原理:将拷贝操作精准化

immer 使用很傻瓜,仅仅是在项目里轻轻地 Import 一个 produce:

javascript 复制代码
import produce from "immer"

// 这是我的源数据
const baseState = [
    {
        name: "云牧",
        age: 99
    },
    {
        name: "秀妍",
        age: 100
    }
]

// 定义数据的写逻辑
const recipe = draft => {
    draft.push({name: "yunmu", age: 101})
    draft[1].age = 102
}

// 借助 produce,执行数据的写逻辑
const nextState = produce(baseState, recipe)
  • produce:入口函数,它负责把上述要素串起来。
  • (base)state:源数据,是我们想要修改的目标数据。
  • recipe:一个函数,我们可以在其中描述数据的写逻辑
    • draft:recipe 函数的默认入参,它是对源数据的代理,我们可以把想要应用在源数据的变更应用在 draft 上。

Immer.js 是如何工作的

Immer.js 使用 Proxy,对目标对象的行为进行"元编程"。

所谓"元编程",指的是对编程语言进行再定义。

借助 Proxy,我们可以给目标对象创建一个代理(拦截)层、拦截原生对象的某些默认行为,进而实现对目标行为的自定义。

Produce 关键逻辑抽象

Immer.js 的一切奥秘都蕴含在 produce 里,包括其对 Proxy 的运用。

这里我们只关注 produce 函数的核心逻辑,我将其提取为如下的极简版本:

javascript 复制代码
function produce(base, recipe) {
  // 预定义一个 copy 副本
  let copy
  // 定义 base 对象的 proxy handler
  const baseHandler = {
    set(obj, key, value) {
      // 先检查 copy 是否存在,如果不存在,创建 copy
      if (!copy) {
        copy = { ...base }
      }
      // 如果 copy 存在,修改 copy,而不是 base
      copy[key] = value
      return true
    }
  }

  // 被 proxy 包装后的 base 记为 draft
  const draft = new Proxy(base, baseHandler)
  // 将 draft 作为入参传入 recipe
  recipe(draft)
  // 返回一个被"冻结"的 copy,如果 copy 不存在,表示没有执行写操作,返回 base 即可
  // "冻结"是为了避免意外的修改发生,进一步保证数据的纯度
  return Object.freeze(copy || base)
}

对这个超简易版的 producer 进行一系列的调用:

javascript 复制代码
// 这是我的源对象
const baseObj = {
	a: 1,
	b: {
		name: '云牧',
	},
}

// 这是一个执行写操作的 recipe
const changeA = draft => {
	draft.a = 2
}

// 这是一个不执行写操作、只执行读操作的 recipe
const doNothing = draft => {
	console.log('doNothing function is called, and draft is', draft)
}

// 借助 produce,对源对象应用写操作,修改源对象里的 a 属性
const changedObjA = produce(baseObj, changeA)

// 借助 produce,对源对象应用读操作
const doNothingObj = produce(baseObj, doNothing)

// 顺序输出3个对象,确认写操作确实生效了
console.log(baseObj)
console.log(changedObjA)
console.log(doNothingObj)

// 【源对象】 和 【借助 produce 对源对象执行过读操作后的对象】 还是同一个对象吗?
// 答案为 true
console.log(baseObj === doNothingObj)
// 【源对象】 和 【借助 produce 对源对象执行过写操作后的对象】 还是同一个对象吗?
// 答案为 false
console.log(baseObj === changedObjA)
// 源对象里没有被执行写操作的 b 属性,在 produce 执行前后是否会发生变化?
// 输出为 true,说明不会发生变化
console.log(baseObj.b === changedObjA.b)

执行结果:

只要写操作没执行,拷贝动作就不会发生

只有当写操作确实执行,也就是当我们试图修改 baseObj 的 a 属性时,produce 才会去执行拷贝动作。

逐层的浅拷贝,则间接地实现了数据在新老对象间的共享。

拓展:"知其所止"的"逐层拷贝"

完整版 produce 的浅拷贝其实是可递归 的。

在本文的案例中,baseObj 是一个嵌套对象,一共有两层:

如果我对 b 属性执行了写操作:

javascript 复制代码
import produce from "immer";

// 这是一个执行引用类型写操作的 recipe
const changeB = (draft) => {
  draft.b.name = " 修个锤子"
}

// 借助 produce 调用 changeB
const changedObjB = produce(baseObj, changeB)
// 【源对象】 和 【借助 produce 对源对象执行过写操作后的对象】 还是同一个对象吗?
// 答案为 false
console.log(baseObj === changedObjB)
// 【b 属性】 和 【借助 produce 修改过的 b 属性】 还是同一个对象吗?
// 答案为 false
console.log(baseObj.b === changedObjB.b)

对于嵌套的对象来说,数据内容的变化和引用的变化也能同步发生

当写操作发生时,setter 方法就会被逐层触发,呈现"逐层浅拷贝"的效果。这是 Immer 实现数据共享的关键。

在 JS 中,基于 reduce(),我们不仅能够推导出其它数组方法,更能够推导出经典的函数组合过程。

Reduce 工作流分析

来看看 reduce 的工作流特征:

javascript 复制代码
const arr = [1, 2, 3]

// 0 + 1 + 2 + 3 
const initialValue = 0  
const add = (previousValue, currentValue) => previousValue + currentValue
const sumArr = arr.reduce(
  add,
  initialValue
)

console.log(sumArr)
// expected output: 6
  1. 执行回调函数 add(),入参为(initialValue, arr[0])。这一步的计算结果为记为 sum0,sum0=intialValue + arr[0],此时剩余待遍历的数组内容为 [2, 3],待遍历元素2个。
  2. 执行回调函数 add(),入参为 (sum0, arr[1])。这一步的计算结果记为 sum1,sum1 = sum0 + arr[1],此时剩余待遍历的数组内容为 [3] ,待遍历元素1个。
  3. 执行回调函数 add(),入参为 (sum1, arr[2]),这一步的计算结果记为 sum2,sum2 = sum1 + arr[2],此时数组中剩余待遍历的元素是 [],遍历结束。
  4. 输出 sum2 作为 reduce() 的执行结果, sum2 是一个单一的值。

上面代码的过程本质上是一个循环调用 add() 函数的过程,上一次 add() 函数调用的输出,会成为下一次 add() 函数调用的输入:

用 reduce() 推导 map()

map:

javascript 复制代码
[1,2,3]map((num)=> num + 1)

用 reduce() 推导 map():

javascript 复制代码
function add1AndPush(previousValue, currentValue) {
  // previousValue 是一个数组
  previousValue.push(currentValue + 1)
  return previousValue
}

用 reduce() 去调用这个 add1AndPush():

javascript 复制代码
const arr = [1, 2, 3]
const newArray = arr.reduce(add1AndPush, [])

这段代码的工作内容和楼上我们刚分析过的 map() 是等价的。

reduce(add1AndPush, [])这个过程对应的函数调用链,它长这样:

Map 和 Reduce 之间的逻辑关系

map() 的过程本质上也是一个 reduce() 的过程。

reduce() 本体的回调函数入参可以是任何值,出参也可以是任何值。

而 map 则是一个相对特殊的 reduce() ,它锁定了一个数组作为每次回调的第一个入参,并且限定了 reduce() 的返回结果只能是数组。

reduce() 映射了函数组合思想

通过观察这个工作流,我们可以发现这样两个特征:

  • reduce() 的回调函数在做参数组合
  • reduce() 过程构建了一个函数 pipeline

reduce() 的回调函数在做参数组合

就 reduce() 过程中的单个步骤来说,每一次回调执行,是把 2 个入参被【组合 】进了 callback 函数里,最后转化出 1 个出参的过程。

educe 方法把多个入参,reduce(减少)为一个出参 。

reduce() 过程是一个函数 pipeline

每次调用的都是同一个函数,但上一个函数的输出,总是会成为下一个函数的输入。

但上面 reduce() pipeline 里的每一个任务都是一样的,都是 add(),仅仅是入参不同:

reduce()既然都能组合参数了,那能不能组合函数呢,当然可以,后文再说。

链式调用

javascript 复制代码
// 用于筛选大于2的数组元素
const biggerThan2 = num => num > 2  
// 用于做乘以2计算
const multi2 = num => num * 2    
// 用于求和
const add = (a, b) => a + b   

// 完成步骤 1
const filteredArr = arr.filter(biggerThan2)    
// 完成步骤 2
const multipledArr = filteredArr.map(multi2)    
// 完成步骤 3
const sum = multipledArr.reduce(add, 0)

上面代码如果我只需要 sum 的结果,那 filteredArr 和 multipledArr 就是没有必要的中间变量。

我们可以借助链式调用构建声明式的数据流:

javascript 复制代码
const sum = arr.filter(biggerThan2).map(multi2).reduce(add, 0)

链式调用的本质 ,是通过在方法中返回对象实例本身的 this/ 与实例 this 相同类型的对象,达到多次调用其原型(链)上方法的目的。

函数组合但回调地狱

这里我直接定义几个极简的独立函数,代码如下:

javascript 复制代码
function add4(num) {
	return num + 4
}

function multiply3(num) {
	return num * 3
}

function divide2(num) {
	return num / 2
}

我们组合一下:

javascript 复制代码
const sum =  add4(multiply3(divide2(num)))

已经开始套娃执行了,我们不断把内部函数的执行结果套进下一个外部函数里作为入参。

如何摆脱回调地狱?

可以使用链式调用。

借助 reduce 推导函数组合

我们把待组合的函数放进一个数组里,然后调用这个函数数组的 reduce 方法 ,就可以创建一个或多个函数组成的工作流。

而这,正是市面上主流的函数式库实现 compose/pipe 函数的思路。

借助 reduce 推导 pipe

顺着这个思路,我们来考虑这样一个函数数组:

javascript 复制代码
const funcs = [func1, func2, func3]

我想要逐步地组合调用 funcs 数组里的函数,得到一个这样的声明式数据流:

如果借助 reduce ,数据流向好像有所区别;

如何对齐?首先是入参的对齐,我们只需要把 initialValue 设定为 0 就可以了:

javascript 复制代码
const funcs = [func1, func2, func3]  

funcs.reduce(callback, 0)

后续我们应该让 callback(0, func1) = func1(0)。

callback(value1, func2) = func2(value1)。

callback(value2, func3) = func3(value2)。

callback(input, func) = func(input)。

推导至此,我们就得到了 callback 的实现:

javascript 复制代码
function callback(input, func) {
  func(input)
}

funcs.reduce(callback, 0)

封装下:

javascript 复制代码
// 使用展开符来获取数组格式的 pipe 参数
function pipe(...funcs) {
	function callback(input, func) {
		return func(input)
	}

	return function (param) {
		return funcs.reduce(callback, param)
	}
}

验证 pipe:串联数字计算逻辑

javascript 复制代码
function add4(num) {
	return num + 4
}

function multiply3(num) {
	return num * 3
}

function divide2(num) {
	return num / 2
}

之前我们使用回调形式,如今我们可以使用 pipe:

javascript 复制代码
const compute = pipe(add4, multiply3, divide2)

compute 的函数就是 add4, multiply3, divide2 这三个函数的"合体"版本。

javascript 复制代码
// 输出 21
console.log(compute(10))

compose:倒序的 pipe

pipe 用于创建一个正序的函数传送带,而 compose 则用于创建一个倒序的函数传送带。

我们只需把上面函数里的 reduce 改成 reduceRight:

javascript 复制代码
// 使用展开符来获取数组格式的 pipe 参数
function compose(...funcs) {
	function callback(input, func) {
		return func(input)
	}

	return function (param) {
		return funcs.reduceRight(callback, param)
	}
}

我们需要把入参倒序传递,如下:

javascript 复制代码
const compute = compose(divide2, multiply3, add4)

面向对象的核心在于继承,而函数式编程的核心则在于组合

React 设计和实践中很大程度上受到了函数式思想的影响。

宏观设计:数据驱动视图

React 的核心特征是"数据驱动视图 "

这个表达式有很多的版本,一些版本会把入参里的 data 替换成 state。

本质都是说 React 的视图会随着数据的变化而变化

React 组件渲染的逻辑分层

写一个 React 组件:

javascript 复制代码
const App = () => {
  const [num, setNum] = useState(1)  

  return <span>{num}</span>
}

上述代码最终转换成了 React.createElement 调用产生虚拟 DOM**。**虚拟 DOM 仅仅是对真实 DOM 的一层描述而已。

要想把虚拟 DOM 转换为真实 DOM,我们需要调用的是 ReactDOM.render() 这个 API :

javascript 复制代码
// 首先你的 HTML 里需要有一个 id 为 root 的元素
const rootElement = document.getElementById("root")

// 这个 API 调用会把 <App/> 组件挂载到 root 元素里
ReactDOM.render(<App />, rootElement)

在 React 组件的初始化渲染过程中,有以下两个关键的步骤:

  • 结合 state 的初始值,计算 <App /> 组件对应的虚拟 DOM
  • 将虚拟 DOM 转化为真实 DOM

React 组件的更新过程,同样也是分两步走:

  • 结合 state 的变化情况,计算出虚拟 DOM 的变化
  • 将虚拟 DOM 转化为真实 DOM
  • 计算层:负责根据 state 的变化计算出虚拟 DOM 信息。这是一层较纯的计算逻辑。
  • 副作用层:根据计算层的结果,将变化应用到真实 DOM 上。这是一层绝对不纯的副作用逻辑。

在 UI = f(data) 这个公式中,数据是自变量,视图是因变量。

组件 作为 React 的核心工作单元,其作用正是描述数据和视图之间的关系。是公式中的 f() 函数。

组件设计:组件即函数

定义一个 React 组件,其实就是定义一个吃进 props、吐出 UI(其实是对 UI 的描述)

javascript 复制代码
function App(props) {
  return <h1>{props.name}</h1>
}

如果这个组件需要维护自身的状态、或者实现副作用等等,只需要按需引入不同的 Hooks:

javascript 复制代码
function App(props) {
  const [age, setAge] = useState(1)
  
  return (
    <>
      <h1> {props.name} age is {age}</h1>
      <button onClick={() => setAge(age+1)}>add age</button>
    </>
  );
}

如何函数式地抽象组件逻辑

直到 React-Hooks 的出现,才允许函数组件"拥有了自己的状态":

javascript 复制代码
function App(props) {
  const [age, setAge] = useState(1)
  return (
    <>
      <h1> {props.name} age is {age}</h1>
      <button onClick={() => setAge(age+1)}>add age</button>
    </>
  );
}

上述代码,即便输入相同的 props,也不一定能得到相同的输出。这是因为函数内部还有另一个变量 state。
真的没那么纯了吗?

useState() 的状态管理逻辑其实是在 App() 函数之外。

因为函数执行是一次性的。如果 useState() 是在 App() 函数内部维护组件状态,状态必然不会得到保持。

现实是,不管 App 组件渲染多少次,useState()总是能"记住"组件最新的状态。

对于函数组件来说,state 本质上也是一种外部数据 。函数组件能够消费 state,却并不真正拥有 state

相当于把函数包裹在了一个具备 state 能力的"壳子"里:

javascript 复制代码
function Wrapper({state, setState}) {
  return <App state={state} setState={setState}/>
}

所以 state 本质上和 props、context 等数据一样,都可以视作是 App 组件的 "外部数据",也即 App() 函数的"入参"

我们用 FunctionComponent 表示任意一个函数组件,函数组件与数据、UI 的关系可以概括如下:

javascript 复制代码
UI = FunctionComponent(props, context, state)

对于同样的入参(也即固定的 props context state ),函数组件总是能给到相同的输出。因此,函数组件仍然可以被视作是一个"纯函数"。
所以 Hook 对函数能力的拓展,并不影响函数本身的性质。函数组件始终都是从数据到 UI 的映射,是一层很纯的东西

而以 useEffect、useState 为代表的 Hooks,则负责消化那些不纯的逻辑。比如状态的变化,比如网络请求、DOM 操作等副作用。

设计一个函数组件,我们关注点则就可以简化为"哪些逻辑可以被抽象为纯函数,哪些逻辑可以被抽象为副作用 "
React 背靠函数式思想,重构了组件的抽象方式,为我们创造了一种更加声明式的研发体验。

如何函数式地实现代码重用

**组合大于继承的思想下,**经典的 React 设计模式包括但不限于:

  • 高阶组件
  • render props
  • 容器组件/展示组件

"设计模式"对于 React 来说,并不是一个必选项,而更像是一个"补丁"

高阶组件(HOC)的函数式本质

高阶组件是参数为组件,返回值为新组件的函数。
让人不免联想到高阶函数,指的就是接收函数作为入参,或者将函数作为出参返回的函数。

高阶函数的主要效用在于代码重用 ,高阶组件也是如此。
当我们使用函数组件构建应用程序时,高阶组件就是高阶函数。

当我们需要同时用到多个高阶组件时,直接使用 compose 组合这些高阶组件:

jsx 复制代码
// 定义一个 NetWorkComponent,组合多个高阶组件的能力
const NetWorkComponent = compose(
  // 高阶组件 withError,用来提示错误
  withError,
  // 高阶组件 withLoading,用来提示 loading 态
  withLoading,
  // 高阶组件 withFetch,用来调后端接口
  withFetch
)(Component)

const App = () => {
  ...

  return (
    <NetWorkComponent
      // params 入参交给 withFetch 消化
      url={url}
      // error 入参交给 withError 消化
      error={error}  
      // isLoading 入参交给 withLoading 消化
      isLoading={isLoading}
    />
  )
}

高阶组件本质上也是函数。所以无论组件的载体是类还是函数,React 的代码重用思路总是函数式的。

高阶组件(HOC)的局限性

看看下面这个高阶组件,它被用来进行条件渲染:

jsx 复制代码
import React from 'react'

const withError = (Component) => (props) => {
  if (props.error) {
    return <div>Here is an Error ...</div>
  }

  return <Component {...props} />
}

export default withError

如果有一个错误,它就渲染一个错误信息。如果没有错误,它将渲染给定的组件。

尽管 hook 很多场景已经可以取代高阶组件,但在"条件渲染"这个场景下,使用高阶组件仍然不失为一个最恰当的选择。

毕竟,Hooks 能够 return 数据,却不能够 return 组件。

我们探讨其实并不是类似"条件渲染"这种场景,而是对【状态相关的逻辑】 的重用。

比如下面这个高阶组件:

jsx 复制代码
import React from "react"

// 创建一个 HOC, 用于处理网络请求任务
const withFetch = (Component) => {
    
  // 注意,处理类组件时,HOC 每次都会返回一个新的类
  class WithFetch extends React.Component {
    constructor(props) {
      super(props)
      this.state = {
        data: {},
      }
    }

    componentDidMount() {
      // 根据指定的 url 获取接口内容
      fetch(this.props.url)
        .then((response) => response.json())
        .then((data) => {
          this.setState({ data })
        })
    }

    render() {
      // 将目标数据【注入】给 Component 组件
      return (
        <>
          <Component {...this.props} data={this.state.data} />
        </>
      )
    } 
  }
  
  // 这行完全是 debug 用的,由于高阶组件每次都会返回新的类,我们需要 displayName 帮我们记住被包装的目标组件是谁
  WithFetch.displayName = `WithFetch(${Component.name})`
  
  // 返回一个被包装过的、全新的类组件
  return WithFetch
};

export default withFetch

这个组件主要做了这么几件事:

  • 它根据 this.props 中指定的 url,请求一个后端接口
  • 将获取到的数据 (data) 以 state 的形式维护在高阶组件的内部
  • 将高阶组件内部的 state.data 以 props 的形式传递给目标组件 Component

用一句话来概括这个过程:高阶组件把状态【注入】到了目标 Component 里。

使用的时候:

jsx 复制代码
const FetchCommentComponent = withFetch(Component)

随着应用复杂度的提升,上面 HOC 的局限性也会跟着显现。

可读性问题

Component 仅仅具备"获取数据"这一个能力是不够的,产品经理希望你为它增加以下功能:

  1. 在请求发起前后,处理对应的 Loading 态(对应 HOC withLoading)
  2. 在请求失败的情况下,处理对应的 Error 态(对应 HOC withError)

在 compose的加持下,我们可以快速写出如下代码:

jsx 复制代码
// 定义一个 NetWorkComponent,组合多个高阶组件的能力
const NetWorkComponent = compose(
  // 高阶组件 withError,用来提示错误
  withError,
  // 高阶组件 withLoading,用来提示 loading 态
  withLoading,
  // 高阶组件 withFetch,用来调后端接口
  withFetch
)(Component)

const App = () => {
  // 省略前置逻辑...

  return (
    <NetWorkComponent
      // url 入参交给 withFetch 消化
      url={url}
      // error 入参交给 withError 消化
      error={error}  
      // isLoading 入参交给 withLoading 消化
      isLoading={isLoading}
    />
  )
}

**上面代码很理想,**参数名和 HOC 名严格对照,我们可以轻松地推导 props 和 HOC 之间的关系。

但很多时候,我们见到的代码是这样的:

jsx 复制代码
// 定义一个 NetWorkComponent,组合多个高阶组件的能力
const NetWorkComponent = compose(
  // 高阶组件 withError,用来提示错误
  withError,
  // 高阶组件 withLoading,用来提示 loading 态
  withLoading,
  // 高阶组件 withFetch,用来调后端接口
  withFetch
)(Component)

const App = () => {
  // 省略前置逻辑...

  return (
    <NetWorkComponent
      // url 入参交给 withFetch 消化
      url={url}
      // icon 入参是服务于谁的呢?
      icon={icon}
      // image 入参是服务于谁的呢?
      image={icon}
    />
  )
}

我们通过上面知道知道 url 是供 withFetch 消化的参数。

但是 icon参数和 image参数又是为谁服务的呢?是为另外两个 HOC 服务的,还是为 Component组件本身服务的?

所以 HOC 和被包装组件 Component 之间的关系是模糊的,而且被 HOC包装后,它会变成一个全新的组件,这就导致 HOC 层面的 bug 非常难以追溯,所以才会在每个 HOC 中标记 displayName。

命名冲突问题

这个问题就比较好理解了。假设我在同一个 Component 里,想要复用两次 withFetch,代码该怎么写呢?

jsx 复制代码
const FetchForTwice = compose(
  withFetch,
  withFetch,
)(Component)

const App = () => {
  ...

  const userId = '10086'

  return (
    <FetchForTwice
      // 这个 url 用于获取用户的个人信息
      url={`https://api.xxx/profile/${userId}`}
      // 这个 url 用于获取用户的钱包信息
      url={`https://api.xxx/wallet/${userId}`}
    />
  );
};

上面代码,当出现两个同名的 props 时,后面那个(钱包接口 url)会把前面那个(个人信息接口 url)覆盖掉。

FetchForTwice 确实能够 fetch 两次接口,但这两次 fetch 动作是重复的,每次都只会去 fetch 用户的钱包信息而已。

render props 的正反两面

render props 被认为是比 HOC 更加优雅的代码重用解法。

上面代码, 是通过 HOC 的 withFetch 简单改写出的 render props 版本。

上面的红色方框圈住的是"数据的准备工作 "(充满副作用),下面的红色方框圈住的则是"数据的渲染工作 "(纯函数)。

this.props.render() 可以是任意的一个函数组件:

jsx 复制代码
<FetchComponent
  render={(data) => <div>{data.length}</div>}
  />

也就是说,从 render props 这个模式开始,我们已经初步地在实践"pure/impure 分离"的函数式思想。

然而,render props 也存在问题,最典型的就是回调地狱。

但整体而言还是好用的。

【函数组件 + Hooks】实现代码重用

Hooks 是无法完全替代 HOC 和 render props 的,但大部分场景可替换。比如对【状态相关的逻辑】 的重用

以 HOC 话题下的 NetWorkComponent组件为例,使用 Hooks,我们可以将它重构成这样:

jsx 复制代码
const NewWorkComponent = () => {
  const { data, error, isLoading } = useFetch('xxx')  
  if(error) {
    // 处理错误态
  }
  if(isLoading) {
    // 处理 loading 态
  }
  return <Component data={data} />
}

由于不存在 props 覆盖的问题,对于需要分别调用两次接口的场景,只需要像这样调用两次 useFetch 就可以了:

jsx 复制代码
const NewWorkComponent = ({userId}) => {
  const {
    data: profileData, 
    error: profileError 
    isLoading: profileIsLoading
  } = useFetch('https://api.xxx/profile/${userId}')  
  const {
    data: walletData, 
    error: walletError 
    isLoading: walletIsLoading
  } = useFetch('https://api.xxx/wallet/${userId}')    

  // ...其它业务逻辑
    
  return <Component data={data} />
}

Hooks 能够帮我们在【数据的准备工作 】和【数据的渲染工作 】之间做一个更清晰的分离。

具体来说,在 render props 示例中,我们并不想关心组件之间如何嵌套,我们只关心我们在 render props 函数中会拿到什么样的值;

在 HOC 示例中,我们也并不想关心每个 HOC 是怎么实现的、每个组件参数和 HOC 的映射关系又是什么样的,我们只关心目标组件能不能拿到它想要的 props 。

但在【函数组件+Hooks】这种模式出现之前,尽管开发者"并不想关心",却"不得不关心"。因为这些模式都没有解决根本上的问题,那就是心智模型的问题。

为什么【函数组件+Hooks】是更优解?

HOC 虽然能够实现代码重用,但没有解决逻辑和视图耦合的问题。

render props 是有进步意义的,因为它以 render 函数为界,将整个组件划分为了两部分:

  • 数据的准备工作------也就是"逻辑"
  • 数据的渲染工作------也就是"视图"

其中,"视图"表现为一个纯函数组件,这个纯函数组件是高度独立的。

尽管"视图"是高度独立的,"逻辑"却仍然耦合在组件的上下文(this)里。这种程度的解耦是暧昧的、不彻底的。

【函数组件 + Hooks】模式的出现,恰好就打破了这种暧昧的状态。状态被视作函数的入参和出参,它可以脱离于 this 而存在,状态管理逻辑可以从组件实例上剥离、被提升为公共层的一个函数,由此便彻底地实现逻辑和视图的解耦。

拓展:关注点分离------容器组件与展示组件

容器组件与展示组件也是非常经典的设计模式。这个模式有很多的别名,比如:

  • 胖组件/瘦组件
  • 有状态组件/无状态组件
  • 聪明组件/傻瓜组件

这个模式的要义在于关注点分离,具体来说,先将组件逻辑分为两类:

  • 数据的准备工作------也就是"逻辑"
  • 数据的渲染工作------也就是"视图"

然后再把这两类逻辑分别塞进两类组件中:

  • 容器组件:负责做数据的准备和分发工作
  • 展示组件:负责做数据的渲染工作

这个模式强调的是容器组件和展示组件之间的父子关系:

  • 容器组件是父组件,它在完成数据的准备工作后,会通过 props 将数据分发给作为子组件的展示组件。

由此,我们就能够实现组件的关注点分离,使组件的功能更加内聚 ,实现逻辑与视图的分离(最终目标都是这)

我们探讨些主流的函数式状态管理解法,它们分别是:

  • React 状态管理中的"不可变数据"
  • Redux 设计&实践中的函数式要素
    • 纯函数
    • 不可变值
    • 高阶函数&柯里化
    • 函数组合
  • RxJS 对响应式编程与"盒子模式"的实践
    • 如何理解"响应式编程"
    • 如何把副作用放进"盒子"

React 状态管理中的"不可变数据"

"不可变值/不可变数据"是 React 强烈推荐开发者遵循的一个原则。

state props 数据一旦被创建,就不能被修改,只能通过创建新的数据来实现更新。

在 React 中,如果不遵循不可变数据的原则,可能会导致一些不可预期的问题:

jsx 复制代码
import React, { useState } from 'react'

function MutableComponent() {
  const [items, setItems] = useState(['apple', 'banana', 'orange'])

  const handleRemove = (index) => {
    // 直接修改了原数组,违背了不可变原则
    items.splice(index, 1) 
    setItems(items)
  }

  return (
    <ul>
      {items.map((item, index) => (
      <li key={index}>
        {item}
        <button onClick={() => handleRemove(index)}>remove</button>
      </li>
    ))}
    </ul>
  )
}

上述代码,React 预期我们针对新的状态创建一个全新的数组,以此来确保新老数据的不可变性。

但是我们没有这样做,所以这段代码会导致组件无法正常更新。还会导致性能问题,使用可变数据,React 就需要对每个可变数据进行深度比较。
为了避免这类问题出现,我们应该始终使用不可变数据。

实现不可变数据的思路有很多,比如先对原始数组做一次拷贝:

jsx 复制代码
import React, { useState } from 'react'

function ImmutableComponent() {
  const [items, setItems] = useState(['apple', 'banana', 'cherry'])

  const handleRemove = (index) => {
    // 基于原始数组,创建一个新数组
    const newItems = [...items]  
    newItems.splice(index, 1) 
    setItems(newItems)
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>
          {item}
          <button onClick={() => handleRemove(index)}>remove</button>
        </li>
      ))}
    </ul>
  )
}

**上述代码,在 **setState 前后,新老状态相互独立、各有各的引用,这就是 React 所期待的"状态不可变"
这只是表象,为什么数据就一定要不可变!

内核:"数据-视图"间高度确定的函数式映射关系

React 组件是一个吃进数据、吐出 UI 的【纯函数】。
纯函数 意味着确定性 ,意味着严格的一对一映射关系,意味着对于相同的数据输入,必须有相同的视图输出

在这个映射关系的支撑下,对于同一个函数(React 组件)、同一套入参(React 状态)来说,组件所计算出的视图内容必定是一致的。

也就是说,在数据没有发生变化的情况下,React 是有权不去做【重计算】的

这也是我们可以借助Pure Component 和 React.memo() 等技术缓存 React 组件的根本原因。

React 之所以以"不可变数据"作为状态更新的核心原则,根源就在于它的函数式内核 ,在于它追求的是数据(输入)和视图(输出)之间的高度确定的映射关系。

如果数据可变(注意,"可变"指的是引用不变,但数据内容变了),就会导致数据和 UI 之间的映射关系不确定,从而使得 React 无法确定"有没有必要进行重计算",最终导致渲染层面的异常。

也就是说,React 组件的纯函数特性和不可变数据原则是相互支持、相互依赖的

它们的本质目的都是为了确保 React 的渲染过程高度确定、高度可预测,从而提高应用的性能和可维护性。

Redux

和 React 一样,Redux 在前端社区中也扮演了一个推广函数式编程的重要角色。

来说说其中最核心的 5 个 buff:纯函数、不可变数据、高阶函数、柯里化和函数组合

纯函数

在 Redux 中,所有的状态变化都是由纯函数(称为 reducer)来处理的,这些函数接收当前的状态(state)和一个 action 对象,返回一个新的状态:

jsx 复制代码
// 定义初始状态
const initialState = {
  count: 0,
};

// 定义 reducer 函数,接收当前状态和动作对象,返回新状态
function counterReducer(state = initialState, action) {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 }
    case "decrement":
      return { ...state, count: state.count - 1 }
    default:
      return state;
  }
}

// 创建 store,将 reducer 函数传入
const store = Redux.createStore(counterReducer)


// 分发动作对象,触发状态变化
store.dispatch({ type: "increment" })
store.dispatch({ type: "decrement" })

上述代码,counterReducer 函数就是一个 reducer。它接收当前 state 和一个 action 对象作为入参,返回一个新的 state 作为计算结果

Redux 的设计原则要求整个 reducer 的函数体除了计算、啥也不干 ,因此 reducer 是标准的纯函数。

由于纯函数要求我们保持外部数据的不可变性,这里我们在更新 count 属性时,使用了扩展运算符来拷贝当前状态。

这就又引出了我们喜闻乐见的"不可变数据"原则。

由此可见,纯函数和不可变数据真的是一对好基友,它们总是相互支持、相互成就的。

不可变数据

Redux 的不可变数据原则体现在它的 state 数据结构上。

Redux 要求我们在修改 state 时使用不可变数据------也就是创建一个新的 state 对象,而不是在原有的 state 上进行修改。

高阶函数&柯里化

在 Redux 中,高阶函数的应用非常广泛。比如在 Redux 中,中间件是一个函数,它【嵌套地 】接收三个入参:store、next 和action。

其中,store 是 Redux 唯一的状态树,next 是一个函数,用于将当前 action 传递给下一个中间件或者传递给 reducer,而 action 则是当前需要处理的行为信息对象。

下面是一个简单的 Redux 中间件,用于在状态更新的前后输出两行 log:

jsx 复制代码
const loggerMiddleware = (store) => (next) => (action) => {
  console.log('dispatching the action:', action)
  const result = next(action)
  console.log('dispatched and new state is', store.getState())
  return result
}

const store = createStore(reducer, applyMiddleware(loggerMiddleware))

我们定义了一个名为 loggerMiddleware 的中间件。

它接收一个 store 对象,返回一个结果函数 A,A 函数接收一个 next 函数,返回一个新的结果函数 B。

这个结果函数 B 会接收一个 action 对象,最终执行完整个中间件逻辑,并返回执行结果。

loggerMiddleware 是柯里化函数,通过柯里化,Redux 中间件可以将参数相同的多次调用转化为单次调用,提高了代码复用性和可维护性,也为"延迟执行"。

函数组合

以下是 Redux 中组合不同中间件的示例代码:

jsx 复制代码
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import logger from 'redux-logger'

const middleware = [thunk, logger, errorReport]

const store = createStore(
  reducer,
  compose(
    applyMiddleware(...middleware),
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
  )
)

我们将 thunk、logger 和 errorReport 这三个中间件函数通过 compose 函数组合起来,并使用 applyMiddleware 函数将这些中间件函数应用到 Redux的 store 中。

RxJS 是一个在 JavaScript 中实现响应式编程的库。

它利用可观察序列(Observable )来表达异步数据流,并通过一系列的操作符(Operators )来对这些数据流进行转换、筛选和组合,最终实现业务逻辑。

下面使用 RxJS6 展示如何基于可观察序列来实现状态管理:

jsx 复制代码
import { fromEvent } from 'rxjs'
import { map, filter, debounceTime } from 'rxjs/operators'

// 获取输入框 DOM 
const searchInput = document.getElementById('search-input')

// fromEvent可以将一个 DOM 转换为一个可观察的对象(Observable)
const input$ = fromEvent(searchInput, 'input')
  .pipe(
    map(event => event.target.value),
    filter(value => value.length > 2),
    debounceTime(500)
  )

input$.subscribe(value => {
  console.log(`Performing search with query "${value}"...`)
  // 发起异步请求,并更新页面
})

变量名有 $ 是为了表明这是一个流对象,是一个约定。

上面的代码使用 RxJS 监听了一个输入框的值变化,当输入框的值变化后,我们借助 map 操作符将事件对象转换为输入框的值,然后通过 filter 操作符过滤掉长度小于等于 2 的输入,最后通过 debounceTime 操作符确保用户停止输入一段时间后才会发出值。

RxJS 中提供了强大的操作符工具函数 ,使得我们能够非常方便地对数据流进行处理和转换,从而实现响应式的状态管理。

在 RxJS 中,操作符(operators )是用来组合 Observable 的纯函数,用于对 Observable 进行各种转换、过滤、合并等操作。

RxJS 中提供了大量的操作符,例如 map、filter、mergeMap、switchMap 等等。上面我们用到的操作符有 map 和 filter。

而工具函数(utility functions)则是一些不依赖于 Observable 的纯函数,用于处理 Observable 发射出来的值。RxJS 中提供了大量的工具函数,例如 tap、delay、timeout 等等。上面,我们用到的工具函数是 fromEvent。

从函数式编程到响应式编程

函数式编程和响应式编程的差异是非常相似的范式。

都属于声明式编程 ,都遵循函数式编程的基本原则,但 关注点的不同:

函数式编程强调的是函数的组合和变换 ,通过将复杂的问题分解成小的函数,再将这些函数组合起来,达到解决问题的目的。函数式编程中,函数是"一等公民"。

响应式编程强调的是数据流的变化和响应 ,它将复杂的问题抽象成一个数据流,通过对数据流进行变换和响应,达到解决问题的目的。响应式编程中,函数仍然是"一等公民",但它更强调对"数据流"的关注。

一句话概括:函数式编程关注函数,响应式编程关注数据流

业内比较广泛的一种观点是:响应式编程是函数式编程的一种扩展和补充,它在函数式思想的基础上,更加强调对数据流的关注

RxJS 对"盒子模式"的运用

"盒子模式"生产实践:RxJS 中的 Monad 与 Functor

Functor 是指实现了 map 函数的盒子,而 Monad 则是指实现了 map 和 flatMap 函数的盒子。
在 RxJS 中, Observable 既是一个 Functor,也是一个 Monad。

使用 RxJS5 来写盒子模式的示例:

jsx 复制代码
import Rx from "rxjs"
import { Observable } from "rxjs/Observable"

// observable 是一个 Observable 类型的盒子,它既是 Functor 也是 Monad
const observable = Observable.from([1, 2, 3])

// observable 是一个 Functor,可以调用 Functor 的 map 方法
const mappedBox = observable.map((x) => x * 2)

// observable 是一个 Monad,可以调用 Monad 的 flatMap 方法,把嵌套的 Functor 拍平
const flattenObservable = observable.flatMap((x) => Observable.from([x, x * 2]))

// 可以通过订阅打印出盒子的内容
// 输出:1, 2, 3
observable.subscribe((val) => console.log(val))

// 输出:2, 4, 6
mappedBox.subscribe((val) => console.log(val))

// 输出:1, 2, 2, 4, 3, 6
flattenObservable.subscribe((val) => console.log(val))

我们使用 RxJS 的 from 操作符将一个数组转换成了一个 Observable 类型的盒子:observable。

由于 Observable 盒子实现了 map 函数,我们可以像使用数组的 map 函数一样,对 observable 进行变换得到一个新的 Observable 盒子。

这个过程中,Observable充当了 Functor 的角色。

flatMap **函数则不同于 **map **函数,它不仅可以进行变换,还可以将嵌套的 **Observable 结构展平。

我们通过在 flatMap 的回调函数中调用 from 方法,将 observable 中的每个元素从数字转换为了 Observable 盒子。

由于 observable 本身是一个 Observable 盒子,这波转换相当于是在盒子里面套了新的盒子。

如果我们调用的是 map 而不是 flatMap,那么映射出来的结果就会是一个嵌套的盒子。像这样:

jsx 复制代码
const nestedObservable = observable.map((x) => Observable.from([x, x * 2]))

console.log("nestedObservable", nestedObservable)
nestedObservable.subscribe((val) => console.log("val is:", val))

nestedObservable 本身是一个盒子,如下图:

nestedObservable 内部存储的 val 也是一系列的盒子,如下图:

但我们知道,flatMap 是可以处理嵌套盒子的场景的。这里使用 flatMap,就可以将嵌套的双层 Observable 盒子展开为一个单层的 Observable 对象:

jsx 复制代码
// observable 是一个 Monad,可以调用 Monad 的 flatMap 方法,把嵌套的 Functor 拍平
const flattenObservable = observable.flatMap((x) => Observable.from([x, x * 2]))  

// 输出一个 Observable 盒子
console.log("flattenObservable", flattenObservable)    

// 输出具体的 val 值:1、2、2、4、3、6
flattenObservable.subscribe((val) => console.log("val is:", val))

由于同时具备 map 能力和 flatMap 能力,所以 Observable 盒子既是一个 Functor,也是一个 Monad

Monad 的另一面:把"副作用"放进盒子。

当使用 RxJS 时,我们经常会遇到需要在异步数据流中执行副作用的情况。
我们可以将具有副作用的操作封装在 Monad 中,以便于隔离其它函数对副作用的关注。

请大家看这样一个例子(仍然是基于 RxJS5 的):

jsx 复制代码
import Rx from "rxjs"
import { Observable } from "rxjs/Observable"

// 这里我用 setTimeout 模拟一个网络请求
function fetchData() {
  return Observable.create((observer) => {
    setTimeout(() => {
      observer.next("data")
      observer.complete()
    }, 1000)
  })
}

// 处理数据的纯函数
function processData(data) {
  return data.toUpperCase()
}

// 使用副作用放进盒子的方式处理网络请求
const boxedData = Observable.of(null).flatMap(() => fetchData())

// 订阅处理结果
boxedData.map(processData).subscribe((data) => {
  console.log(data) // 输出 "DATA"
})

在上面的例子中,我们将网络请求这个副作用包裹在一个 Observable 盒子中(上面已经分析过,Observable 盒子是一个 Monad),并将盒子的执行结果作为一个值"发射"出去,这个值可以被后续的 map() 操作消费和处理。
这个过程中, Observable 就是一个专门用来消化副作用的盒子------它将异步操作封装在内部,防止了副作用的外泄。

我们可以注意到,processData() 是一个纯函数 ,它负责将传入的数据转换成大写字母,没有任何副作用。

我们将 processData() 函数传递给 map() 方法,map()方法就会在原有的 Observable 盒子的每个值上调用这个纯函数,并将处理结果放到新的 Observable 盒子中------这整个过程都是纯的

整个过程:在整个 boxData盒子的调用链中,boxData 本身作为一个 Monad 盒子,它是不纯的;末尾的 subscribe() 函数涉及到了在控制台输出数据,它也是不纯的。但夹在这两者中间的所有 map() 调用都是纯的

也就是说,这种模式能够帮助我们把纯函数和副作用分离开来,保证盒子和 subscribe() 回调之间的所有逻辑的纯度。此外,使用 Monad 封装副作用,也可以使代码更加模块化,可维护性更高。

更进一步:【函数管道】将生产端-消费端分离

在 RxJS 中,Observable 负责生产数据,而 Observer 负责消费数据。

在楼上的例子中,boxedData 是一个生产数据的 Observable,而 subscribe 方法所传入的回调函数则是消费数据的 Observer。

Observable(生产端)和 Observer(消费端)都可能涉及副作用,例如异步请求、打印日志等等。因此它们都是不纯 的。

但是,那些夹在 Observable Observer 之间的操作,例如 map filter merge 等等,这些操作专注于数据的计算,并不关心数据的来源和去处、不涉及外部环境 ,因此它们总是纯的。

这也就是说,RxJS 背靠函数式编程的思想,在 Observable Observer 之间架起了一条"函数管道" 。生产端 Observable 将数据"发射"出去后,数据首先会经过这条"管道 ",在"管道 "中完成所有的计算工作后,才会抵达消费端 Observer。

对于 RxJS 来说,想和外界发生交互,只能通过管道的首尾两端(也即生产端、消费端)。管道内部是由纯函数组成的,这就保证了整个计算过程的可靠性和可预测性。同时,通过这条"管道",生产端 Observable 和消费端 Observer 被有效地分离,实现了高度的解耦

函数式的 JavaScript

JavaScript是多范式的语言,函数式编程是它主要支持的范式之一。JS 支持函数式最关键的链各个特性:

  1. 函数是一等公民
  2. 闭包

函数是一等公民 在 JavaScript 中,函数是一等公民,它们可以像普通变量一样被赋值、传递和返回。

这使得我们可以将函数作为参数传递给另一个函数,或者从一个函数中返回一个新的函数。
这也使"以函数为基本单位构建应用程序"成为可能。
任何语言如果想要实现对函数式编程范式的支持,就必须支持"函数是一等公民"这一特性。

闭包

在 JavaScript 中,闭包(closure)是指一个函数能够访问并使用其声明时所在的词法作用域(lexical scope)中的变量 ------即使该函数在声明时所在的作用域已经执行完毕、并且该作用域已经被销毁了。

简单来说,外部函数嵌套内部函数,内部函数访问外部函数的变量和参数,那么这个函数就形成了一个闭包。 像下面这样:

jsx 复制代码
function outerFunction() {
  const outerValue = '外部函数的变量'

  function innerFunction() {
    console.log(outerValue)
  }

  return innerFunction
}

const inner = outerFunction()  
//// 输出:"外部函数的变量"
inner()

闭包允许函数"记住"它们创建时的词法环境(lexical environment),即函数的外部变量。
这是我们在 JS 函数式编程中实现高阶函数、柯里化、偏函数等技术的基本前提

因此我们这里可以结合一个高阶函数的例子来看闭包在函数式编程中的应用:

jsx 复制代码
function multiplyBy(factor) {
  return function (num) {
    return num * factor
  }
}

const double = multiplyBy(2)
const triple = multiplyBy(3)

// 输出 4
console.log(double(2))
// 输出 6
console.log(triple(2))

multiplyBy() 函数是一个高阶函数,它接收一个参数 factor,并返回一个新的函数。

个新函数是一个闭包,它可以访问到外部函数 multiplyBy() 的参数 factor。

通过调用 multiplyBy() 函数并传入不同的参数,我们可以得到两个新函数 double() 和 triple(),这两个函数分别会将传入自身的参数乘以 2 和 3。

这是因为 double()和triple()"记住"了自己的词法环境,记住了外部函数 multipleBy()作用域下的 factor 参数。而这"记忆"的能力,正是由闭包提供的
任何一门语言要想实现对函数式编程的支持,除了要做到对"函数是一等公民"特性的支持外,还需要确保闭包的实现。

TS 和 Flow 对函数式的支持

TypeScript (TS) 和 Flow 都是 JavaScript (JS) 的超集,它们继承了 JS 的各种特性,因此也都支持使用函数式编程范式来编写代码。

TS 是由微软开发和维护,而 Flow 是由 Facebook 开发和维护。它们都在 JS 基础上增加了类型系统

类型检查

在函数式编程中,函数的参数和返回值类型通常很重要,因此类型安全特别重要

jsx 复制代码
// ts 示例代码
function add(a: number): (b: number) => number {
  return function(b: number): number {
    return a + b
  };
}

const add2 = add(2)  
// 输出 5
console.log(add2(3)) 
jsx 复制代码
// @flow
function add(a: number): (b: number) => number {
  return function(b: number): number {
    return a + b
  };
}

const add2 = add(2)
// 输出 5
console.log(add2(3)) 

通过类型检查,TS 和 Flow 可以在编译时就识别出参数类型的错误。例如,如果我们在调用 add2() 函数时传入一个字符串参数,TS 和 Flow 会在编译时就抛出错误提示,而不是在运行时抛出异常

泛型和函数重载

函数重载是指在同一个作用域内定义了多个同名函数,这些同名函数的参数类型或数量不同。

当调用这个同名函数时,编译器会根据传入的参数类型或数量来决定应该调用哪个函数。

泛型允许我们在定义函数时不指定具体类型,而是在调用函数时再根据传入的参数类型确定具体的类型。

就函数式编程的实践而言,类似 TS 和 Flow 这样的强类型语言中有三个特性是格外值得我们关注的,那就是类型检查、函数重载和泛型
类型检查 可以帮助我们在编写代码时就发现潜在的类型错误,使函数的运行时更加安全可靠
函数重载 可以帮助我们处理不同参数数量和类型的函数实现。
泛型 则可以帮助我们间接地约束不同函数之间类型的一致性 ,函数重载和泛型都可以使我们的函数式代码更加灵活和通用

尽管 TS 和 Flow 支持函数式编程的特性比 JS 更加全面,但是它们仍然基于 JS,并没有完全消除 JS 的局限性(如可变状态和副作用等问题)。因此,在实践中,我们仍然需要遵循函数式编程的原则和最佳实践,以确保代码的可靠性和可维护性

函数式编程工具库

函数式编程在前端领域的应用是极为广泛的,我们几乎能在任何一个前端技术分支里看到它的身影,这些分支包括但不限于:

  • 实用工具库:如 Ramda、Lodash-fp 等
  • 状态管理库:如 Redux、 MobX-State-Tree 等
  • 视图库:如 React、Cycle.js 等
  • 测试库:如 Jest、Cypress 等
  • 不可变数据库:如 ImmutableJS、Immer.js 等
  • 响应式编程库:RxJS、Bacon.js 等

其中,状态管理库(代表作 Redux)、视图库(代表作 React)、不可变数据库(代表作 ImmutableJs 和 Immer.js)和响应式编程库(代表作 RxJS)。

Ramda:为函数式编程而生

Ramda 是一个为 JavaScript 编程语言设计的函数式编程库。

Ramda 的几个核心特征包括:自动柯里化、Data-Last、纯函数和不可变性

自动柯里化

Ramda 提供的所有函数都是默认柯里化的。柯里化有助于代码的重用,也为函数组合创造了便利。

jsx 复制代码
import R from 'ramda'

// 自动柯里化的 add 函数
const add = R.add 

// 部分应用参数 5
const add5 = add(5) 
// 输出 8    
console.log(add5(3)) 


// 一次性传入所有参数,输出 8
console.log(add(5, 3))

Data-Last

Ramda 的函数默认遵循 Data-Last 的原则,这意味着数据参数通常是函数的最后一个参数

这种设计有助于偏函数和函数组合的实现。下面是一个体现 Data-Last 原则的代码示例:

jsx 复制代码
const numbers = [1, 2, 3, 4]   
// R.add(1) 返回一个函数,函数作为了第一个参数
const addOne = R.map(R.add(1))   
// numbers 是数据,数据作为最后一个参数
console.log(addOne(numbers)) 

Why Data-Last?

为什么说 Data-Last 更有利于函数组合的实现?我们通过一个简单的例子来理解这个问题。

假设我们有两个函数库,一个使用 Data-First(DF)原则,另一个使用 Data-Last(DL)原则。

现在我们需要处理一些数据,具体步骤是:先过滤出大于 10 的数字,然后将它们乘以 2。

首先看 Data-First 的实现:

jsx 复制代码
const filterDF = data => predicate => data.filter(predicate)
const mapDF = data => fn => data.map(fn)

// 过滤大于10的数字
const greaterThan10 = num => num > 10
// 乘以2
const multiplyBy2 = num => num * 2

// 嵌套调用实现函数的组合
const processDataDF = data => mapDF(filterDF(data)(greaterThan10))(multiplyBy2)

const data = [5, 10, 15, 20]  
// 输出 [30, 40]
console.log(processDataDF(data)) 

现在再来看 Data-Last 的实现:

jsx 复制代码
const filterDL = predicate => data => data.filter(predicate)
const mapDL = fn => data => data.map(fn)

// compose 实现函数组合
const processDataDL = data => R.compose(mapDL(multiplyBy2), filterDL(greaterThan10))(data)

const data = [5, 10, 15, 20]
// 输出 [30, 40]
console.log(processDataDL(data)) 

在 Data-First 的实现中,我们不能使用像 R.compose 、R.pipe 这样的函数将 filterDF 和 mapDF 预先组合在一起------因为数据参数是第一个参数。

我们需要将数据显式地传递给 filterDF,然后将结果传递给 mapDF。这其实是一个嵌套调用的过程,嵌套调用在函数数量较多时,会导致代码难以阅读

相比之下,在 Data-Last 的实现中,我们可以利用函数组合(R.compose)提前将 filterDL 和 mapDL 组合在一起。由于数据参数是最后一个参数,我们可以预先组合两个函数,而无需等待数据的到来

Data-Last 不仅仅有助于函数组合的实现,也有助于偏函数/柯里化的实现------当一个需要被偏函数/柯里化处理的函数同时具备函数和数据两种参数时,数据参数往往是动态的,而函数参数则相对稳定。因此,在偏函数/柯里化的过程中,将需要固定的函数参数放在前面,将动态的数据参数放在最后,可以使得函数更加通用和可复用。

纯函数

Ramda 在设计上鼓励使用纯函数。Ramda 自身提供的函数都是纯函数,这意味着它们的输出完全取决于其输入,而且不会产生副作用。

通过使用 Ramda 的纯函数,用户可以更容易地编写纯函数,从而提高代码的可预测性和可测试性。

不可变性

Ramda 提供了一系列不可变的操作方法,例如 assoc、dissoc 等,这些方法不会修改原始数据,而是返回新的数据。这有助于保证数据的完整性和代码的可预测性。

jsx 复制代码
const person = { name: 'Xiu Yan', age: 30 }
const updatedPerson = R.assoc('age', 31, person)

// 输出 { name: 'Xiu Yan', age: 30 }
console.log(person)   
 // 输出 { name: 'Xiu Yan', age: 31 }
console.log(updatedPerson)    

// false
console.log(updatedPerson === person)

Lodash-fp:为 Lodash 披上函数式的外衣

Lodash 起初的设计目标是为了解决 Underscore.js 在性能和 API 方面的问题,它的作者 John-David Dalton 意在创建一个更快、更一致且功能更丰富的实用工具库。因此,最初的 Lodash 并不完全符合函数式编程范式

而在函数式编程逐渐流行的过程中,许多开发者希望 Lodash 能够更好地支持函数式编程。

为了满足这些需求,Lodash 的作者创建了一个 Lodash 的子项目:Lodash-fp
Lodash-fp 为 Lodash 披上了一层函数式编程的"外衣" ------它对 Lodash 原有的函数进行了改造,使其更符合函数式编程的原则。

披上这层"外衣"后,Lodash-fp 也具备了以下的函数式特性:

自动柯里化与 Data-Last

和 Ramda 一样,Lodash-fp 中的函数也都是自动柯里化的,这意味着你可以提前传递部分参数,生成一个新的函数,稍后再传递剩余的参数。例如:

jsx 复制代码
import _ from "lodash/fp"   

// 原始数据
const users = [
  { id: 1, name: 'Xiu Yan', age: 28 },
  { id: 2, name: 'You Hu', age: 24 },
  { id: 3, name: 'Xiao Ce Sister', age: 32 },
]

// Lodash-fp 函数自动柯里化
const getNames = _.map(_.property('name'))

// data-last,先传入迭代器,再传入数据
const names = getNames(users)

// ['Xiu Yan', 'You Hu', 'Xiao Ce Sister']
console.log(names) 

上面的示例中,我们使用了 Lodash-fp 中的 .map 函数,和许多 Lodash-fp 导出的函数一样,它也是自动柯里化的。
注意:在传参顺序上,我们先传入了迭代器函数(
.property('name')),然后再传入数据(users),因此这个例子同时也反映了 Lodash-fp 的 Data-Last 原则

纯函数与不可变性

在 Lodash-fp 中,大部分函数都是纯函数和遵循不可变性原则的 ,但确实存在一些例外。

以下是一些不符合纯函数和不可变性要求的 Lodash-fp 函数示例:

  1. _.uniqueId:这个函数生成唯一的 ID。由于每次调用uniqueId函数都会生成一个新的唯一标识符,不能保证对于相同的输入有相同的输出,所以它不是一个纯函数。
  2. _.random:这个函数生成一个随机数,在给定相同的输入时,可能会产生不同的输出,所以它也不是一个纯函数。
  3. _.assign:这个函数用于将一个或多个源对象的属性分配给目标对象。它会直接修改目标对象。

原理分析:如何为 Lodash 套上函数式的"皮"

在 Lodash-fp 的源码库中,有一个名为 convert.js 的核心模块,负责根据 lodash 的源码生成 lodash-fp。

在 index.js 文件中,可以看到 lodash-fp 的入口就是这个 convert.js:

jsx 复制代码
// index.js 源码
module.exports = require('./convert')(require('lodash-compat').runInContext());

convert.js 是 lodash-fp 的核心部分,它的关键逻辑提取如下:

jsx 复制代码
// ... 省略部分代码 ...

// wrap 函数负责将原始的 lodash 方法转换为 lodash-fp 风格
var wrap = function (name, func) {
  var wrapper = wrappers[name];
  if (wrapper) {
    return wrapper(func);
  }
  var result;
  each(listing.caps, function (cap) {
    each(mapping.aryMethodMap[cap], function (otherName) {
      if (name == otherName) {
        // 调整函数参数数量
        result = ary(func, cap);
        // 重新排列函数参数,将数据参数放在最后
        if (cap > 1 && !mapping.skipReargMap[name]) {
          result = rearg(result, mapping.aryReargMap[cap]);
        }
        // 对函数进行柯里化
        return !(result = curry(result, cap));
      }
    });
    return !result;
  });

  return result || func;
};

// ... 省略部分代码 ...

整个 convert.js 的核心就在于这段 wrap 函数

在 wrap 函数中,会首先调用 ary 函数调整函数的参数数量。接着,调用 rearg 函数重新排列函数参数,实现 Data-Last 。最后,使用 curry 函数对调整过参数的函数进行柯里化

上述的每一步,对应到代码中是这样的:

  1. 调整函数参数数量:result = ary(func, cap); 这行代码调用 ary 函数来生成一个新的函数,该函数接受的参数数量由 cap 决定。
  2. Data-Last:result = rearg(result, mapping.aryReargMap[cap]); 这行代码使用 rearg 函数重新排列函数参数,将数据参数放在最后(具体的参数重排规则由 mapping.aryReargMap[cap] 提供,mapping.aryReargMap 规则的设计是遵循 Data-Last 原则的)。
  3. 自动柯里化:result = curry(result, cap); 这行代码调用 curry 函数对调整过参数的函数进行柯里化。

经过上述转换后,原始的 Lodash 函数就变成了符合函数式编程风格的 Lodash-fp 函数。

总结一下:Lodash-fp 是一个基于 Lodash 源代码生成的函数式编程库,它对 Lodash 中的存量函数进行了转换,转换后的函数具有自动柯里化、Data-Last 等函数式编程特性。

函数式的前端测试:以 Jest 为例

在前端测试领域,函数式思想的实践往往表现在以下三个方面: 纯函数、不可变性、高阶函数

Jest、Cypress 和 Mocha 这三个测试库都在不同程度上实践了函数式编程思想,它们的函数式实践自然也都绕不开上述的三个函数式特性。

这里我们以 Jest 为例,一起来看看这些特性是如何表现的。

纯函数和不可变性

Jest 并没有强制要求开发者编写纯函数和使用不可变性,但它提供了相应的工具和方法来实现这些概念。

在工具方面,Jest 的断言库中提供了一些用于比较对象的 matcher,如 toEqual、toMatchObject 和 toHaveProperty。

这些 matcher 都会对比对象的属性和值,而不是对比对象的引用。这样可以确保开发者不会意外修改对象,从而保证不可变性

以下是一个借助 toEqual来检查数据是否被意外修改的测试用例:

jsx 复制代码
// 使用纯函数和不可变性原则编写测试用例
it("should not modify the input array", () => {
  // 原始数组
  const inputArray = [1, 2, 3]
  // 原始数组的副本
  const originalArray = [...inputArray]
  // 对原始数组执行目标函数
  targetFunc(inputArray) 
  // 检查目标函数是否修改了原始数组的内容
  expect(inputArray).toEqual(originalArray)
})

此外,Jest 还提供了其他一些工具和方法来帮助开发者编写纯函数和遵循不可变性原则,比如用于创建纯函数模拟的 jest.fn() 和用于断言不可变性的 jest-expect-immutable。

注:Jest 还提供了 Mock 功能,Mock 功能可以用来隔离副作用(例如网络请求或者文件读写等),它使得测试可以更加可控、稳定和独立。

高阶函数

Jest 在自身的接口设计上大范围使用了高阶函数,比如钩子函数 beforeEach 和 afterEach,断言函数 expect,它们都是接受一个函数作为参数的高阶函数。

以下是 beforeEach 和 afterEach 的简单示意:

jsx 复制代码
beforeEach(() => {
  // do something before each test
});

afterEach(() => {
  // do something after each test
});

这里的 beforeEach 和 afterEach 接受的参数都是一个函数,这个函数会在每个测试用例执行之前或之后执行。

这两个函数作为钩子函数存在,允许我们在测试用例执行前后进行一些公共的操作,比如初始化一些数据、创建一些实例等等。

此外,Jest 中的 describe 和 it 函数也都是高阶函数,这里不再赘述。

相关推荐
Martin -Tang20 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发21 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录1 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html