那些年我关注过的NBA球星

我不喜欢用"那些年"这类很直白缅怀过去的字眼做标题,但是折腾许久才慢慢意识到现在的我已经找不着更好一点儿的词汇了。其实这就是一篇缅怀过去的一篇文章,并且是关于 NBA 球星的。因为我意识到自己已经很难和过去一样关注 NBA 赛事的输赢,NBA 于我渐行渐远。

第一次接触

首次接触 NBA 是在 2007 年,同学的一份体育类报纸被我当作隔离水泥的墙纸贴在宿舍的墙上。

因为家距离学校不足一公里,我成为班级里极少数的"走读生"。但是学校还是给我预留了一个床位,或许嫌弃家附近的塑料工厂太吵,也可能是觉得晚自习十点后的夜太黑,亦或是思索着换种方式能与同学走得更近些,毕竟居住在一室共同的话题才多。总之,我在学校宿舍待了一段时间,每夜入睡前都会不经意间瞧见墙上的那图、那文字。

科比・布莱恩特 vs. 拉沙德・刘易斯
科比・布莱恩特 vs. 拉沙德・刘易斯

报纸封面的新闻是关于拉沙德・刘易斯跟魔术队签了一份价值超亿的转会合同。虽然玩篮球有很长一段时间了,体验过扔进框的喜悦,享受过拼抢的激动;但那时我不知道世界上有 NBA 这类东西、这个联盟。我很好奇这个人有多厉害,一个运动员能值这么大价值?

刚刚步入高中的我后来也发现,了解 NBA 资讯越多就能与同学有更多的共同话题,特别是体魄强壮的男生。往后周六日,我也愈来愈关注 CCTV5 频道、慢慢准时追着 NBA 球赛。

球赛是无聊的,远没有自己在球场扔球来得好玩。追比赛的动力只为了上学第一天的周一能与同学有更多的谈资:谁赢了?哪个球星得了多少分?哪只队伍还有进季后赛的机会。

球赛是无聊的,我坚持了一段时间就再也没接触 NBA 球赛了。因为懂得少,因为自己打篮球只是扔个球,因为比赛的输赢好像跟我无关。大家都喜欢科比、麦迪、詹姆斯、加内特,可是他们距离我好远。但是了解到身边存在着许多热衷篮球的同学,不论高矮、性别、成绩,喜欢的球队赢了关注的球员表现亮眼了都会很开心。

球赛是无聊的,某个周六上午最后一节课刚刚结束,同桌迫不及待地悄悄跟我说"阳儿,下午打篮球不?我借到了一个篮球下午可以来"。果然,我居然没有注意到在他课桌的死角内放置着一个起着毛刺的篮球,我们课堂上时常喊着饥饿其实就是为了能够在操场上流汗。

这种邀请我是不会拒绝的,从下午两点玩到六点,要不是快到饭点我们是不会离开的。
同桌是真心爱篮球的,一直嫌弃自己一米七八的身高还不够高,因为喜欢加内特能够在一个夏天把自己晒得黝黑黝黑的。
于我而言,这比看篮球比赛有意思多了,挣着球一群人流着汗...

中国队

08 年是中国多灾多难的一年,也是我存储许多记忆的一年。
年初的南方大雪,寒假在家的我担心父亲还能不能回家过年,给我准备新衣裳、发压岁钱。
刚开学不久,新闻里播着西藏拉萨打砸抢烧暴力事件,文科老师偶尔也在课堂上义愤填膺。

当我紧张忙着午间休憩时,又听闻四川汶川地震。幸好在四川的外婆没啥事。趁着暑假,和妹妹二人勇敢地去了一趟四川德阳。经历过几次余震,却无意识畏惧,日常就是和妹妹在德阳街道图书馆看书,在家追着动漫,对川式水煮五花肉也终身难忘,刺激味蕾的记忆是挥之不去的。

北京奥运
2008・北京奥运

最重要的还是北京奥运啦,"北京欢迎你"的调调随处都存在着。

那个阶段我关注的是刘翔的 110 米栏、中国足球、中国篮球。等待了一上午,刘翔没有完成比赛,心中难以释怀——或许1356就不应该押付到一个人的身上。
足球是对阵美丽遥远的新西兰,拿下奥运首个进球的中国队没有拿下比赛;中国队的实力怎么这么不济,这是我难以接受的事实。还好,篮球比赛是很精彩的。

印象最深刻的是中国对战美国和中国对战西班牙——对战世界第一、第二。
第一例进球是姚明的三分,赛前我构思了第一个球的各种场景,却没猜测到居然会是姚明的三分。
朱芳雨、王仕鹏的手感好好,各种进;看着矮矮的刘炜,运起球来各种潇洒;易建联、王治郅、孙悦与美国全明星对位起来一点儿也不虚。
双方有来有回不可开交,虽然半场之后中国队慢慢落入下风。
确实我们都知道这是一场结果赤裸裸的比赛,但还是为中国队的球员骄傲、发自内心的。更何况,对阵西班牙的比赛中国队差点还赢了。

原来篮球这么好看,哦不原来打篮球的那一群人是如此优秀。

季后赛

学业的压力徒增,生活中面临的诱惑也多了许多,流汗的疯狂却越来越少;不过,看 NBA 球赛却成为学习之外的日常。

季后赛姚明迎接湖人队防守
季后赛姚明迎接湖人队防守

〇九上半年,班级中最具人气的球员麦迪被交易走了,没有麦迪的火箭队其实更强大。季后赛与湖人挣抢七让我见识到竞技体育的韧性,记忆最深刻的是那一场比赛好多同学凌晨就逃离校园去看比赛了,期待奇迹却没有奇迹。

勒布朗・詹姆斯 vs. 拉沙德・刘易斯
勒布朗・詹姆斯 vs. 拉沙德・刘易斯

另外一边则是詹姆斯的骑士和魔术鏖战着,我知道有许多人不喜欢詹姆斯,但是看过那一轮系列赛的我对他的评价始终是无与伦比的优秀。有第二场绝杀转身举指的怒吼,也有第六场的黯然离去,上演着我以为只有电影里面才有的剧情。

季后赛科比:湖人 vs. 魔术
季后赛科比:湖人 vs. 魔术

〇九总决赛的主角是科比,第一场科比求胜的眼神告诉我"第二名才是真正的输家"不是简单说说而已。熬过巴蒂尔和阿泰斯特防守,那个时期的联盟还有什么组合能够阻止科比夺冠的心。第二年依旧是科比与凯尔特人大战七场,科比上演了自己曼巴式复仇,不过看着他们这群球员比赛真的好可爱。

一一年是是神奇的逆转,当小牛夺冠诺维茨基第一时间离开球场;拉起球衣捂住脸掩盖自己失控的情绪。〇六年总决赛被翻盘,〇七年被勇士"黑八",一〇年又被马刺"黑七",没有被打入谷底的耻辱,怎会有攀上顶峰的泪水。

一二年在热火的詹姆斯终于拿到自己想要的荣誉,虽然我不认同"无冠之王不是王"的价值观。

一三年,雷・阿伦的神奇三分与吉诺比利神一般的表现以及邓肯最后一场最后一刻不甘地捶地板。马刺太老了、GDP 时代结束了 …

一四年,去年马刺的失利让我耿耿于怀了许久,想不到今年能够卷土重来,我好害怕马刺再输。但总决赛阵容与去年一般,我告诉我自己这个系列赛我一场都不愿意错过。
可惜,最后一次比赛却发生在我毕业答辩时刻...

马刺夺冠
马刺夺冠:"每一次的失败都是下一次的卷土重来"

马刺夺冠时刻就是我毕业答辩的时刻。总决赛的最后一场,我在答辩室盯着 3G 网络手机中的文字直播,可是答辩的次序被安排在了上午。我的内心是紊乱的,毕业设计做得不够好,害怕被老师刁难,又担心马刺不能赢。想不到大学的最后时刻是如此纠结的体验,庆幸结局都是美好的。

最后

未来很长的一段时间我可能会很难再关注 NBA 联赛,吸引我的是某些球员,让我不舍的是那碎片一样的岁月,而非 NBA 联赛,无关篮球。追求自己想要、喜欢的路途感觉真好,虽然会一路波折、忐忑、耻辱。

版权声明:文中涉及图片素材均来源于Zimbio,版权归属于Zimbio

前端工程化开发方案 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