第六章:应用实例
声明式编程
现在我们需要切换思维方式。从现在开始,我们将不再指挥计算机如何完成工作,而是编写期望结果的规范说明。相比事无巨细的管控模式,这种方式会轻松许多。
声明式(与命令式相对)意味着我们通过编写表达式而非逐步指令来实现目标。
以SQL为例,不需要『先执行这个,再执行那个』的操作序列,只需一个表达式来指定从数据库获取的数据。具体工作由数据库自主完成,无需人工干预执行方式。当数据库升级或SQL引擎优化时,查询语句也无需修改。因为同一个规范说明可以通过多种方式解释执行并得到相同结果。
声明式编码的概念对很多开发者(包括我自己)来说需要一定的理解过程,让我们通过几个典型示例来建立直观认知。
// imperative
const makes = [];
for (let i = 0; i < cars.length; i += 1) {
makes.push(cars[i].make);
}
// declarative
const makes = cars.map(car => car.make);命令式循环必须先实例化数组。解释器必须在继续后续操作前先评估这条语句。随后它直接遍历车辆列表,手动增加计数器,并通过显式迭代的方式逐个展示数据片段。
而map函数版本则是单一表达式,无需确定执行顺序。map函数如何遍历以及如何组装返回数组都有极大的自由度。它仅规定做什么,而非如何做,因而体现了纯粹的声明式特质。
除了更加清晰简洁之外,map函数可随时进行优化,应用程序代码无需随之改动。
若有读者认为『命令式循环显然更快』,建议深入了解JIT(即时编译器)如何优化代码。这段精彩视频或许能带来启发。
再看另一个例子。
// imperative
const authenticate = (form) => {
const user = toUser(form);
return logIn(user);
};
// declarative
const authenticate = compose(logIn, toUser);尽管命令式版本并无明显错误,但其内部仍编码了逐步执行的评估逻辑。compose表达式则直述事实:身份验证是toUser与logIn的组合。这种声明方式为支持代码变更留有余地,使应用代码成为高层次规范说明。
上例中评估顺序已被指定(必须先调用toUser再调用logIn),但在许多场景下顺序无关紧要,这正是声明式编码易于表达的典型用例(后续将深入讨论)。
由于无需编码评估顺序,声明式编码天然适配并行计算。此特性与纯函数结合,正是函数式编程在并行未来中占据优势的原因——构建并行/并发系统无需特殊处理。
函数式编程的 Flickr 实践
现在我们将以声明式、可组合的方式构建示例应用。目前仍将使用副作用操作,但会控制在最小范围并与纯代码隔离。目标是创建从Flickr获取并展示图片的浏览器组件。首先搭建应用骨架,HTML结构如下:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Flickr App</title>
</head>
<body>
<main id="js-main" class="main"></main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.2.0/require.min.js"></script>
<script src="main.js"></script>
</body>
</html>main.js架构如下:
const CDN = s => `https://cdnjs.cloudflare.com/ajax/libs/${s}`;
const ramda = CDN('ramda/0.21.0/ramda.min');
const jquery = CDN('jquery/3.0.0-rc1/jquery.min');
requirejs.config({ paths: { ramda, jquery } });
requirejs(['jquery', 'ramda'], ($, { compose, curry, map, prop }) => {
// app goes here
});我们选用Ramda而非lodash等工具库,它包含compose、curry等函数。此处使用requirejs可能稍显复杂,但为保持全书代码一致性仍保留。
进入需求说明环节,应用需实现四个功能:
- 为指定搜索词构造URL
- 调用Flickr API
- 将返回的JSON数据转换为HTML图片元素
- 将图片渲染至屏幕
上述说明包含两个非纯操作:从Flickr API获取数据与屏幕渲染。我们首先定义这两个操作实现隔离。同时为方便调试添加trace函数。
const Impure = {
getJSON: curry((callback, url) => $.getJSON(url, callback)),
setHtml: curry((sel, html) => $(sel).html(html)),
trace: curry((tag, x) => { console.log(tag, x); return x; }),
};此处对jQuery方法进行柯里化包装,并调整参数位置以获得更优结构。使用Impure命名空间标记这些高危函数。后续示例中将对这些函数进行纯化处理。
接下来构造传递给Impure.getJSON的URL参数。
const host = 'api.flickr.com';
const path = '/services/feeds/photos_public.gne';
const query = t => `?tags=${t}&format=json&jsoncallback=?`;
const url = t => `https://${host}${path}${query(t)}`;尽管可使用幺半群或组合子实现无点风格的url定义,但为保持代码可读性,此处采用常规方式组装字符串。
编写发起API调用并将内容渲染至屏幕的app函数:
const app = compose(Impure.getJSON(Impure.trace('response')), url);
app('cats');此函数先调用url生成URL字符串,再传递给已部分应用trace的getJSON。加载应用后控制台将显示API响应结果。

我们需要将JSON转换为图片元素。mediaUrls似乎深嵌在items数组各元素的media.m属性中。
为提取这些嵌套属性,可使用Ramda提供的通用属性获取函数prop。以下是自实现版本以便理解工作原理:
const prop = curry((property, object) => object[property]);其实现相当简单,使用[]操作符访问对象属性。现在用该函数获取mediaUrls。
const mediaUrl = compose(prop('m'), prop('media'));
const mediaUrls = compose(map(mediaUrl), prop('items'));收集所有items后,通过map遍历提取每个mediaUrl,得到整洁的mediaUrls数组。将此接入应用并在屏幕打印结果:
const render = compose(Impure.setHtml('#js-main'), mediaUrls);
const app = compose(Impure.getJSON(render), url);我们新建的组合函数将调用mediaUrls并通过<main>标签渲染结果。已用render替换trace调用以便展示非原始JSON内容。这将原始mediaUrls简单显示于页面。
最后一步需将mediaUrls转为真实<img>标签。在大型应用中可能选用Handlebars或React等模板/DOM库。本例仅需img标签,故继续使用jQuery实现。
const img = src => $('<img />', { src });jQuery的html方法支持标签数组作为参数,只需将mediaUrls转换为图片元素数组并传入setHtml即可。
const images = compose(map(img), mediaUrls);
const render = compose(Impure.setHtml('#js-main'), images);
const app = compose(Impure.getJSON(render), url);至此应用完成!

最终脚本如下: include
请观察这段优雅的声明式规范——它描述事物之本质,而非实现过程。现在每行代码都可视为具有恒定性质的等式,可利用这些性质进行应用逻辑推导与重构。
《原则性重构》
存在可用map组合律实现的优化机会:当前对每个item映射获取mediaUrl后再次映射生成img标签。关于map的组合律表述如下:
// map's composition law
compose(map(f), map(g)) === map(compose(f, g));利用此定律可优化代码。现进行原则性重构:
// original code
const mediaUrl = compose(prop('m'), prop('media'));
const mediaUrls = compose(map(mediaUrl), prop('items'));
const images = compose(map(img), mediaUrls);调整map的对齐方式。借助等式推导与纯函数特性,可将mediaUrls的调用内联到images中。
const mediaUrl = compose(prop('m'), prop('media'));
const images = compose(map(img), map(mediaUrl), prop('items'));对齐map后即可应用组合律:
/*
compose(map(f), map(g)) === map(compose(f, g));
compose(map(img), map(mediaUrl)) === map(compose(img, mediaUrl));
*/
const mediaUrl = compose(prop('m'), prop('media'));
const images = compose(map(compose(img, mediaUrl)), prop('items'));现在这个有趣的问题只需一次循环即可将每个item转换为img标签。为增强可读性,我们将函数单独提取:
const mediaUrl = compose(prop('m'), prop('media'));
const mediaToImg = compose(img, mediaUrl);
const images = compose(map(mediaToImg), prop('items'));总结
我们通过实际小型应用演示了新技术的实践运用,利用数学框架进行代码推导与重构。但错误处理与条件分支该如何处理?如何使整个应用纯函数化而非仅隔离非纯函数?如何提升应用安全性与表达力?这些将是第二部分要解决的问题。