姜小饼
首发于姜小饼
React 同构技术

React 同构技术

知乎箴言:在文章封面引用一张毫无关系的图片会显得高大上

前言

在太阳系地球村2016年,Web 前端最火爆的技术组合莫过于 React + Redux + ES6 + Webpack,如果你正在做的项目与上述四个英文名词毫不沾边的话,那么你很可能已经与潮流脱节了。在今天,姜老饼资深程序员结合上述的技术栈,向大家介绍一同样新奇有趣的技术 - 前后端同构应用。

关于同构应用,国内已经有部分团队开始尝试了,网络上也有一些文章介绍这种技术,为了不重复别人说过的东西,本文就把团队自身的实践经验和大家分享。

背景

目前我们项目的 Wap 应用都是采用 SPA 架构(SPA,Single Page Application,即单页面应用,这种架构有很多好处,页面无刷新更好用户体验,切换页面加载量少等),所以路由控制页面渲染都是在浏览器端的 Javascript(以下简称为 js) 处理的,在这个处理过程中需要依赖 js 加载完毕后才可启动整个 Web 应用,在这个过程中还会引起 DOM 结构多次改变导致浏览器重排重绘,这对我们首屏渲染时间的优化造成了瓶颈。

而传统的服务端渲染是,后端服务器把全部数据读取完毕(这种读取方式是走内网,速度会比浏览器可靠快速),准备好后再构建 HTML 结构输出到浏览器,这种传统方式欠缺灵活性,但是可以在某种程度上加快首屏渲染速度。为什么要说某种程度呢?举个例子,假如某页面需要请求10个数据源,如果是并行请求当然是根据木桶原理以最慢的接口为最终响应时间,如果是串行请求那么响应时间就是10个接口的时间总和了。在这整个过程中,倘若存在耗时特别长的请求,那么响应时间会大幅提升。所以,有些程序员会把耗时特别长的接口拆分出来,作为异步数据在前端读出。当然最激进的做法是把所有接口的拆分,以 Restful 方式全部由前端调用读取,这就是刚刚所说的 SPA 架构,在这里服务端只做数据接口,不参与任何的 view 层渲染。

而前后端同构应用就是结合 SPA 架构和传统后端渲染的一种设计理念,它结合了后端渲染的首屏时间快和 SPA 后续操作加载量低的优点。

同构

同构的英文是 isomorphic ,我们在高中学化学的时候曾经接触过一个有点类似的名词 - 同分异构体。

同分异构物指的是拥有相同分子式,但结构式却不相同的多种分子

在本文中所谓前后端同构,实质上是指前端脚本代码和后端服务器代码使用同一份代码,更广泛的含义是指复用同一套逻辑,并且最终的输出要保持前后端一致。当然了,如果你可以大费周章地在服务器采用 JSP/PHP,前端的 JS 渲染对着后端模板的每个 HTML 标签来编写,同样可以达到同构应用加快首屏渲染的效果,但这工作量远远超过性能提升所带来的价值。而前后端共用同一份代码显然易见的好处是,维护成本将会大大降低,逻辑完全保持一直,在部署方面不再需要控制前后端上线的先后等。

所以要实现前后端同构,你永远只能选择 Javascript,因为浏览器只支持 Javascript,也就注定了后端只能选择 Node。同样地,只能选择具有对 DOM 抽象化的 View Library,这样才可以在使用同一份 JS 代码的情况下,让后端输出的 HTML 和前端输出的 HTML 一致。

实现

我们的项目是基于 github.com/erikras/reac 作为蓝本重构的,具体的代码可以直接参考 github,不过我们的具体实现可能会有所差异:

match({ history, routes }, (error, redirectLocation, renderProps) => {
  render(<Router {...renderProps} />, mountNode)
})

兼容

  • View 兼容,后端输出 HTML 结构,前端同样输出 HTML 结构,之所以需要选用一套对 DOM 做了抽象化的模板引擎是为了保证前后端最终产生的 DOM 结构一致。而 React 中的 React Server Render 可以使前后端共用同一套代码。
  • AJAX 兼容,我们使用 isomorphic-fetch 替换 AJAX 库 reqwest,该库可以自动识别浏览器和 Node 环境调用数据接口。而另一方面,对于浏览器环境,异步请求 API 会自动带上用户的 cookie 值;而对于 Node 环境,需要主动拿到用户的 cookie 后构造请求头部调用 API;而对于 JSONP 在服务器端作一般请求处理。
  • 全局变量兼容,Node 环境下缺失 window 对象和 document 对象,对于这种问题有三种处理方案:
    1. Node 层面模拟缺失字段,例如:对于 location、ua 等这些字段对代码作透明处理,在运行时动态修改保持与浏览器对应拿到的值一致;
    2. 第二种处理方案是,对部分全局 API 抽象化处理,如不直接引用本地存储 API(避免报错)通过引用预先封装好的库避免服务端环境下导致报错,但这种方案对代码的改动有一定的量;
    3. 第三种处理方案是,根据 React 在服务端和浏览器端生命周期(Lifecycle)的差异,调用不同的方法,把涉及到 DOM 操作的方法推移到 componentDidMount 中执行;
    // Node 层面模拟缺失字段
    app.use( (req, res) => {
    	// 补全同构应用运行时缺失的全局对象
    	global.window = {
    		navigator: {
    			userAgent: req.get('User-Agent'),
    		},
    		location: {
    			protocol: req.protocol + ':',
    			hostname: req.hostname,
    			host: host,
    			origin: req.protocol + '://' + host,
    		},
    	};
    }); 
    
  • 数据流兼容,数据流处理统一使用 Redux。目前服务端需要把数据读取完毕后再进行页面渲染,对于浏览器来说,整个过程只是渲染了一次;而浏览器端是加载了组件后(即先显示组件 DOM 轮廓)再读取数据,页面将进行多次渲染。同时由于两端 React 的运行生命周期不一致,所以一般同构方案的处理方式是把调用数据的方法都放到组件的静态方法中(该方法是目前最多文章推荐的,而该静态方法返回的是一个 promise),服务器渲染会先读取完静态方法后再改变 redux state,根据状态渲染组件完毕后再吐出整个 DOM,而在浏览器端会在 componentDidMount 生命周期中再次调用这个静态方法(再次强调,服务端是没有 componentDidMount 这个生命周期),由于获取到的数据是一致的,而根据 React 的 DOM DIFF 算法所得,服务端和浏览器生成的 DOM 结构是一致,页面并不会发生变化,这样完成了双端数据流同构。但是这种方式有个弊端,客户端会做多一次多余的数据请求(虽然不会影响最终结果),所以有一些方案是采用数据前置加载方案,具体已经有库实现。原理是在组件加载前把需要预加载的数据全部加载完,再显示模板,其好处是可以做到双端代码完全复用,弊端是后续异步操作会有一定的白屏问题。
  • 以上

    编辑于 2016-07-06

    文章被以下专栏收录