这可能是你没见过的“JS函数式编程(ramda)”实践

2,629 阅读14分钟

前言

文本主打🌰,或"命令式编程"和"函数式"的案例对比,或是demo的实践(主要是react)。

// 有一个面试题,是一串api数组,
// 写一个类似chunk的工具函数,传参控制个数,进行并发请求
var testList = Array.from({length: 7}, (v, i) => () =>
  new Promise(resolve => {
    setTimeout(() => {
      resolve(i);
    }, 2000);
  })
);

先看对比,左侧是写的正常命令式编程代码,右侧强改成ramda作为个对比,用来感受下。

image.png

全柯里化函数一种视觉的颗粒感,很多时候,ramda风格代码行数也会比较少。上例在线代码

主体内容少提及文绉绉概念,希望读者阅后的收获是:有换个角度的感觉。

为什么需要函数式

优势

  1. 可以是重要,也可以是没必要,这个很主观,爽就完事。像TS一样写字谜。使用函数式编程很难反驳“又不是不能用”这句名言。
  2. 思想上,能练习抽象化的编程,能助威react项目的一些功能的实现。比如redux相关编写或者高阶组件的设计。尽可能对代码结构保持正向思考,而不是在业务逻辑中,进行api的拼堆。
  3. 代码阅读性。压缩了许多"const, var, ()=>{}"之类代码的视觉空间(惰性执行),阅读关键字,得知这段逻辑要干什么。

“*不可变数据、无负影响、纯函数,惰性执行,Pointfree风格,类型签名 *” 等之类概念,直接用上库,并加以同模式的运用,已经可以学到和做到,不加进来水字数,本文核心是实践。另外,在业务代码中,函数纯不纯,很多都是不影响的。这些理念是函数式编程需要囊括的,而不是专属的。

小风险点

  1. 调试吃力。中间炸了个函数,控制台可能无法一眼看出来。
  2. 性能相关。打包出来的代码是多了不少的,执行时的函数栈和闭包之类东西会多起来。
  3. ts有时推导不了。追求效率就是忽略这个报错了,或者as unknown as XX
  4. 极少见,执行过程出现了匪夷所思的报错,没亲眼目睹。
  5. 上手过程耗时,影响项目进度。

为什么是ramda

1. 较lodash/fpfunctional.js优势:
  • 有更多的处理遍历处理的函数, 比如 uncurryN compose
  • 更友善的文档,也有中文版本,支持控制台调试
  • 有常用套路的集合

文档写着是0.27,但中文网控制台是0.26,英文网是0.25,个别函数会注明版本,最新版本要到try Ramda

2. 较folktale更容易上手。
  • ramda 是对现有类型进行操作。将较小的函数组合成进行语言描述和逻辑运行,与Lodash有较多类似函数。
  • folktale 分几个大属性模块,有较强烈的语义划分,需要一定熟悉度。文档不习惯,不方便调试和查询。

引用个ramda作者对比描述,可以点进去folktale部分的引用感受下,有着强烈又不一样的风格。

3. 有着较多使用者,stackoverflow能搜出很多想要的组合,同时上手后可以选择类似JS严格模式的Sanctuary
4. TS的诱导学习。

如果你的项目结合了ts,或许会接触到ts-toolbelt。同时,点进函数看,能学到一些函数的类型怎么写。

很多情况不用特别注明类型,因为本身的ts推导还是比较好的。有时候推导出"unknown",就需要强写明类型,也能能帮助阅读就,容易得知某变量是什么结果,如下图。

刚写ramda时候的ts报错,那大概率是你的函数真的写错了。。。先不要怀疑是没有推导出来。

运用

头等函数

functions as first-class citizens。不少国内文介绍为“一等公民”。外网文有的没提及,名字可能也不同,但“first-class”绝对是关键字。放几个较权威性的网址。

平时其实挺多是运用到了3点特性:

  1. 函数赋值给变量。就是函数表达式,或者像ES6语法,对象里面的函数
  2. 函数作为参数。就是reduce、map之类
  3. 返回一个函数。高阶函数(Higher-Order Functions,HOF),redux中间件案例很多,也讲究纯函数。react里面就是高阶组件(HOC)

另外,普通JS逻辑中较常见的操作对象,会用到特别存在的 “lens系列”

本意就是镜头,让你聚焦对象的属性,包装setter和getter,讲究原数据不可变。 源码会有一丝丝绕,需要几个函数齐论,以设置一个对象的属性值为例。

下面解析的对象设值过程(把a.x从1改成4),感受上面3点特性。当做思路体验。

// 设置a变量的x属性成4
const xLens = R.lensProp('x'), a = {x: 1, y: 2}; // 代号 A 逻辑
R.set(xLens, 4, a); // => {x: 4, y: 2}; // 代号 B 逻辑

// A 逻辑 A1) 。实际上 lensProp 就是返回一个函数,第3点。返回了 lens
function lensProp(k) {
  // prop 功能类似lodash.get,
  // assoc 功能类似lodash.set,但 assoc 内部是简单克隆返回个新对象
  return lens(prop(k), assoc(k));
})

// A2) lens 接受取和设两个函数作为参数,第2点。
function lens(getter, setter) {
  return function(toFunctorFn) { // xLens 装的这个函数,对应第1点。
    return function(target) {
      return R.map(// ramda的遍历
        function(focus) {
          return setter(focus, target);
        },
        toFunctorFn(getter(target))
      );
    };
  };
}

// ⭐️ xLens 此时是lens的运行结果,是一个这样函数。代号 C 逻辑。需要操作的属性已被记录下来(偏函数)
 function(toFunctorFn) {
    return function(target) { // 代号 D 逻辑
      return R.map(// ramda的遍历
        (focus) => assoc('x')(focus, target),
        toFunctorFn(prop('x')(target))
      );
    };
  };

// B逻辑 B1) R.set 里面调用 R.over。set接受值,over接受函数。
// R.set(xLens, 4, a) ≈ R.over(xLens, () => 4, a)
function over(lens, f, x) {
  return lens(y => Identity(f(y)))(x).value;
})

// over的内部函数,这里的map不是js的遍历,是映射之意,接受转换函数f,可以进行转换。提供给R.map函数,使得值维持恒等
function Identity(x) {
  return {value: x, map: (f) => Identity(f(x))};
};

// B2) R.over(xLens, () => 4, a) 运行时,就是运行 xLens 约等于下面
var tmp1 = () => 4 // f(y)运行,over函数的第二个参数运行,并没有入参。即是上述 “prop('x')(target)” 部分
var tmp2 = () => Indetity(tmp1()) // 成了上述的getter,也并没有使用参数,这个函数将来返回:{value: 4, map: (f) => Identity(f(4))}

// 然后运行 C 逻辑,即xLens(tmp2),也是返回一个函数。
var tmp3 = function(target) {
   // 下图用作辅助理解
  return R.map(// ramda的map遍历,这是个很神奇的map,会维持Identity的格式
    (focus) => assoc('x')(focus, target),
    tmp2()
  );
};

// 然后上面 focus 参数就是 tmp2的结果
var tmp4 = tmp3(a).value // 传入对象,运行 D。就是最终结果 {x: 4, y: 2};

上述省略了柯里化的描述,是呈现的大致内容,十九不离十。Lodash这个工具库其实内部相互引用非常多,所以用了一个api可能装了另一个api,不过不是问题。

tmp3.png

最后会绕道R.map里面,下图控制台的temp1就是上面tmp2的结果。源码具体‘fantasy-land/map’和函子函数的‘Identity’为本文超纲内容

image.png

小案例

ramda中useWithconverge会是业务中比较常用的

// 处理多个入参
R.useWith(R.add, [R.dec, R.inc])(2,3); // 结果:1 + 4 = 5
                    ↓       ↓
              入参   2       3

// 处理1个入参
R.converge(R.add, [R.dec, R.inc])(2,3); // 结果:1 + 3 = 4
                    ↓       ↓
              入参   2       2

利用与优化

react的组件传递和HOC已经是很常见的运用。 相应可以应用在一些Hook上。

项目背景:现有同一个页面,有A和B组件,都使用了用户列表接口,但是需要的前端渲染结构不一样

我的思路方向:

  1. getUsers不写两次,除非你的http.js本身有接口缓存。 → swrreact-query或其他手段,存储接口内容
  2. 通过格式化函数进行传参,从而修改
// service/usersList.ts
export type TUser = {id: number,name: string}
export const getUsers = async (params, filter) => {
    cosnt res = await http.post<TUser[]>('/api/user', params)
    if(filter) {
        return res.map(filter)
    }
    return res;
}

// 第1步,增加这个hook
export const useGetUsersBySwr = (mapObj?: {[key in keyof TUser]: string}) => any[]) => {
    const res = useSWR('/api/user', () => {
        ...
        return getUsers(params, mapObj ? renameKeys(mapObj) : null)
    })
    return {...res}
}

// ramdaCookBook.ts -> https://github.com/ramda/ramda/wiki/Cookbook#rename-keys-of-an-object
export const renameKeys = R.curry((keysMap, obj) =>
  R.reduce((acc, key) => R.assoc(keysMap[key] || key, obj[key], acc), {}, R.keys(obj))
);


// A.tsx
const A = () => {
    const data = useGetUsersBySwr({id: 'value', name: 'text'})
    return <></>
}

// B.tsx
const A = () => {
    const data = useGetUsersBySwr({id: 'name', name: 'label'})
    return <></>
}

函数组合

核心主要是 pipecompose,代码本质没什么区别,后者调用了前者,前者使用reduce

目的是返回一个新函数,运行时候,将pipe参数的函数运行,并层层传递回调值。

// 制作一个对数字自增的平方的函数,比如 1 -> 2 -> 4
var inc = x => x + 1;
var square = x => x * x;

var combo = n => square(inc(n))
combo(1) // 4

R.pipe(inc, square)(1)
R.pipe(square, inc)(1)
  • pipe:会更接近常人阅读顺序,我会用在结构段落式的代码中运用。
  • compose:会更有原始 combo 函数的感觉,有点洋葱模型或中间件的触感。

redux的composekoa的compose。ramda的组合处理的层级会更深一点,更多的执行上下文绑定

小案例

// 有一个数据库查询结果,找到是父节点的索引数组
var dataList = [{id: 1}, {id: 2, pId: 1}, {id: 3, pId: 2}, {id: 4}]

var pidCollection = R.compose(
        R.map(R.prop('id')),
        // 调试性质的代码 👇🏻 
        (v) => { console.log(v); return v },
        R.filter(R.compose(R.not, R.propIs(Number, 'pId')))
      )(dataList)
      
// 执行顺序:filter(propIs,not) -> map(prop)

利用与优化

如下图,某个react项目的入口文件,已经被包裹着4层Provider,或者还会有顺序要求的Provider加入,这种凹凸风就很不雅和不方便。

image.png

  1. 显而易见,第一步是把自己主逻辑的APP部分抽离,这里抽离叫“main”函数
  2. 使用组合函数,装载这些无关业务逻辑的Provider

react官网也有提及

  const main = () => <EEApp />

  /**
   * provider 的容器函数
   * @param {JSX.Element} react的业务入口组件
   * @returns 包裹provider后的 app
   */
  const hocWrap = (app: JSX.Element) => {
    // 0. 用来绑定一些静态参数
    const providerWrap = (Comp: React.FC<any>, extraParams = {}) => props =>
      R.compose(Comp, R.merge(extraParams))(props)

    type TProvider = (child: any) => JSX.Element
    // 1. 存放静态 provider,之后有新 provider 就往这里塞
    const providerList = [
      SulaWrapper,
      StoreProvider,
      providerWrap(ConfigProvider, {locale: zhCN}),
      providerWrap(QueryClientProvider, {client: queryClient}),
    ] as TProvider[]

    // 2. 遍历 providerList 运行成HOC,理解成 Comp => (children) => <Comp>{children}</Comp> 的过程
    const mapWrap = (list: TProvider[]) => {
      return R.map((comp: TProvider) => children => comp({children}))(list)
    }

    // ramda 的ts没支持 展开运算符,强转ts
    return R.pipe(...(mapWrap(providerList) as [TProvider]))(app)
  }
  
  return <>{hocWrap(main())}</>

装载React.forwardRefredux connect或者其他HOC可以使用这种方式整理代码结构,帮助划分代码层次和聚焦主要的业务代码。

柯里化和偏函数

  1. 柯里化:将一个多参函数“惰性执行处理”,使其【未拿到所有参数前】不执行内部逻辑:
    1. 原函数F1,执行F1(a,b,c),其返回“ok”。柯里化F1后的新函数是F2,
    2. 执行F2(a,b)【或者F2(a)(b)】,F2没拿到F1.length数量的参数,返回新函数F3,
    3. F3目前差一个参数,待集齐(a,b,c)后才执行F1逻辑。执行F3(c) 返回 "ok"。
  2. 偏函数:意境上是要存储一个变量,F3相当于一个偏函数,因为它已存储了a和b参数。像Function.prototype.bind,但不绑定this。ramda的partial本质是_curry2+Function.prototype.apply,把partial的第二个参数和结果新函数的arguments拼接起来

这个库的函数绝大多数都是自带柯里化处理,比如 三元柯里化R.propIs、二元柯里化R.add

源码内部有4个柯里化处理函数。

  • 比如_curry1_curry2_curry3,就是普通的判断arguments.length个数进行返回。

ramda的api函数大部分都是3个以内的参数,猜测是性能原因,不同参数个数的api,内部调取相对应的内部curry函数。如 R.add -> _curry2。

  • 第四个:_curryN,但也不会常规的柯里化处理函数,其内部调用的_arity限制了参数个数不能大于10。很多都内部调用了,比如R.pipe,所以文档中“第一个函数可以是任意元函数(参数个数不限)”不是很准的样子。
var test1 = (a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12) => {}
R.pipe(test1)() // First argument to _arity must be a non-negative integer no greater than ten

R.curry(test1) // First argument to _arity must be a non-negative integer no greater than ten

猜测限制的目的是R.curryN需要确切运行数字限制,参考inssue。控制到个数10一般都是够用了。

曾见过用lodash.filterramda.filter对比性能,没多大意义,定位不一样。ramda主旨是自己一套函数式编程,lodash是实打实的工具函数库,clonecurry等都会严谨许多。

如果真有超过10个数的传参,可以尝试用es6语法绕开。

// babel后也没事,因为babel后的test1是十个参数,rest变量是函数体内生成。
var test1 = function(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,...rest) {return R.sum(arguments)}
console.log('9个参数', R.curry(test1)(...Array(9).fill(1))); // 9个参数 ƒ (t){return s.apply(this,arguments)}
console.log('11个参数', R.curry(test1)(...Array(11).fill(1))); // 11个参数 11

☆:占位符(R.__):就是占一个 arguments 参数位置,内部用过_isPlaceholder函数来判断参数是不是占位符对象,为真则忽略执行这个参数,比如R.values中运用的_curry2源码会比较清晰

f2(a, b) {} 中,如果,a是占位符,
则返回个新函数 function(_a) { return fn(_a, b); }

小案例

R.__ // {@@functional/placeholder: true}

R.propIs(Number, 'x', {x: 1}) // true
R.propIs()(Number, 'x', {x: 1}); // true
R.propIs(R.__, 'x', {x: 1})(Number); // true
R.propIs()(Number, 'x'); // ƒ f1(a) {...}
R.propIs(Number); // ƒ f2(a, b) {...}
// 把 “R.filter(R.propIs(Number, 'pId'))”通过 偏函数 改写
R.filter(R.partial(R.propIs, [Number, 'pId']))

利用与优化

项目背景:

  1. 原有一个二次封装antd的动态Form组件,代号A;拥有输入框boy选择框girl,两者动态,互斥出现,if/else的关系。
  2. 现有新需求,同页面多了个抽屉模块,里面有个新表单组件,称其为B。
  3. B的某些变动,改变boygirl的值成为特定内容 我的思路方向:
  4. 改值肯定要用api A的form实例的 setFieldsValue
  5. 我目的:A改动最少 不使用React.forwardRef 向上抛出函数
  6. 我目的:B不关注A此刻是男是女,只负责设值 A需要间接传递给B一个函数,作用是setFieldsValue('boy', xx)setFieldsValue('girl', xx)

// 页面文件: page.tsx ↓
const page = () => {
    // 不抛东西出去,那必定要A"设值一个函数"
    const [
        handlerForSetFieldsValue,
        setHandlerForSetFieldsValue,
      ] = useState<(v: any) => void>(() => v => {
          throw '未赋予设值的偏函数'
      })
    // ps: 用useRef,context,redux都行,这里用useState存放函数。

    return <>
        <A {{...setHandlerForSetFieldsValue}}/>
        <B {{...handlerForSetFieldsValue}}/>
    </>
}

// 组件文件: A.tsx ↓
const A = ({setHandlerForSetFieldsValue}) => {
    const [form] = useForm()

    const changeForShowField = (type: 'boy' | 'grid') => {
        // 把当前是性别存储起来传递出去,交付给外面组件直接设值
        const fn = R.partial(form.setFieldsValue, [type])
        setHandlerForSetFieldsValue(fn)
   }
   
   useEffect(() => {
       ...
       // 条件触发设置男女
       changeForShowField(sex)
       ...
   }, [...])
   
   ...
}

// 组件文件: B.tsx ↓
const B = ({handlerForSetFieldsValue}) => {
    const onChange = (v) => {
        ...
        // 某些行为触发后,直接设置A表单的某个字段值,不关注是什么字段
        handlerForSetFieldsValue(newVal)
    }
    ...
}

我说不清这种做法叫什么设计模式,访问者模式?状态模式?。但这种做法是确切有便利性的。最近使用在"sula的表单,改变下拉框数据源"的情景,还绕开了 表单数据变动后渲染不实时 的问题。

也有其他尝鲜:在以前项目的全局redux中,传递偏函数组件。在一个业务组件中,内部有一个实时变化的小函数组件,偏函数方式固定部分参数,传递给祖层的弹窗工厂组件,进一步渲染Modal的footer。

总结

实际业务代码,需要花时间去摸索怎么写的,也很难说普通或不够复杂度的业务中有什么大优势,个人本就是枯燥的业务中找点新鲜。

但文章目的归终与做个函数式的思想方向分享,主要是展示一种函数传递的感觉,以及顺带放几段真正在使用的ramda代码,可以给想学的人找找感觉。此外,函数编程思想拓展的库,如immutable.jsRxJsImmer