某某茶叶有限公司欢迎您!
金沙棋牌在线 > Web前端 > React 同构开发(二)

React 同构开发(二)

时间:2019-12-29 06:38

React 同构应用 PWA 升级指南

2018/05/25 · JavaScript · PWA, React

原文出处: 林东洲   

React/Redux打造的同构Web应用

2018/07/30 · CSS · React, Redux

原文出处: 原 一成(Hara Kazunari)   译文出处:侯斌   

大家好,我是原一成(@herablog),目前在CyberAgent主要担任前端开发。

Ameblo(注: Ameba博客,Ameba Blog,简称Ameblo)于2016年9月,将前端部分由原来的Java架构的应用,重构成为以node.js、React为基础的Web应用。这篇文章介绍了本次重构的起因、目标、系统设计以及最终达成的结果。

新系统发布后,立即就有人注意到了这个变化。

 图片 1

twitter_msg.png

React 同构

所谓同构,简单的说就是客户端的代码可以在服务端运行,好处就是能极大的提升首屏时间,避免白屏,另外同构也给SEO提供了很多便利。

React 同构得益于 React 的虚拟 DOM。虚拟 DOM 以对象树的形式保存在内存中,并存在前后端两种展现形式。

  • 在客户端上,虚拟 DOM 通过 ReactDOM 的 render 方法渲染到页面中,形成真实的 dom。
  • 在服务端上,React 提供了另外两个方法: ReactDOMServer.renderToString 和 ReactDOMServer.renderToStaticMarkup 将虚拟 DOM 渲染为 HTML 字符串。

在服务端通过 ReactDOMServer.renderToString 方法将虚拟 DOM 渲染为 HTML 字符串,到客户端时,React 只需要做一些事件绑定等操作就可以了。

在这一整套流程中,保证 DOM 结构的一致性是至关重要的一点。 React 通过 data-react-checksum来检测一致性,即在服务端产生 HTML 字符串的时候会额外的计算一个 data-react-checksum 值,客户端会对这个值进行校验,如果与客户端计算的值一致,则 React 只会进行事件绑定,如果不一致,React 会丢弃服务端返回的 dom 结构重新渲染。

为什么要做同构

要回答这个问题,首先要问什么是同构。所谓同构,顾名思义就是同一套代码,既可以运行在客户端(浏览器),又可以运行在服务器端(node)。

我们知道,在前端的开发过程中,我们一般都会有一个index.html, 在这个文件中写入页面的基本内容(静态内容),然后引入JavaScript脚本根据用户的操作更改页面的内容(数据)。在性能优化方面,通常我们所说的种种优化措施也都是在这个基础之上进行的。在这个模式下,前端所有的工作似乎都被限制在了这一亩三分地之上。

那么同构给了我们什么样的不同呢?前面说到,在同构模式下,客户端的代码也可以运行在服务器上。换句话说,我们在服务器端就可以将不同的数据组装成页面返回给客户端(浏览器)。这给页面的性能,尤其是首屏性能带来了巨大的提升可能。另外,在SEO等方面,同构也提供了极大的便利。除此以外,在整个开发过程中,同构会极大的降低前后端的沟通成本,后端更加专注于业务模型,前端也可以专注于页面开发,中间的数据转换大可以交给node这一层来实现,省去了很多来回沟通的成本。

前言

最近在给我的博客网站 PWA 升级,顺便就记录下 React 同构应用在使用 PWA 时遇到的问题,这里不会从头开始介绍什么是 PWA,如果你想学习 PWA 相关知识,可以看下下面我收藏的一些文章:

  • 您的第一个 Progressive Web App
  • 【Service Worker】生命周期那些事儿
  • 【PWA学习与实践】(1) 2018,开始你的PWA学习之旅
  • Progressive Web Apps (PWA) 中文版

系统重构的起因

2004年起,Ameblo成为了日本国内最大规模的博客服务。然而随着系统规模的增长,以及很多相关人员不断追加各种模块、页面引导链接等,最终使得页面展现缓慢、对网页浏览量(PV)造成了非常严重的影响。并且页面展现速度方面,绝大多数是前端的问题,并非是后端的问题。

基于以上这些问题,我们决定以提高页面展现速度为主要目标,对系统进行彻底重构。与此同时后端系统也在进行重构,将以往的数据部分进行API化改造。此时正是一个将All-in-one的巨型Java应用进行适当分割的绝佳良机。

服务端对 ES6/7 的支持

React 新版本中已经在推荐采用 ES6/7 开发组件了,因此服务端对 ES6/7 的支持也不得不跟上我们开发组件的步伐。但是现在 node 原生对 ES6/7 的支持还比较弱,这个时候我们就需要借助于 babel 来完成 ES6/7 到 ES5 的转换。这一转换,我们通过 babel-register 来完成。

babel-register 通过绑定 require 函数的方式(require hook),在 require jsx 以及使用 ES6/7 编写的 js 文件时,使用 babel 转换语法,因此,应该在任何 jsx 代码执行前,执行 require('babel-register')(config),同时通过配置项config,配置babel语法等级、插件等。

这里我们给一个配置 demo, 具体配置方法可参看官方文档。

{
  "presets": ["react", "es2015", "stage-0"],

  "plugins": [
    "transform-runtime",
    "add-module-exports",
    "transform-decorators-legacy",
    "transform-react-display-name"
  ],

  "env": {
    "development": {
      "plugins": [
        "typecheck",
        ["react-transform", {
            "transforms": [{
                "transform": "react-transform-catch-errors",
                "imports": ["react", "redbox-react"],
                "locals": ["module"]
              }
            ]
        }]
      ]
    }
  }
}

基于React的同构开发

说了这么多,如何做同构开发呢?
这还得归功于 React提供的服务端渲染。

ReactDOMServer.renderToString  
ReactDOMServer.renderToStaticMarkup

不同于 ReactDom.render将DOM结构渲染到页面, 这两个函数将虚拟DOM在服务端渲染为一段字符串,代表了一段完整的HTML结构,最终以html的形式吐给客户端。

下面看一个简单的例子:

// 定义组件 
import React, { Component, PropTypes } from 'react';

class News extends Component {
    constructor(props) {
        super(props);
    }

    render() {
        var {data} = this.props;
        return <div className="item">
      <a href={data.url}>{ data.title }</a>
    </div>;
    }
}

export default News;

我们在客户端,通常通过如下方式渲染这个组件:

// 中间省略了很多其他内容,例如redux等。
let data = {url: 'http://www.taobao.com', title: 'taobao'}
ReactDom.render(<News data={data} />, document.getElementById("container"));

在这个例子中我们写死了数据,通常情况下,我们需要一个异步请求拉取数据,再将数据通过props传递给News组件。这时候的写法就类似于这样:

Ajax.request({params, success: function(data) {
    ReactDom.render(<News data={data} />, document.getElementById("container"));    
}});

这时候,异步的时间就是用户实际等待的时间。

那么,在同构模式下,我们怎么做呢?

// 假设我们的web服务器使用的是KOA,并且有这样的一个controller  
function* newsListController() {

  const data = yield this.getNews({params});

  const data = {
    'data': data
  };

  this.body = ReactDOMServer.renderToString(News(data));
};

这样的话,我么在服务端就生成了页面的所有静态内容,直接的效果就是减少了因为首屏数据请求导致的用户的等待时间。除此以外,在禁用JavaScript的浏览器中,我们也可以提供足够的数据内容了。

PWA 特性

PWA 不是单纯的某项技术,而是一堆技术的集合,比如:Service Worker,manifest 添加到桌面,push、notification api 等。

而就在前不久时间,IOS 11.3 刚刚支持 Service worker 和类似 manifest 添加到桌面的特性,所以这次 PWA 改造主要还是实现这两部分功能,至于其它的特性,等 iphone 支持了再升级吧。

目标

本次系统重构确立了以下几个目标。

css、image 等文件服务端如何支持

一般情况来说,不需要服务端处理非js文件,但是如果直接在服务端 require 一个非 js 文件的话会报错,因为 require 函数不认识非 js 文件,这时候我们需要做如下处理, 已样式文件为例:

var Module = require('module');
Module._extensions['.less'] = function(module, fn) {
  return '';
};
Module._extensions['.css'] = function(module, fn) {
  return '';
};

具体原理可以参考require 解读

或者直接在 babel-register 中配置忽略规则:

require("babel-register")({
  ignore: /(.css|.less)$/,
});

但是,如果项目中使用了 css_modules 的话,那服务端就必须要处理 less 等文件了。为了解决这个问题,需要一个额外的工具 webpack-isomorphic-tools,帮助识别 less 等文件。

简单地说,webpack-isomorphic-tools,完成了两件事:

  • 以webpack插件的形式,预编译less(不局限于less,还支持图片文件、字体文件等),将其转换为一个 assets.json 文件保存到项目目录下。
  • require hook,所有less文件的引入,代理到生成的 JSON 文件中,匹配文件路径,返回一个预先编译好的 JSON 对象。

什么原理

其实,react同构开发并没有上面的例子那么简单。上面的例子只是为了说明服务端渲染与客户端渲染的基本不同点。其实,及时已经在服务端渲染好了页面,我们还是要在客户端重新使用ReactDom.render函数在render一次的。因为所谓的服务端渲染,仅仅是渲染静态的页面内容而已,并不做任何的事件绑定。所有的事件绑定都是在客户端进行的。为了避免客户端重复渲染,React提供了一套checksum的机制。所谓checksum,就是React在服务端渲染的时候,会为组件生成相应的校验和(checksum),这样客户端React在处理同一个组件的时候,会复用服务端已生成的初始DOM,增量更新,这就是data-react-checksum的作用。

所以,最终,我们的同构应该是这个样子的:

// server 端  
function* newsListController() {

  const data = yield this.getNews({params});

  const data = {
    'data': data
  };
  let news = ReactDOMServer.renderToString(News(data));
  this.body = '<!doctype html>n
                      <html>
                        <head>
                            <title>react server render</title>
                        </head>
                        <body><div id="container">' +
                            news +
                            '</div><script>var window.__INIT_DATA='+ JSON.stringify(data) +'</script><script src="app.js"></script>
                        </body>
                      </html>';
};

// 客户端,app.js中  
let data = JSON.parse(window.__INIT_DATA__);  
ReactDom.render(<News props={data} />, document.getElementById("container"));

Service Worker

service worker 在我看来,类似于一个跑在浏览器后台的线程,页面第一次加载的时候会加载这个线程,在线程激活之后,通过对 fetch 事件,可以对每个获取的资源进行控制缓存等。

页面展现速度的改善(总之越快越好)

用于测定用户体验的指标有很多,我们认为其中对用户最重要的指标就是页面展现速度。页面展现速度越快,目标内容就能越快到达,让任务在短时间内完成。这次重构的目标是尽可能的保持博客文章、以及在Ameblo内所呈现的繁多的内容的固有形式,在不破坏现有价值、体验的基础上,提高展现和页面行为的速度。

构建

客户端的代码通过配置 webpack 打包发布到 CDN 即可。

通过配置 webpack 和 webpack-isomorphic-tools 将非 js 文件打包成 assets 文件即可。

小结

最近一直在做同构相关的东西,本文主要讨论react同构开发的基本原理和方式,作为一个引子,其中省去了很多细节问题。关于同构应用开发,其实有很多事情要做,比如node应用的发布、监控、日志管理,react组件是否满足同构要求的自动化检测等。这些事情都是后续要一步一步去做的,到时候也会做一些整理和积累。

明确哪些资源需要被缓存?

那么在开始使用 service worker 之前,首先需要清楚哪些资源需要被缓存?

系统的现代化(搭乘生态系统)

从前的Web应用是将数据以HTML的形式返回,那个时候并没有什么问题。然而,随着内容的增加,体验的丰富化,以及设备的多样化,使得前端所占的比重越来越大。此前要开发一个好的Web应用,如果要高性能,就一定不要将前后端分隔开。当年以这个要求开发的系统,在经历了10年之后,已经远远无法适应当前的生态系统。

「跟上当前生态系统」,以此来构建系统会带来许许多多的好处。因为作为核心的生态系统,其开发非常活跃,每天都会有许许多多新的idea。因而最新的技术和功能更容易被吸纳,同时实现高性能也更加容易。同时,这个「新」对于年轻的技术新人也尤为重要。仅懂得旧规格旧技术的大叔对于一个优秀的团队来说是没有未来的(自觉本人膝盖也中了一箭)。

缓存静态资源

首先是像 CSS、JS 这些静态资源,因为我的博客里引用的脚本样式都是通过 hash 做持久化缓存,类似于:main.ac62dexx.js 这样,然后开启强缓存,这样下次用户下次再访问我的网站的时候就不用重新请求资源。直接从浏览器缓存中读取。对于这部分资源,service worker 没必要再去处理,直接放行让它去读取浏览器缓存即可。

我认为如果你的站点加载静态资源的时候本身没有开启强缓存,并且你只想通过前端去实现缓存,而不需要后端在介入进行调整,那可以使用 service worker 来缓存静态资源,否则就有点画蛇添足了。

升级界面设计、用户体验(2016年版Ameblo)

Ameblo的手机版在2010年经历了一次改版之后,就基本上没有太大的变化。这其间很多用户都已经习惯了原生应用的设计和体验。这个项目也是为了不让人觉得很土很难用,达到顺应时代的2016年版界面设计和用户体验。

OK,接下来让我具体详细聊聊。

缓存页面

缓存页面显然是必要的,这是最核心的部分,当你在离线的状态下加载页面会之后出现:

图片 2

究其原因就是因为你在离线状态下没办法加载页面,现在有了 service worker,即使你在没网络的情况下,也可以加载之前缓存好的页面了。

页面加载速度的改善

缓存后端接口数据

缓存接口数据是需要的,但也不是必须通过 service worker 来实现,前端存放数据的地方有很多,比如通过 localstorage,indexeddb 来进行存储。这里我也是通过 service worker 来实现缓存接口数据的,如果想通过其它方式来实现,只需要注意好 url 路径与数据对应的映射关系即可。

改善点

系统重构前,通过 SpeedCurve 进行分析,得出了下面结论:

  • 服务器响应速度很快
  • HTML文档较大(页面所有要素都包含其中)
  • 阻塞页面渲染的资源(JavaScript、Stylesheet)较多
  • 资源读取的次数过多,体积过大

依据这些确定了下面这几项基本方针:

  • 为了不致于降低服务器响应速度,对代码进行优化,缓存等
  • 尽可能减少HTML文档大小
  • JavaScript异步地加载与执行
  • 最初呈现页面时,仅仅加载所需的必要资源

缓存策略

明确了哪些资源需要被缓存后,接下来就要谈谈缓存策略了。

SSR还是SPA

近年来相比于添加到收藏夹中,用户更倾向于通过搜索结果、Facebook、Twitter等社交媒体上的分享链接打开博客页面。Google和Twitter的AMP, Facebook的Instant Article表明第一页的展现速度极大影响到用户满意度。

此外,从Google Analytics等日志记录中了解到在文章列表页面和前后文章间进行跳转的用户也很多。这或许是因为博客作为个人媒体,当某一用户看到一篇不错的文章,非常感兴趣的时候,他也同时想看一看同一博客内的其它文章。也就是说,博客这种服务 第一页快速加载与页面间快速跳转同等重要

因此,为了让两者都能发挥最佳性能,我们决定在第一页使用服务器端渲染(Server-side Rendering, SSR),从第二页起使用单页面应用(Single Page Application, SPA)。这样一来,既能确保第一页的展示速度和机器可读性(Machine-Readability)(含SEO),又能获得SPA带来的快速展示速度。

BTW,对于目前的架构,由于服务器和客户端使用相同的代码,全部进行SSR或是全部进行SPA也是可能的。目前已经实现即便在不能运行JavaScript的环境中,也可以正常通过SSR来浏览。可以预见将来等到Service Worker普及之后,初始页面将更加高速化,而且可以实现离线浏览。

图片 3

z-ssrspa.png

以前的系统完全使用SSR,而现在的系统从第二页起变为SPA。

 图片 4

z-spa-speed.gif

SPA的魅力在于呈现速度之快。因为仅仅通过API获取所需的必要数据,所以速度非常快!

页面缓存策略

因为是 React 单页同构应用,每次加载页面的时候数据都是动态的,所以我采取的是:

  1. 网络优先的方式,即优先获取网络上最新的资源。当网络请求失败的时候,再去获取 service worker 里之前缓存的资源
  2. 当网络加载成功之后,就更新 cache 中对应的缓存资源,保证下次每次加载页面,都是上次访问的最新资源
  3. 如果找不到 service worker 中 url 对应的资源的时候,则去获取 service worker 对应的 /index.html 默认首页

// sw.js self.addEventListener('fetch', (e) => { console.log('现在正在请求:' + e.request.url); const currentUrl = e.request.url; // 匹配上页面路径 if (matchHtml(currentUrl)) { const requestToCache = e.request.clone(); e.respondWith( // 加载网络上的资源 fetch(requestToCache).then((response) => { // 加载失败 if (!response || response.status !== 200) { throw Error('response error'); } // 加载成功,更新缓存 const responseToCache = response.clone(); caches.open(cacheName).then((cache) => { cache.put(requestToCache, responseToCache); }); console.log(response); return response; }).catch(function() { // 获取对应缓存中的数据,获取不到则退化到获取默认首页 return caches.match(e.request).then((response) => { return response || caches.match('/index.html'); }); }) ); } });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// sw.js
self.addEventListener('fetch', (e) => {
  console.log('现在正在请求:' + e.request.url);
  const currentUrl = e.request.url;
  // 匹配上页面路径
  if (matchHtml(currentUrl)) {
    const requestToCache = e.request.clone();
    e.respondWith(
      // 加载网络上的资源
      fetch(requestToCache).then((response) => {
        // 加载失败
        if (!response || response.status !== 200) {
          throw Error('response error');
        }
        // 加载成功,更新缓存
        const responseToCache = response.clone();
        caches.open(cacheName).then((cache) => {
          cache.put(requestToCache, responseToCache);
        });
        console.log(response);
        return response;
      }).catch(function() {
        // 获取对应缓存中的数据,获取不到则退化到获取默认首页
        return caches.match(e.request).then((response) => {
           return response || caches.match('/index.html');
        });
      })
    );
  }
});

为什么存在命中不了缓存页面的情况?

  1. 首先需要明确的是,用户在第一次加载你的站点的时候,加载页面后才会去启动 sw,所以第一次加载不可能通过 fetch 事件去缓存页面
  2. 我的博客是单页应用,但是用户并不一定会通过首页进入,有可能会通过其它页面路径进入到我的网站,这就导致我在 install 事件中根本没办法指定需要缓存那些页面
  3. 最终实现的效果是:用户第一次打开页面,马上断掉网络,依然可以离线访问我的站点

结合上面三点,我的方法是:第一次加载的时候会缓存 /index.html 这个资源,并且缓存页面上的数据,如果用户立刻离线加载的话,这时候并没有缓存对应的路径,比如 /archives 资源访问不到,这返回 /index.html 走异步加载页面的逻辑。

在 install 事件缓存 /index.html,保证了 service worker 第一次加载的时候缓存默认页面,留下退路。

import constants from './constants'; const cacheName = constants.cacheName; const apiCacheName = constants.apiCacheName; const cacheFileList = ['/index.html']; self.addEventListener('install', (e) => { console.log('Service Worker 状态: install'); const cacheOpenPromise = caches.open(cacheName).then((cache) => { return cache.addAll(cacheFileList); }); e.waitUntil(cacheOpenPromise); });

1
2
3
4
5
6
7
8
9
10
11
12
import constants from './constants';
const cacheName = constants.cacheName;
const apiCacheName = constants.apiCacheName;
const cacheFileList = ['/index.html'];
 
self.addEventListener('install', (e) => {
  console.log('Service Worker 状态: install');
  const cacheOpenPromise = caches.open(cacheName).then((cache) => {
    return cache.addAll(cacheFileList);
  });
  e.waitUntil(cacheOpenPromise);
});

在页面加载完后,在 React 组件中立刻缓存数据:

// cache.js import constants from '../constants'; const apiCacheName = constants.apiCacheName; export const saveAPIData = (url, data) => { if ('caches' in window) { // 伪造 request/response 数据 caches.open(apiCacheName).then((cache) => { cache.put(url, new Response(JSON.stringify(data), { status: 200 })); }); } }; // React 组件 import constants from '../constants'; export default class extends PureComponent { componentDidMount() { const { state, data } = this.props; // 异步加载数据 if (state === constants.INITIAL_STATE || state === constants.FAILURE_STATE) { this.props.fetchData(); } else { // 服务端渲染成功,保存页面数据 saveAPIData(url, data); } } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// cache.js
import constants from '../constants';
const apiCacheName = constants.apiCacheName;
 
export const saveAPIData = (url, data) => {
  if ('caches' in window) {
    // 伪造 request/response 数据
    caches.open(apiCacheName).then((cache) => {
      cache.put(url, new Response(JSON.stringify(data), { status: 200 }));
    });
  }
};
 
// React 组件
import constants from '../constants';
export default class extends PureComponent {
  componentDidMount() {
    const { state, data } = this.props;
    // 异步加载数据
    if (state === constants.INITIAL_STATE || state === constants.FAILURE_STATE) {
      this.props.fetchData();
    } else {
        // 服务端渲染成功,保存页面数据
      saveAPIData(url, data);
    }
  }
}

这样就保证了用户第一次加载页面,立刻离线访问站点后,虽然无法像第一次一样能够服务端渲染数据,但是之后能通过获取页面,异步加载数据的方式构建离线应用。

图片 5

用户第一次访问站点,如果在不刷新页面的情况切换路由到其他页面,则会异步获取到的数据,当下次访问对应的路由的时候,则退化到异步获取数据。

图片 6

当用户第二次加载页面的时候,因为 service worker 已经控制了站点,已经具备了缓存页面的能力,之后在访问的页面都将会被缓存或者更新缓存,当用户离线访问的的时候,也能访问到服务端渲染的页面了。

图片 7

延迟加载

我们使用SSR+SPA的方法来优化页面间跳转这种横向移动的速度,并且使用延迟加载来改善页面的纵向移动速度。一开始要展现的内容以及导航,还有博客文章等最早呈现,在这些内容之下的次要内容随着页面的滚动逐渐呈现。这样一来,重要的内容不会受页面下面内容的影响而更快的显示出来。对于那些想尽快读文章的用户来说,既不增加用户体验上的压力,又能完整的提供页面下方的内容。

 图片 8

z-lazyload.png

之前的系统因为将页面内的全部内容都放到HTML文档里,所以使得HTML文档体积很大。而现在的系统,仅仅将主要内容放到HTML里返回,减少了HTML的体积和数据请求的大小。