在计算机领域,响应式编程是一种异步编程范式,关注数据流和变化的传播。这意味着可以通过所使用的编程语言轻松地表达静态或动态数据流。并且,在关联执行模型中,存在可推断的依赖关系,这有助于自动传播与数据流相关的更改。
响应式编程可以加深代码的抽象程度,使开发人员更专注于业务逻辑,与此同时,还能使代码更加简洁、易用。目前,响应式编程在前端开发、Android 开发中运用广泛,此外,其非阻塞异步编程模型以及对消息流的处理模式在后端也得到了越来越多的应用。然而,响应式编程不是银弹,它的边界在哪?主流框架又受到其何种影响?前端开发还可以从哪些方面进行创新?
带着这些困惑,InfoQ 采访到近期参与 JSConf China 2019 的演讲嘉宾——豆瓣阅读前端负责人马申彦,就响应式编程以及豆瓣阅读的前端实践进行了探讨,在 JSConf 上,他也对响应式编程进行了演讲分享。以下为采访全文整理,希望对正在从事前端开发的你有所帮助。
InfoQ:您在本次 JSConf 的演讲主题是响应式编程,为什么会选择这一话题?将响应式编程的范式应用在前端开发可以解决哪些问题?传统开发模式与响应式开发模式有何区别?
马申彦:响应式编程其实已经拥有了很长的历史,可以追溯到上世纪九十年代,ReactiveX 系列函数库也是早在 2012 年就由微软发布开源,Angular 在从 1.x 升级到 2.0 的时候果断选择了 RxJS 作为其数据处理的工具,想必是经历了一番思索的。Angular 因开发周期过长,期间 React + Vue 双雄崛起,一举拿下了国内大部分市场,但相当一部分企业级应用依然坚持使用 Angular 来保证代码的健壮性和可维护性。豆瓣阅读站内当前采用 React 作为主要 UI 框架,但同时吸收了 Angular 采用的 TypeScript 和 RxJS 来控制代码质量和数据流,取得了不错的成果,这才萌生了向广大前端 er 继续科普 RxJS 及响应式编程的想法。RxJS 本身就是一个基于 TypeScript 开发的框架,所以其代码健壮性很高,而且从 v5 到 v6 通过 pipe 方式载入运算符以后大大缩减了打包后的体积,为前端项目后续优化提供了丰富且宝贵的经验。
在三大框架大行其道的当下,大部分复杂的 DOM 操作都被框架接管了,现在的开发人员主要是基于框架做业务层的开发,但业务层往往数据密集且多变,而且又是与用户直接面对面,需要时刻处理用户的输入。一个比较常见的场景是用户的点击事件触发了一个网络请求继而发生了 DOM 上的变更,这种简单的变动一般一个函数就可以完成,但是遇到复杂的多输入问题,往往会形成函数调用函数难以追溯事件源头的情境。我们前端组在维护历史遗留的 Backbone 代码过程中这种情况时常发生,后来把一系列异步流程用 Promise 来进行管理,确实达到了一定的效果,但我们不禁反思,这种复杂异步操作究竟如何才能做到在多重依赖和并发下的优雅实现,后来我们找到了它,就是响应式编程。
传统开发模式大部分是命令式的,也就是函数的操作方式。响应式和命令式最大的区别在于,命令式是 pull,就是我去调一个函数传递一个参数,最后得到一个值。而响应式的思维方式是完全相反的,它是一个 push 操作,就是我去订阅一个数据源,看它什么时候给我发消息,我会按约定向下继续传递。就像工厂流水线一样,从上游发出原材料经过一层层加工后传递到下游,最后组装成一个东西。具体来说,响应式编程容易让事件的发生和转化过程变得清晰有序,因为它的惰性求值可以做到按需计算,省去传统开发模式中很多无用的中间变量,又因为它几乎是纯函数的,所以也方便构成组件复用于不同的项目。这样我们就着眼于数据的流动而不是时时刻刻思考着用户做了操作之后该执行哪一步,因为这些都是响应式编程的范式内部所解决的问题。
InfoQ:目前,主流前端框架或多或少受响应式编程范式的影响。如何利用好这种范式,写出更优雅易读的代码呢?
马申彦:因为我们组对 React 比较熟悉,就应用 React 做下说明,我们可以从源头说起,大家知道一个 React 组件有 props 和 state 两种储存变量的方式,如何判断究竟是 props 还是 state 呢?有三个问题:它是不是从父组件传递下来?它会随时间变化吗?它可以由其他 props 或者 state 计算而来吗?按照这种思路得到的 state 本身就是一个独立的、随时间不断吐出值的数据流。我们会在设计 state 时有意识地让它们变得正交,也就是彼此独立不相干。但这时问题来了,存在着部分 state 需要跟随 props 的变化进行更新,所以有了 componentWillReceiveProps 这类 lifecycle method,可是其调用方式非常诡谲,初学者使用时不小心就容易形成死循环。
为了解决这些问题,React hooks 应运而生,它就是一个很棒的响应式编程的实践,比如说 useEffect 这个 hooks,它可以观察一组输入,包括 props 或是 state 等,当其中任一输入值发生改变时做一些操作。这组输入就是 single source of truth,真理的唯一源头。而 useEffect 要做的就是处理这些输入转换成输出的过程,这是一个纯函数,就保证了流经它的数据流是被约束的。
可以进一步思考这组输入,通常情况下无外乎网络请求得到的异步数据或是用户输入等,这些都可以称作 inputs,我们可以得到一个公式:
这就和 Redux 的数据模型很像了。
React 和 Redux 本质上都是把一组输入通过一系列变化,同步或者异步地转化成一个 state 的过程,仅仅保证数据的独立其实是不够的,还需要保证转化过程的独立性,这时我们发现了 rxjs-hooks 这个库,它很好地结合了 React hooks 对数据依赖的抽象,以及运用了 RxJS 来处理复杂的异步数据流。特别是 useEventCallback 这个 API,可以把它理解成通常写的 handleClick 函数,只不过它完美地封装了对 props 的依赖和初始 state,然后返回一个 [eventCallback, value] 的数组,只需要将 eventCallback 连接到组件的 event listener 上就可以获取,或者说是订阅了这样一个随着 event 的发生、props 的变动而实时更新的 value,书写 React 异步操作从未如此简单。
InfoQ:响应式编程不是银弹,也有不擅长的领域。您觉得这个边界在哪?如何在开发过程中进行判断?
马申彦:具体来说,响应式编程主要应用在那些随着时间推移会变化的数据上,如果只是一次性的同步计算,那响应式编程将毫无用武之处,当然还是可以用一些运算符来进行规约,毕竟同步编程可以看作是是异步编程的一种特殊解,例如著名的 MapReduce 就是通过并行计算来提升性能,web 上也有 Service Worker,简单来说就是同步转异步,用并发来换取计算性能的提升。还有就是极其简单的异步操作,比如一次简单的 fetch 那就没有必要用上 RxJS 这种重型武器,直接用 Promise 上的方法就很方便了,但如果说需要一个带自动重试功能同时还要处理并发请求的 fetch 操作,这时候就可能需要按需引入 RxJS 来管理这些请求了。
虽然 RxJS 号称 Lodash for events,但需要注意的是,RxJS 整体的学习曲线比较陡峭,但上手之后你肯定会爱上它的,而且其丰富的操作符集合也有助于控制并保持团队内开发者对于异步事件处理的代码风格,毕竟轮子还是别人造的香且好用。事实上,最难的部分莫过于在学习 RxJS 过程中放弃命令式编程相关的想法:事件在某种层面上与我们每天正在用的集合(例如 Array)不同。Lodash 基于同步数据的集合,而 RxJS 基于异步数据的事件流。
InfoQ:随着移动应用在用户交互方面的演进,前端可以从哪些方面进行创新?
马申彦:用户交互与前端关系密切,影响用户体验的要素很多,简单来说分为页面加载与后续用户交互两部分。先谈谈页面加载,移动端网页可以分 App 内嵌页面和浏览器打开两种,比如豆瓣 App 内书影音这个 tab 里就是 App 内嵌的页面,上面一排包括电影、电视、读书等页面内 tab 是原生实现的 tab,下面的内容部分是基于豆瓣自研的 rexxar 框架开发,实现了包括热部署、缓存、网络请求和页面唤起系统 API 等功能,这样打包在 App 内的页面首次加载只需载入数据速度很快,相对来讲用户体验是极好的,而且应用了 React 作为 Javascript 这边的开发框架显著降低了新页面的开发难度。针对浏览器打开的页面,豆瓣阅读 mobile 版是一个缩略版的书店 SPA 应用,混合了 Backbone 和 React 的开发模式,最大限度地复用 pc 端的组件,在保证页面跳转无全页刷新的基础上利用分包异步加载等技术降低用户等待时间从而提升使用体验。
从用户在页面上操作的交互上讲,可以分为用户输入过程和输入反馈两类。输入过程主要有表单和按钮等,表单是前端老生常谈的问题了,例如使用 input text=‘tel’ 来唤起系统数字键盘方便用户输入电话号码等也是人尽皆知的手法,最近项目里应用了第三方的级联省市区选择器,能够根据屏幕尺寸和展开位置自动响应级联菜单的位置,方便用户选择。同时移动端表单也会考虑屏幕的局限性,提示语和输入框会尽量采用上下式而不是左右式布局,有时就需要考虑输入框组是否过长的问题,会考虑将表单分段方便用户理解和操作。输入反馈是用户直观看到的部分,例如表单的提交成功会给予反馈,比如有全局的模态框和 toast 等,用户操作路径上比较重要的提示或选择就会使用模态框,而一般的操作反馈就会通过 toast 告知用户操作结果。在交互细节上,比如控制按钮可点击区域的大小适中符合人手指尺寸,定义 scroll-snap-type 以获得连贯流程的滚动体验,使用 css 内联 svg 以避免出现元素空白等。移动端现在常用 feed 流形式展示数据,这时就需要考虑滚动加载的时机和实现形式了。
总的来说,可以从网络请求、数据加载、用户在页面上的操作、视觉 UI 方面去切合移动端的需求。天下难事,必作于易;天下大事,必作于细。通过开发手段提升用户体验的要素有很多,最关键的是一颗主动体验使用自己开发的产品并认真负责每一处细节的心。
InfoQ:我之前看到过一篇豆瓣前端工程师写给前端新人的一封信,里面提到前端要有预见性地学习新技术,拥抱新标准,前端工程师是所有工程师中最需要“工匠精神”的。您可以谈谈豆瓣目前主要的技术重点是什么?在新技术上有哪些尝试?
马申彦:不只是前端工程师,所有工程师都非常需要“工匠精神”,豆瓣向来拥抱新技术的尝试与落地,前端组也会不定期组织组内分享各产品线或个人项目运用的新技术。2016 年底小程序刚出来时,豆瓣就组织了以小程序开发为主题的 Happy Day 活动,其中用小程序开发的“阿尔法城重生”项目触动了在场工程师们的集体怀旧记忆。豆瓣现在正处在快速增长时期,平台和业务方面都遇到了很多挑战,但同时也是机遇,如何应对阶段性的高并发请求,如何降低服务器开销,如何提升用户访问页面的速度等,都是我们所面临的问题。
豆瓣阅读早期使用 jQuery 和 Backbone 来构建 web 应用,在 React 出来以后迅速地被其声明式的写法所吸引,现今站内包括管理后台和编辑器在内的中大型 web 应用都优选使用 React 构建,应用了包括 TypeScript、GraphQL、RxJS、Storybook 和 Jest 在内的多种技术完善保证项目的开发与维护。现在阅读站内主要针对原创作品写作者和读者进行了多项功能优化和调整。为了开发维护方便,我们果断抛弃了使用人数非常少的 IE10 以下的浏览器,这样我们代码里省去了很多为特定浏览器做的 css prefix 或是 hack。在产品上针对 Draft.js 进行二次开发的编辑器以及 web 阅读器,在保证正常阅读编辑体验的同时,不断优化代码结构,保证像素级体验。
InfoQ:在今年柏林的 JSConf EU 上,npm 公开了一组关于“逃离 JavaScript”的数据,其中提到两个趋势,分别是:TypeScript 的使用者已经从去年的 46%增长到 63%;WebAssembly 的出现。您如何看待 JavaScript 未来的发展趋势?
马申彦:TypeScript 已是大中型前端项目的标准配置,而且它是 JavaScript 的超集,这其实并不能说是逃离 JavaScript 的趋势,可以说是应用了更强类型规约的 JavaScript 来保证我们的代码的健壮性。WebAssembly 主要是解决 JavaScript 计算性能不足的问题,重点用在依赖图形渲染或者高速计算的项目,WebAssembly 的另一个意义是可以有更多的语言参与到浏览器这块战场,这是好事,不应该将其他语言视为敌人,而应该拥抱这种变化,正如我们 iOS App 内的排版引擎由 C++ 编写,但界面依然是用 Objective-C 和 Swift 构建,所谓术业有专攻。软件项目开发与技术人员对业务逻辑的熟悉度和实现难度关系更大,只要能够带来生产效率的提升,那么任何新技术都是值得探讨和学习的。
回到 JavaScript 的发展趋势,应该还是渐进式增强,完善面向对象编程(主要是 class)的部分,标准类型借鉴现有流行类库的最佳实践,更友好的 DOM 操作方法,以及更优雅的语法糖等等,同样值得注意的还是与设备端的联合,比如传感器等 API,这也是移动优先的发展方向。
InfoQ:您目前还在关注哪些新的技术问题?
马申彦:我们团队去年在推动 GraphQL 的落地与实现,尝试建立从 GraphQL 的 schema 到 TypeScript 类型系统的直接映射,希望能优化开发流程;同时研究 CSS in JS 和 CSS Modules 相关技术以优化项目内对组件样式的开发;从工程化的角度来说,还关注前端的微服务化,但这方面目前看到的最佳实践还比较少。
专家介绍:
马申彦,豆瓣阅读前端负责人。现负责豆瓣阅读 web 前端的开发与维护,包括完整的 web 版电子书店、App 内嵌页面、原创作品的写作发布及后台审稿相关功能,日常运营相关活动与比赛等。目前,豆瓣阅读技术团队由十多名各具匠心且热心前沿技术方向的工程师组成,依托豆瓣的技术平台与基础设施进行独立开发,继承了豆瓣优良的技术氛围同时具有自主创新的产品方法。
评论