前端工程化开发方案 app-proto

本文是针对去年(2016 年 10 月)美团点评技术沙龙第 13 期分享的《前端工程化开发方案 app-proto 介绍》整理而来的技术博客。
年初的时候又针对 Koa2.0 做了些许语法适配和完整的 SSR 支持(app-proto-2.0)。

什么是前端工程化?根据具体的业务特点,将前端的开发流程、技术、工具、经验等规范化、标准化就是前端工程化。
它的目的是让前端开发能够"自成体系",最大程度地提高前端工程师的开发效率,降低技术选型、前后端联调等带来的协调沟通成本。

美团点评厦门智能住宿前端研发团队通过多个前端项目开发的探索和实践,基于"约定优于配置"(Convention Over Configuration)的原则制定了一套前端工程化开发方案 app-proto。本文将简要介绍其中的一些设计细节和约定。

面临的业务特点

智能住宿前端团队承担的前端业务主要面向 B 端项目,用户主要是商家、销售、运营、产品经理以及研发人员。

诸如工单管理、信息管理、门锁运营、PMS(Property management system)、CRM(Customer relationship management)及 AMS(Asset management system)等项目都是单页面工具类应用,特点是功能交互繁多、复杂表单,非展示类、无 SEO(Search engine optimization)需求。

如果这些项目脱离浏览器这个"外壳",与传统的原生桌面 GUI 软件无异。换言之,这些项目就是一种运行于浏览器的工具软件。

实际上,部分项目我们也确实利用 CEF(Chromium Embedded Framework)等技术给其套个"外壳",当作传统的桌面 GUI 应用提供给用户使用。

同时,部分服务需要从智能门锁、控制盒 Wifi 等硬件设备收录状态数据,限于硬件环境测试的不稳定性,后端的开发测试周期远比前端开发周期长。大部分场景下,前后端需并行开发,后端工程师并不能在第一时间兼顾到前端所需的 API 接口等服务,给前端开发造成没有必要的"等待期",影响开发进度。

此外,项目多、敏捷需求多、开发周期短以及面向多后端服务(多个后端团队)等也是我们前端研发团队面临的挑战。

一些前端经验总结

针对多个项目的开发实践和探索,我们在对前端工程化设计中得到如下一些经验总结:

  • 前端开发应该"自成体系"(包括构建、部署及前端运维),不应该和后端项目耦合在一起。
  • 避免"大而全"的重量级框架,一个框架真的满足不了所有的业务场景。项目多了,我们又不想为每个新项目重新造一遍技术"轮子"。
  • 新的前端技术(ReactVueAngular2等)和工具(Grunt/gulpwebpackBabel等)不断涌现、迭代,新技术选型应避免"改头换面"式重构。
  • 工程化设计要合理分层且相互独立,随时应对新需求和技术的变化,任何一层能够低成本被替换、淘汰。

设计概览

目前,app-proto 将前端工程化项目拆分成三大模块:Node 服务(负责数据代理、url 路由和服务端渲染)、Web 应用开发(专注 Web 交互体验)以及前端运维(构建、测试、部署及监控等)。整体的结构设计如图 1 所示。

Structural Design
app-proto 结构设计图
  • Node 服务:用于实现前后端分离,核心功能是实现数据代理中转,附带 url 路由分发和服务端渲染功能。
  • Web 应用开发:纯粹的前端模块,给予前端工程师极大的自由度进行技术选型,专注于 Web 交互体验的开发。
  • 前端运维:主要指前端项目构建和部署、工程质量(源码质量检查和测试等)及监控服务(日志、性能等)等工作。

前后端分离

正如前文所强调的,前端模块开发应该"自成体系",而不是后端项目的一部分(Controller 或 View 层)。比如说,前端工程师要在本地跑通完整的项目,就必须配置好后端所需开发环境和各种服务,如果后端涉及的服务多、变化频繁,配置开发联调环境工作往往是耗时耗力的。为了实现彻底的前后端分离,我们在前端开发体系中引入了 Node 服务层。

在最初的开发中,为了降低 Node 端的开发和运营成本,我们极力避免在 Node 服务中"掺合"过多的业务逻辑。经过几个项目的实践,最后"约定"在 Node 服务中我们仅仅做三件事:数据代理、路由分发和服务端渲染。

数据代理

首先,前端数据从何而来?通过 Ajax 的形式直接从后端服务中获取数据是传统的方式,但是在应对多后端服务时,还是面临着诸如请求认证、CORS(Cross-origin resource sharing)等困扰。常见的解决方案是通过http-proxy,即在 Node 端通过 HTTP 请求得到数据后,Web 端再通过 Ajax 的方式从 Node 端间接获取后端数据,Node 服务起到"桥梁"的作用。

方案http-proxy对已经成熟的后端服务是具备实用价值的,但是在后端服务并没有完成开发(或前后端并行开发)的场景下时,开发阶段前端的数据来源依旧是个问题。同时,前端还面临诸多请求合并、缓存等需求,解决这些困扰,前端工程师需要和后端技术人员做大量的沟通、约定。

在这里,我们基于原有的http-proxy基础上在 Node 服务中添加datasources模块,尝试在数据的处理上给予前端工程师很大的自由度,并实现"按照约定写代码"。

举例说明,开发某一前端业务时涉及到pmsupm两个后端服务,且提供的 API 内容如下:

# pms API
pms/api/v2.01/login
pms/api/v2.01/inn/create
pms/api/v2.01/inn/get

# upm API
upm/api/v3.15/menu

面对这些接口,理想情况下前端直接通过ajax.post('pms/api/v2.01/login', params)方式获取即可。但是,pms接口服务尚处在开发阶段,面临跨域或不可用问题。upm接口服务虽稳定,但是该服务由第三方团队维护,请求需要权限认证。传统的 Ajax 方式在这类场景下并不适用。而datasources模块是通过怎样的设计来优化这些问题的呢?首先,我们将前端需要的 API 映射到前端源码仓库,映射的目录结构如下:

# server/datasources/{后端系统}/{接口目录}
── datasources
├── pms
│ ├── login.js
│ ├── login.json
│ └── inn
│ ├── create.js
│ └── get.js
└── upm
├── menu.js
└── menu.json

其中,每个**.js后缀的文件的内容是将原本 Web 端 Ajax 操作转移到 Node 端的 HTTP 请求,以pms/login.js为例:

/* async 函数 */
export default async function (params) {
const http = this.http
const pms = this.config.api.pms
try {
const apiUri = `${pms.prefix}/login`
// http 请求:http.post() 方法封装了权限认证
const result = await http.post(apiUri, params)

// 简单的数据格式校验
if (Number(result.status) === 0 && 'data' in result && 'bid' in result.data) {
// 将bid值记录至session
this.session.bid = result.data.bid
}
return result
} catch (e) {
// 后端API出现异常 (实时通知 or 记录日志)
}
return null
}

当然,对于那些已经成熟稳定的 API 服务直接通过http-proxy方式实现数据中转即可。但由于需求变更频繁,后端 API 服务始终处在不断迭代中,前端在进行数据处理过程中总会面临如下的几种情况:

  • 接口校验或数据二次加工:面临多后端服务,API 的格式可能不一致;或者对数据列表排序加工等。
  • 合并请求:可以发多个 http 请求,避免 Web 端同时发送多个 Ajax 请求。
  • 前端运维的数据:比如城市字典、阴阳历转换表等固定数据。
  • 缓存数据:如请求的用户信息,短期内不会有大变动,可以采用Half-life cache等算法实现简单缓存。
  • 需权限认证的接口:HTTP Authentication

这些场景下都建议使用datasources模块进行数据中转,将原本需由前后端沟通协调才能实现的功能全部交给前端自行处理,给予前端工程师处理数据提供自由度的同时也降低了后端 API 的开发维度。

那该如何快捷地调用datasources目录下的async函数呢?这里我们做了简单封装,将该目录下的所有**.js文件解析到 Koa 的上下文环境中以this.ds对象进行存储,并按照目录结构进行驼峰式(Camel-Case)命名,转换过程见图 2。

datasources-recipes
datasources 目录解析转换过程

在 Koa 中间件中通过this.ds对象调用,比如src/datasources/pms/login.js函数映射至this.ds.PmsLogin()

// Koa Middlewares
app.use(async (ctx, next) => {
// ..`.
// 最后一个参数为是否使用mock
const loginData = await this.ds.PmsLogin(params, false)
// ...
})

在 Web 端可以统一封装ds()方法,无需关注 Ajax 请求Headers、是否跨域等问题:

// Web (Browser)
ds('PmsLogin', { username, password }, true).then(success).catch(error)
Mock 支持

正如前文所提到的,后端研发进度一般滞后于前端,在后端 API 服务可用之前,前端仅有一份 API 文档供参考。在规范中,**.json后缀的文件就起到 Mock 作用,同样以pms/login.json举例:

{
"status": 0,
"message": "成功",
"data": { "bid": "@string(32)", "innCount": 1 }
}

具体的json格式写法请参考mockjsSyntax Specification

简言之,当 API 服务可用时则执行**.js后缀文件中的async函数来获取数据,不可用时则解析**.json后缀 Mock 文件,并不需要单独开启一个 Mock 服务。

路由分发

对 url 路由的处理和数据代理的做法类似,按照目录结构来管理。url 路由配置在server/pages目录下,目录下的文件会自动映射成为路由。

比如 url 为http://example.com/pms页面,映射到server/pages/pms.js文件的写法如下:

export default {
urls: ['/pms', '/pms/error'], // 多种正则如:['/pms', ['/pms/v1'], ['/pms/v**']]
methods: ['GET'], // 多种method:['GET', 'POST']
js: ['http://code.jquery.com/jquery-1.12.0.min.js'],
css: ['http://yui.yahooapis.com/pure/0.6.0/pure-min.css'],
template: 'default', // 服务端渲染模板
middlewares: [], // 针对本页面的中间件
controller: async function (next) {
// Koa中间件最后一环
// 可以从this.ds对象中拿数据
const loginData = await this.ds.PmsLogin(params)
return { foo: '来自服务端数据', loginData }
},
}

由于urls支持多种正则,原则上每个根 url 映射server/pages/目录下一个**.js文件,映射关系如图 3 所示。

pages-recipes
pages目录文件与url映射关系

如果对jscsstemplate没有特殊设置(采用默认设置)的情况下,可精简如下:

export default {
urls: ['/pms', '/pms/error'],
controller: async function (next) {
const loginData = await this.ds.PmsLogin(params)
return { foo: '来自服务端数据', loginData }
},
}

需要注意的是,controller项是 Koa 中间件的最后一环,要求其返回值是可序列化的对象用于模板渲染的服务端参数,在此处也可以进行权限校验、从this.ds对象中拿数据等操作。

服务端渲染

Node 服务端最后一个核心功能是渲染:输出 HTML Shell 和 JSON。输出 JSON 字符串的用途是为了浏览器端能以 Ajax 形式动态获取数据,而输出的 HTML 内容则是我们 Web 应用的所需的 HTML"壳子"。

正如前文提到我们的业务特点是"一种运行于浏览器的工具软件",重操作交互、无 SEO 需求。因此,同构(Isomorphic JavaScript)不是强需求,不是每次都要依赖服务器来重复处理逻辑和数据。服务端只需要渲染简单完善的 HTML 结构即可,具体的页面内容则由客户端 JavaScript 实现。简言之,不鼓励将前端 JavaScript 脚本再在 Node 服务端重复执行一遍。

如果了解过 Google 推崇的 Progressive Web App,你可以参考《The App Shell Model》一文来理解 HTML"壳子"更多的用途。

渲染最简单的 HTML"壳子"如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>app-proto</title>
<script>
window.serveData = { foo: '来自服务端数据' }
</script>
</head>
<body>
<div id="app"></div>
<script src="//cdn/file-5917b08e4c7569d461b1.js"></script>
</body>
</html>

提供简单的服务端数据window.serveData供客户端使用,更多渲染则由//cdn/file-5917b08e4c7569d461b1.js进行增量控制。

静态资源与 Node 端衔接

那 Web 端构建的静态资源是如何 Node 服务端做衔接的呢?前端静态资源构建工作与 Node 服务相互分离,Node 服务在开启的过程中会读取前端构建生成的静态资源映射表。前端的构建过程如图 4 所示,在构建工作完成之后会生成assets.json静态资源映射表。

pages-recipes
静态资源映射文件assets.json构建

前端构建工具基本都提供静态资源映射表生成插件,比如构建工具 Webpack 就存在插件assets-webpack-plugin来实现该功能。

生成的assets.json映射表内容参考如下:

{
"index": // 对应的页面(url: example.com/index)
{ "js":"//s0.example.net/pms/index-2abb99.js" }, // 涉及到的静态资源列表(带版本号)
"login":
{ "js":"//s0.example.net/pms/login-5917b0.js" }
}

比如在渲染页面example.com/index时,Node 服务会以index作为键值,读取assets.json中带版本号的静态资源 CDN 地址列表,用于在"壳子"中与前端资源的衔接工作。

Web 端的一些"约定"

Web 端的技术选项是没有强制性限制的,无论你采用何种构建工具、前端库,只要生成符合约定供 Node 端使用的assets.json文件即可。

前端工程师可以根据具体的业务特点、团队技术喜好来选取合理的开发方案,无论是 React、Vue 还是 Angular2 并不做强限制。尽管给予 Web 前端开发很大的自由度,但是鼓励遵循下面几条"约定":

  • Ajax 请求从 Node 端代理,而非具体后端服务。
  • 鼓励将 JavaScript、CSS、HTML 视为前端领域的"汇编"。
  • 重视前端页面状态管理,推荐的方案有ReduxvuexMobX等。
  • 强调组件化,面向组件集开发。

这里重点强调下面向组件集的前端开发。在项目初期我们一般不会马上投入到业务开发,而是针对设计师和产品经理提供的设计稿、产品原型图实现一套组件集或选择合适的开源组件集,积累好基础组件集后再投入到具体业务开发。

在进行前端技术调研时,该技术是否有配套的开源组件集往往是我们考虑的重点。比如基于 React 实现的开源组件集ant.designMaterial-UI等,我们部分前端项目都直接或间接的使用到了,极大地减少了研发成本。

当然,美团点评内部也提供一个组件中心平台(可参考美团点评前端组件中心介绍 Slide),鼓励大家将各自项目中的有价值组件分享出来,实现组件跨项目复用。

工程化支持

项目脚手架

项目脚手架的作用是在启动一个新项目时,通过几个简单命令就能快速搭建好项目的开发环境。我们基于Yeoman构建了一个完整的项目脚手架。

# 安装脚手架
$ npm install -g yo
$ npm install -g @ia/generator-app-proto@latest
# 初始化新项目(进行简单选择)
$ yo @ia/app-proto
工程质量保障

我们重视项目的每次commit,同个项目要求遵循同一套编码规范,并采用ESLint等工具进行约束,对于一些复用性高的核心组件也强制要求写测试。
为保障项目质量,每个项目都要求接入美团点评基于Stash实现的Castle CI 系统,每次的源码提交都会自动执行一遍 ESLint、测试和构建,并生成构建日志通过公司内部沟通工具大象进行实时消息推送。

标准化测试环境管理

美团点评内部提供了基于 Docker 实现的测试环境管理服务 Cargo,用于提升测试和联调测试效率,促进 DevOps 开发模式。将项目接入到 Cargo 服务后,只需在仓库中提供简单的配置文件cargo.yml(配置参考如下),就会自动生成一套测试环境。

# 依赖的镜像
image: registry.cargo.example.com/node:v4.2.1
# 容器占用的端口
ports:
- '8998'
# 环境变量
env:
- COMMON_VARIABLE = 'true'
- NODE_ENV = 'cargo'
- DEBUG = 'app-proto,datasource.*'
# 收集的日志文件
logs:
- error = /var/path/logs/app-proto/error.log
- out = /var/path/logs/app-proto/out.log
# 构建脚本
build_script: bin/pre-deploy-staging
# 运行脚本
run_script: bin/cargo-start

总结

前端工程化体系的引入,让前端开发能和原生 App 应用项目开发一样"自成体系",脱离了对后端项目的依赖。基于"约定优于配置"、"按照约定写代码"的原则对 Node 层功能的设定能够降低沟通协调成本,构建、部署等工作的规范化,使前端技术人员的开发重点回归到 Web 应用的交互体验本身,回归到"纯粹"的前端研发。

JavaScript 異步編程小結

JavaScript 是單線程的,除了你的 JS 代码,其它操作都是并行执行的(everything runs in parallel except your code)。

在 JS 執行線程中進行的行為被稱作同步(Synchronous)操作,非 JS 執行線程執行的行為則被稱呼為異步(Asynchronous)操作。
諸如 Ajax/HTTP 請求、I/O 操作等行為均與 JS 執行線程無關(由自己獨立的線程進行運作),這些行為在執行完成之後會將結果通知到 JS 執行線程;
因此,JS 執行線程中會有個類似while(true)的循環,以觀察者的姿態監聽(轮询)是否有其它線程傳遞消息過來,一旦捕獲到則執行本 JS 執行線程中相應的函數塊(回調)。

JavaScript 事件循環不是本文的重點(JavaScript Event Loop),本文僅對前端異步編程進行些許總結。個人的理解是 JavaScript 異步編程方式只有兩種方式:回調和觀察者模式。需要注意的是:

  • Promises/A+ 是如何優雅地使用回調而設計的一種編程規範,本質依舊是回調
  • 事件監聽和觀察者模式(發佈/訂閱模式)完全可以理解成是“一個孩子的不同暱稱”
  • Generators 是一種特性,實現函數在執行過程中暫停、並在將來的某個時刻恢復執行的功能
  • Generators+Promises 可以搭配漂亮的語法糖,將異步源碼寫得像同步源碼

Callback Functions

函數式編程中有個概念叫做高階函數(Higher-order Functions),其有個特性是一個函數可以作為另外一個函數的參數。通常我們將那個作為另外一個函數參數的函數稱呼為回調函數。

為方便描述和解釋,此處模擬一個具體的業務場景:通過 Ajax 方式請求**/api/v1.0/user/{id}接口獲取某個用戶的信息(Asynchronous behavior),然後針對拿到的用戶信息進行後續的處理。
典型的做法是將Ajax異步請求之後進行的操作封裝成callback()函數,在接口訪問成功得到用戶信息之後再執行該函數:

function getUserInfoCallback(id, callback) {
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: data => callback(null, data),
error: (xhr, textStatus, errorThrown)
=> callback(new Error(textStatus), errorThrown),
})
}
getUserInfoCallback('10086', handleUserInfo)

Promises/A+

An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.

濫用回調帶來的問題是代碼邏輯耦合度很高,面臨回調災難。Promises/A+是種合理使用回調的規範,避免回調的濫用。

特點一:提供好看的 API,由嵌套回調(callback hell)轉向鏈式語法

首先將請求用戶信息的 Ajax 異步操作包裝成一個 Promise 實例,後續的同步行為通過該實例對象的then()方法調用。

function getUserInfoPromise(id) {
return new Promise((fulfill, reject) => {
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: fulfill,
error: reject,
}) // end $.ajax
}) // end return
} // end getUserInfoPromise

getUserInfoPromise('10086')
.then((userInfo) => handleUserInfo)
.catch(console.log)

特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果

假設存在這樣的一個業務場景:獲取用戶 id 為10086的用戶信息,然後在不同的兩個階段對其異步操作獲取的用戶信息進行兩種不同的操作(分別為handleUserInfo()console.log())。
在兩個階段中,都需要異步操作獲取得到的userInfo數據,如果採用傳統回調方式,一般採用閉包的方式緩存userInfo或者暴力點重複進行一次 Ajax 異步請求。

但是採用 Promise 方式,則無需這些很複雜的實現方式,因為可以重複使用 Promise 對象。

// 閉包緩存
let globUserInfo = null
getUserInfoCallback('10086', (userInfo) => (globUserInfo = userInfo))
// 難以保證 globUserInfo 已經更新
handleUserInfo(globUserInfo)
console.log(globUserInfo)

// 進行了兩次異步操作
getUserInfoCallback('10086', handleUserInfo)
getUserInfoCallback('10086', console.log)

// 保存Promise對象
const userInfoPromise = getUserInfoPromise('10086')
userInfoPromise.then(handleUserInfo)
// 可以再次使用`userInfoPromise`對象
userInfoPromise.then(console.log)
  • 這種策略和函數式編程中lazy evaluation概念是類似的,強調call-by-need
  • 也可以對異步操作進行柯裡化(Curring)暫存異步操作的結果(類似的概念還有 thunk,參考node-thunkify)。

特點三:可組合,復用

類似於函數式編程中推廣的從已有的函數中創建新函數,也可以通過已有的 Promise 對象生成新的 Promise 對象。
比如獲取多個用戶信息,可使用Promise.all()方法實現異步操作的組合:

const promises = ['10010', '10086', '10000'].map(
(id) =>
new Promise((fulfill, reject) =>
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: fulfill,
error: reject,
})
)
)
const userInfosPromise = Promise.all(promises).then(console.log).catch(console.log)

感覺上 Promise/A+規範是函數式編程概念在前端領域的一次最佳實踐(回調的語法糖)。更多詳細的內容待補充。

Event Emitters

事件監聽式異步編程本質上還是依賴於回調函數實現的,區別在於回調函數並不執行異步行為完成後需要的操作,而是發佈一個通知去觸發執行相應的函數。

import EventEmitter from 'events'
const emitter = new EventEmitter()
// 註冊
emitter.on('event', handleUserInfo)
$.ajax({
url: `**/api/v1.0/user/10086`,
success: (data) => emitter.emit('event', data), // 觸發:異步操作這個行為帶來的影響
error: console.log,
})

事件監聽其實是觀察者模式的一種實現:當一個對象發生變化時,所有依賴他的相關操作都會得到通知,只不過事件監聽弱化了對象的變化而強調行為(對象數據變更也是一種行為)。
比如上面的代碼段強調的是 Ajax 操作這個行為,一旦完成就通知handleUserInfo()函數的調用,並攜帶參數變更對象數據。

如果採用觀察者模式的話,一般這樣直接處理數據(強調數據變化帶來的影響,造成數據變化的場景可能存在多處),然後觸發數據變動後的行為:

let userInfo = null
emitter.on('event', () => handleUserInfo(userInfo))
const updateUserInfo = data => {
userInfo = data // userInfo對象方式變更
emitter.emit('event') // 通知相關依賴的操作:數據變更帶來的影響
}
$.ajax(
url: `**/api/v1.0/user/10086`,
success: updateUserInfo, // 觸發
error: console.log,
})

很明顯,觀察者模式要比事件監聽方式擴充性更強(雖然本質一致,但是強調側重點不同)。

陷入`emit`死循環

事件監聽式異步編程無異於goto語句,稍有不慎形如on()emit()subscribe()publish()等方法摻雜在各處,“剪不清,理還亂”;如果不是“約定”化編程不建議採用。比如下面這段源碼,稍不慎就陷入如圖 1 所示場景。

const emitter = new EventEmitter()
const foo = () => emitter.emit('bar')
const bar = () => emitter.emit('foo')
emitter.on('foo', foo)
emitter.on('bar', bar)
foo() // 陷入死循環

和回調式異步編程(包括 Promises/A+規範)相比,事件監聽式異步編程的軟肋在於需要手動註冊(Manual)。
原本可以通過數據綁定(Data binding)Object.observe()方法來實現觀察者模式,很可惜該方法已被deprecated掉;目前推薦的是getset+Proxy方式實現(相關討論:36258502)。

但是手動維護這些on()emit()get()set()等方法在項目是很折騰的,通過一些第三方工具包可以實現由ManualAutomatic轉變。
比如採用MobX可以實現得更加優雅:

import { observable, autorun } from 'mobx'
const store = observable({userInfo: null})
// 只要變動`store`對象,就會自動觸發`handleUserInfo()`函數
autorun(() => handleUserInfo(store.userInfo))
$.ajax(
url: `**/api/v1.0/user/10086`,
success: data => store.userInfo = data,
error: console.log,
})

Generator

Coroutine 協程 (a.k.a. co-operative routines)

一般程序中,函數調用一定是從頭到尾執行直到遇到return或執行完;
而 coroutine 則容許函數執行到一半時就中斷(yield),中斷時函數內部上下文環境(context)會被緩存下來。
程序主體可以隨時恢復(resume)這個被緩存的 coroutine,繼續從剛才被中斷處執行後續內容。

function* foo() {
console.log('hello')
yield 10086 // 在此處中斷 coroutine
console.log('world')
}

const bar = foo() // 保存 coroutine 內部狀態的變量
bar.next() // 調用`foo()`函數,遇到 yield 中斷程序調用
console.log('main, not in `foo()`') // 已經從`foo()`函數中跳出來了,可以幹些其它事情
bar.next() // 恢復`foo()`的調用,從 yield 中斷處繼續執行

Thread VS Coroutine

With threads, the operating system switches running threads preemptively according to its scheduler,
which is an algorithm in the operating system kernel.
With coroutines, the programmer and programming language determine when to switch coroutines;
in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points,
typically (but not necessarily) within a single thread.

—— stackoverflow: difference-between-a-coroutine-and-a-thread

Generator (a.k.a. semicoroutines) VS Coroutine

Generator 與 Coroutine 的區別是 Generator 只能從上次中斷處繼續執行,而 Coroutine 則沒有這樣的限制(可以指定從哪裡繼續執行)。
因此,Generator 可以視作是 Coroutine 的一種特殊情況,上文涉及的源碼例子其實就是 Generator 的應用舉例。
其中,Generator 涉及bar.next()自動流程管理的解決方案可以參考cothunks等。

async/await “語法糖”

聲明的async函數就是將 Generator 函數和自動執行器包裝在一個函數裡面(參考async2generator()),
以達到異步編碼編程模式與同步編碼一致。

const run = async () => {
const userInfo = await new Promise((fulfill, reject) => {
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: fulfill,
error: reject,
}) // end $.ajax
}) // end return
handleUserInfo(userInfo)
}

不是總結的總結

  • 函數式編程領域的知識還是要多多接觸的。
  • 有些前端領域的新鮮事物在其他領域可能就是些習以為常的東西,擴充知識面很重要。

References

函数式编程之纯函数

数学上的函数指的是两个集合间的一种特殊的映射关系。这个特殊体现在什么地方呢?

我们将集合A的元素称呼为输入值,集合B的元素称呼为输出值,且集合AB存在这样的映射关系:每个输入值只会映射一个输出值,不同的输入值可以映射相同的输出值,不会出现同一个输入值映射不同的输出值

比如,下图集合A和集合B的映射关系即符合数学函数的定义。

containing block
fn:除以5的余数

在函数式编程语言中,满足这种数学意义上的函数即为纯函数(Pure Function):相同的输入(参数),永远得到的是相同的输出(返回值),并且没有任何可观察的"副作用"。
自然,与纯函数的概念相反的函数(即相同参数的函数在不同环境或时机调用得到的返回值不一致)叫做非纯函数(Impure Function)。

关于函数副作用(side-effect)

函数副作用指当调用函数时,在计算返回值数值的过程中,对主调用函数产生附加的影响。

更高作用域的变量"悄悄"发生变更

let glob = 1
function foo(x) {
return ++glob + x
}
console.log(foo(1)) // => 3

变量glob的值随着foo()的调用发生变化,表现得很不明显。

"隐晦"地修改了引用参数

let glob = 1
const obj = { glob }
function foo(x) {
return ++x.glob
}
foo(obj)
console.log(glob) // => 2

虽然对象obj定义为const,但是修改了间接引用的变量glob;这种场景引发的 bug 其实是很难捕获的(尤其是具备指针概念的 C/C++语言)。

函数副作用确实是滋生 Bug 的"温床",造成的问题一般都很"隐晦";有些开发场景中,我们其实也无法避免函数的副作用(典型的例子是读写数据库操作的函数)。最好的做法是,要将这些副作用限制在可控的范围内。

纯函数带来的好处

函数调用结果可缓存

相同参数得到的返回值是相同的。如果通过参数获取返回值的过程计算量过大,我们可以缓存函数调用的结果,避免相同参数为了获取返回值进行重复计算。典型的实践是对递归函数做性能优化的memoize技术。
fibonacci(n)递归函数为例,传统的实现:

function fibonacci(n) {
if (n === 0 || n === 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}

console.log(fibonacci(10))

计算的复杂度以参数n呈指数级增长:

f(0) = 0
f(1) = 1
f(2) = f(1) + f(0) = 1
f(3) = f(2) + f(1) = 2
f(4) = f(3) + f(2)
= f(2) + f(1) + f(2) = 3
f(5) = f(4) + f(3)
= f(3) + f(2) + f(2) + f(1)
= f(2) + f(1) + f(2) + f(2) + f(1) = 5
f(6) = f(5) + f(4)
= f(4) + f(3) + f(3) + f(2)
= f(3) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2)
= f(2) + f(1) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2) = 8
... ...

为了获取fibonacci(n)的结果,我们不得不将fibonacci(n-1)fibonacci(n-2)都得计算一遍;如果我们在调用一次fibonacci(n)之后,就将其缓存起来,下次再调用时就无需重新再计算。稍加改造,添加对计算结果的缓存:

const fibonacci = (function () {
const cache = {}

return function fib(n) {
if (n in cache) return cache[n]
return (cache[n] = n === 0 || n === 1 ? n : fib(n - 1) + fib(n - 2))
}
})()
console.log(fibonacci(10))

这是典型的以空间换效率的优化思路,避免了额外计算的浪费。
这样实现的前提就是,该递归函数是纯函数,相同参数得到的返回值一定是相同的;如果不能保证相同,我们无法做缓存。

当然,我们可以实现一个memoize()函数来统一做缓存这样的工作。
JavaScript 函数式编程支持库如均提供memoize()函数,这里提供一种不太健壮(内存溢出)的实现方案。

function memoize(func) {
const memo = {}
const slice = Array.prototype.slice

return function () {
const args = slice.call(arguments)

if (args in memo) return memo[args]
return (memo[args] = func.apply(this, args))
}
}

这样函数调用的次数愈多效率会慢慢变得愈高。

便于移植和测试

纯函数是"自给自足"的,所有的函数依赖均由函数自身提供(或参数);因此,我们将一个函数移植到另外一个系统时,是无需考虑成本的
——当然,如果一个函数依赖一个全局变量,在移植该函数时必须"慎重",要将这个全局变量的逻辑一起迁移过去。

相同参数得到的函数返回值是固定的,这一特性也使纯函数更易测试——你无需模拟出一些特殊的测试环境,只要明确定义好函数参数的范围即可。

引用透明(Referential Transparent)

An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions.
—— https://en.wikipedia.org/wiki/Referential_transparency

该如何理解呢?可以拿上文提到的fibonacci()函数举例,比如存在这样一个函数:

function foo(n, fun) {
return fun(n) + fun(n)
}

foo(10, fibonacci)

调用foo(10, fibonacci)会发现fibonacci(10)被执行了两遍。因为纯函数具备引用透明性,某些表达式被替换并不会改变函数的行为;因此,对foo()进行些许变动会使其性能得到质的提升。

function foo(n, fun) {
return fun(n) * 2
}

毕竟在此场景中,一次乘法运算成本远比一次fibonacci(10)递归运算的成本来得低。

这里的由fibonacci(n) + fibonacci(n) => 2*fibonacci(n)转变完全跟数学概念中的f(x) = x + x = 2 *x函数推导一致。

因为纯函数的引用透明的特性,我们完全可以将多个函数构成的复杂程序(函数)推导成更加简单的方式。

并行代码

纯函数无副作用,同时调用两个函数或同个函数被同时调用两次都不会抢占外部公共资源的情况。

总结

  • 程序设计中的大部分 Bug 都是有函数副作用引入的,实际开发中必须鼓励纯函数的编写。
  • 在函数式编程范畴中,欲想以函数为基础生成新的函数,那纯函数是这些新函数的基石。
  • 多尝试使用memoize技术对递归函数进行性能优化。