引言
继前六篇洗礼之后:
- 《XDM,JS如何函数式编程?看这就够了!(一)》 关键词:-代码可读-
- 《XDM,JS如何函数式编程?看这就够了!(二)》 关键词:-偏函数、柯里化-
- 《XDM,JS如何函数式编程?看这就够了!(三)》 关键词:-函数组装-
- 《XDM,JS如何函数式编程?看这就够了!(四)》 关键词:-副作用、纯函数-
- 《XDM,JS如何函数式编程?看这就够了!(五)》 关键词:-数组应用-
- 《XDM,JS如何函数式编程?看这就够了!(六)》 关键词:-异步-
我们可以得出这样的结论:
函数式编程所代表的【声明式代码风格】是高于【命令式代码风格】的!它给了代码可读性数值更多增长的空间!
也可以这样解释:声明式代码风格是基于命令式代码风格,声明式说白了就是对命令式的上层封装!
命令式关注:做什么,申明式关注:是什么。
如果我们的代码夹杂着命令式风格和声明式风格,即处在 图1.2 红线和绿线的交叉点上的话,那我们代码的可读性或许就处在 图 1.1 虚线和实线的交叉点上,可读性非常低。所以,我们在 JS 函数式编程的路上要更加一往无前!别到最后不伦不类,沦为笑柄 Orz
第七篇作为系列阶段性的【完结篇】,一切将从实战出发!一切为了实战!
FP will never be slaves.(函数式编程永不为奴)
武器准备
我们在前篇陆续介绍了很多【高级函数】用于实践 JS 函数式编程。
作者将其整理到了这个文件:fp-helpers.js
随便列举几个,都是前面提过的,熟悉的面孔:
function partial(fn,...presetArgs) { // 偏函数 return function partiallyApplied(...laterArgs){ return fn( ...presetArgs, ...laterArgs ); }; } function curry(fn,arity = fn.length) { // 柯里化 return (function nextCurried(prevArgs){ return function curried(nextArg){ var args = [ ...prevArgs, nextArg ]; if (args.length >= arity) { return fn( ...args ); } else { return nextCurried( args ); } }; })( [] ); } function compose(...fns) { // 函数组装 return fns.reduceRight( function reducer(fn1,fn2){ return function composed(...args){ return fn2( fn1( ...args ) ); }; } ); } var pipe = reverseArgs(compose); // 函数组装:参数反转,从左到右 function reverseArgs(fn) { return function argsReversed(...args){ return fn( ...args.reverse() ); }; } function zip(arr1,arr2) { // 交替选择入参 var zipped = []; arr1 = [...arr1]; arr2 = [...arr2]; while (arr1.length > 0 && arr2.length > 0) { zipped.push( [ arr1.shift(), arr2.shift() ] ); } return zipped; } .....
我们将借用这些高级函数彻底 升级 我们从【命令式编程风格】到【函数式编程风格】!
实践场景
我们用原生模拟一个股票信息操作场景:
DOM 结构:
<ul id="stock-ticker"> <li class="stock" data-stock-id="AAPL"> <span class="stock-name">苹果股价</span> <span class="stock-price">$121.95</span> <span class="stock-change">+0.01</span> </li> <li class="stock" data-stock-id="MSFT"> <span class="stock-name">微软股价</span> <span class="stock-price">$65.78</span> <span class="stock-change">+1.51</span> </li> <li class="stock" data-stock-id="GOOG"> <span class="stock-name">谷歌股价</span> <span class="stock-price">$821.31</span> <span class="stock-change">-8.84</span> </li> </ul>
格式化数据
一些基本的辅助函数:
function addStockName(stock) { // 新增股票 return setProp( "name", stock, stock.id ); } function formatSign(val) { // 格式化股价($) if (Number(val) > 0) { return `+${val}`; } return val; } function formatCurrency(val) { // 格式化股价($$) return `$${val}`; } function transformObservable(mapperFn,obsv){ // 返回 observable 监听(通过 RxJS,第六篇提过) return obsv.map( mapperFn ); }
我们 mock 从服务器获取的数据是这样的:
stock = { id: "AAPL", price: 121.7, change: 0.01 }
在把 price 的值显示到 DOM 上之前,需要用 formatCurrency(..)
函数格式化一下(比如变成 "$121.70"),同时需要用 formatChange(..)
函数格式化 change 的值(比如变成 "+0.01")。但是我们不希望修改消息对象中的 price 和 change。故写一个辅助函数如下:
function formatStockNumbers(stock) { var updateTuples = [ [ "price", formatPrice( stock.price ) ], [ "change", formatChange( stock.change ) ] ]; return reduce( function formatter(stock,[propName,val]){ return setProp( propName, stock, val ); } ) ( stock ) ( updateTuples ); }
我们创建了 updateTuples 元组来保存 price 和 change 的信息,包括属性名称和格式化好的值。把 stock 对象作为 initialValue,对元组进行 reduce(..)
操作(参考第 5 篇)。把元组中的信息解构成 propName 和 val,然后返回了 setProp(..)
调用的结果,这个结果是一个被复制了的新的对象,其中的属性被修改过了。(可理解为深拷贝的进阶版)
我们基于以上再定义几个辅助函数:
var formatDecimal = unboundMethod( "toFixed" )( 2 ); var formatPrice = pipe( formatDecimal, formatCurrency ); var formatChange = pipe( formatDecimal, formatSign ); var processNewStock = pipe( addStockName, formatStockNumbers );
formatDecimal(..)
函数接收一个数字作为参数(如 2.1)并且调用数字的 toFixed( 2 ) 方法。unboundMethod(..)
函数用来创建一个独立的延迟绑定函数。formatPrice(..),formatChange(..) 和 processNewStock(..)
都用到了pipe(..)
来从左到右地组合运算。
操作 DOM
下一步,定义一些操作 DOM 的辅助函数:
function isTextNode(node) { return node && node.nodeType == 3; } function getElemAttr(elem,prop) { return elem.getAttribute( prop ); } function setElemAttr(elem,prop,val) { // 副作用!! return elem.setAttribute( prop, val ); } function matchingStockId(id) { return function isStock(node){ return getStockId( node ) == id; }; } function isStockInfoChildElem(elem) { return /\bstock-/i.test( getClassName( elem ) ); } function appendDOMChild(parentNode,childNode) { // 副作用!! parentNode.appendChild( childNode ); return parentNode; } function setDOMContent(elem,html) { // 副作用!! elem.innerHTML = html; return elem; } var createElement = document.createElement.bind( document ); var getElemAttrByName = curry( reverseArgs( getElemAttr ), 2 ); var getStockId = getElemAttrByName( "data-stock-id" ); var getClassName = getElemAttrByName( "class" );
这些函数不言自明,从函数命名上就可看出其含义。
以上标出了操作 DOM 元素时的副作用。因为不能简单地用克隆的 DOM 对象去替换,所以勉强接受了一些副作用的产生。但如果在 DOM 渲染中产生一个错误,这样做,我们可以轻松地搜索这些代码注释来缩小可能的错误代码。
查找特定 DOM
现在,我们用 getDOMChildren(..)
实用函数来定义股票行情工具中查找特定 DOM 元素的工具函数:
getDOMChildren(..)
用 listify(..)
来保证我们得到的是一个数组(即使里面只有一个元素)。flatMap(..)
,这个函数把一个包含数组的数组扁平化,变成一个浅数组。Array.from(..)
把这个数组变成一个真实的数组(而不是一个 NodeList)。
var getDOMChildren = pipe( listify, flatMap( pipe( curry( prop )( "childNodes" ), Array.from ) ) ); function getStockElem(tickerElem,stockId) { return pipe( getDOMChildren, filterOut( isTextNode ), filterIn( matchingStockId( stockId ) ) ) ( tickerElem ); } function getStockInfoChildElems(stockElem) { return pipe( getDOMChildren, filterOut( isTextNode ), filterIn( isStockInfoChildElem ) ) ( stockElem ); }
getStockElem(..)
和 getStockInfoChildElems(..)
两个实用函数都会过滤掉文字节点,保证返回一个符合股票代码的 DOM 元素数组。
主函数
我们用 stockTickerUI 对象 来保存三个修改界面的主要方法,如下:
var stockTickerUI = { updateStockElems(stockInfoChildElemList,data) { // 渲染股票数据到 DOM 元素上 // .. }, updateStock(tickerElem,data) { // 更新股票数据 // .. }, addStock(tickerElem,data) { // 添加新股票 // .. } };
updateStockElems
updateStockElems(..)
内部实现:
var stockTickerUI = { updateStockElems(stockInfoChildElemList,data) { // **释义1** var getDataVal = curry( reverseArgs( prop ), 2 )( data ); // **释义2** var extractInfoChildElemVal = pipe( getClassName, stripPrefix( /\bstock-/i ), getDataVal ); // **释义3** var orderedDataVals = map( extractInfoChildElemVal )( stockInfoChildElemList ); // **释义4** var elemsValsTuples = filterOut( function updateValueMissing([infoChildElem,val]){ return val === undefined; } ) ( zip( stockInfoChildElemList, orderedDataVals ) ); // **释义5** compose( each, spreadArgs ) ( setDOMContent ) ( elemsValsTuples ); }, // .. };
释义:
getDataVal(..)
:首先把 prop 函数的参数反转,柯里化后,把 data 消息对象绑定上去,得到了 getDataVal(..) 函数;extractInfoChildElemVal(..)
:接受一个 DOM 元素作为参数,拿到 class 属性的值,然后把 "stock-" 前缀去掉,然后用这个属性值("name","price" 或 "change"),通过 getDataVal(..) 函数,在 data 中找到对应的数据;orderedDataVals(..)
:这么做的目的是按照 stockInfoChildElemList 中的 <span> 元素的顺序从 data 中拿到数据。我们对 stockInfoChildElemList 数组调用 extractInfoChildElem 映射函数,来拿到这些数据;elemsValsTuples(..)
:我们用来过滤掉数据对象中值为空的元组,筛选后的结果是一个元组数组(zip压缩:[ <span>, ".." ]
);- 最后调用,我们更新 DOM 中的 元素:
updateStock
updateStock(..)
,是三个函数里面最简单的:
var stockTickerUI = { // .. updateStock(tickerElem,data) { var getStockElemFromId = curry( getStockElem )( tickerElem ); var stockInfoChildElemList = pipe( getStockElemFromId, getStockInfoChildElems ) ( data.id ); return stockTickerUI.updateStockElems( stockInfoChildElemList, data ); }, // .. };
- 柯里化之前的辅助函数
getStockElem(..)
,传给它 tickerElem,得到了getStockElemFromId(..)
函数,这个函数接受 data.id 作为参数。 - 把 <li> 元素(其实是数组形式的)传入
getStockInfoChildElems(..)
,我们得到了三个 子元素,用来展示股票信息,我们把它们保存在stockInfoChildElemList
变量中。 - 然后把数组和股票信息 data 对象一起传给
stockTickerUI.updateStockElems(..)
,来更新 中的数据。
addStock
addStock(..)
,会根据新的股票信息生成一个空的 DOM 结构,然后调用 stockTickerUI.updateStockElems(..)
方法来更新其中的内容。
var stockTickerUI = { // .. addStock(tickerElem,data) { // **释义1** var [stockElem, ...infoChildElems] = map( createElement ) ( [ "li", "span", "span", "span" ] ); // **释义2** var attrValTuples = [ [ ["class","stock"], ["data-stock-id",data.id] ], [ ["class","stock-name"] ], [ ["class","stock-price"] ], [ ["class","stock-change"] ] ]; // **释义3** var elemsAttrsTuples = zip( [stockElem, ...infoChildElems], attrValTuples ); // **释义4** // 副作用!! each( function setElemAttrs([elem,attrValTupleList]){ each( spreadArgs( partial( setElemAttr, elem ) ) ) ( attrValTupleList ); } ) ( elemsAttrsTuples ); // **释义5** // 副作用!! stockTickerUI.updateStockElems( infoChildElems, data ); reduce( appendDOMChild )( stockElem )( infoChildElems ); tickerElem.appendChild( stockElem ); } };
释义:
- 我们先创建 <li> 父元素和三个 <span> 子元素,把它们分别赋值给了 stockElem 和 infoChildElems 数组;
- 为了设置 DOM 元素的对应属性,我们声明了一个元组数组组成的数组。按照顺序,每个元组数组对应上面四个 DOM 元素中的一个。每个元组数组中的元组由对应元素的属性和值组成:
- 我们把四个 DOM 元素和 attrValTuples 数组
zip(..)
起来; - 把属性和值设置到每个 DOM 元素上;
- 调用;
实现结果是:
[ [ <li>, [ ["class","stock"], ["data-stock-id",data.id] ] ], [ <span>, [ ["class","stock-name"] ] ], .. ]
到此为止,我们有了 <span> 元素数组,每个元素上都有了该有的属性,但是还没有 innerHTML 的内容。这里,我们要用 stockTickerUI.updateStockElems(..)
函数,把 data 设置到 <span> 上去,和股票信息更新事件的处理一样。
小结
以上,我们模拟了股票数据操作场景下是如何进行函数式编程的!可能读起来有些晦涩,但是老话说的好:书读百遍,其意自现。 看多了,看久了,也自然能感知一二!
如果你一直以来惯用命令式编程,可以设想下,在命令式编程中,它们会怎么去实现的?
其实,还有一个很重要的点,上面没写,即实现 observable 订阅功能,把事件传递给主函数。
所以,这里的范例更推荐去往项目【ch11-code】 整体阅读!
此篇示例更多的是把前篇概念联系起来,提供更真实的例子来学习。
在你豁然开朗以前一定要持续不断地练习!!
函数库
至此,你应该已经明白:上面提到的辅助函数(高级函数)的确很厉害,但是我自己写不出来,怎么办呢?
没关系,使用武器的人不需要知道武器是怎么生产出来的!
没错,我们先要学会使用这些函数式编程中强大的第三方武器库!
然后再去谈,善用武器的人都应该知道武器原理 Balabala~
它们是:
- Ramda:通用函数式编程实用函数
- Sanctuary:函数式编程类型 Ramda 伴侣
- lodash/fp:通用函数式编程实用函数
- functional.js:通用函数式编程实用函数
- Immutable:不可变数据结构
- Mori:(受到 ClojureScript 启发)不可变数据结构
- Seamless-Immutable:不可变数据助手
- tranducers-js:数据转换器
- monet.js:Monad 类型
更多关于 Monad,挖个坑,后面填。
总结
本阶段关于函数式编程已经说了这么多了~最后的最后,来点形而上学,高屋建瓴。
我不需要再为各位开发者想出更多崇高的理由来激励大家前行。感谢大家一起参与学习 JavaScript 中的函数式编程。期望你我充满希望!—— cognitect-lab
当你在绝望和沮丧的低谷时,别停下来。前面等待你的是一种更好的思维方式!
可以预见的是,本瓜后期还会多次针对 JS 的函数式编程进行更多探究和分享!
感谢阅读!
掘金安东尼,输出暴露输入,技术洞见生活!再会~