React 同构思想

by admin on 2019年10月25日

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

为什么要做同构

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

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

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

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
结构重新渲染。

作者:yangchunwen

前言

最近在给我的博客网站 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应用进行适当分割的绝佳良机。

基于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的浏览器中,我们也可以提供足够的数据内容了。

服务端对 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比较吸引我的地方在于其客户端-服务端同构特性,服务端-客户端可复用组件,本文来简单介绍下这一架构思想。

PWA 特性

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

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

目标

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

什么原理

其实,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"));

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基础,所以,如果你还不清楚React的state/props/生存周期等基本概念,建议先学习相关文档

Service Worker

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

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

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

小结

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

构建

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

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

客户端React

先来回顾一下React如何写一个组件。比如要做一个下面的表格:
图片 2

可以这样写:
先创建一个表格类。
Table.js

var React = require('react');

var DOM = React.DOM;
var table = DOM.table, tr = DOM.tr, td = DOM.td;

module.exports = React.createClass({
    render: function () {
        return table({
                children: this.props.datas.map(function (data) {
                    return tr(null,
                        td(null, data.name),
                        td(null, data.age),
                        td(null, data.gender)
                    );
                })
            });
    }
});

假设已经有了我们要的表格的结构化数据。
datas.js

// 三行数据,分别包括名字、年龄、性别
module.exports = [
    {
        'name': 'foo',
        'age': 23,
        'gender': 'male'
    },
    {
        'name': 'bar',
        'age': 25,
        'gender': 'female'
    },
    {
        'name': 'alice',
        'age': 34,
        'gender': 'male'
    }
];

有了表格类和相应的数据之后,就可以调用并渲染这个表格了。
render-client.js

var React = require('react');
var ReactDOM = require('react-dom');

// table类
var Table = require('./Table');
// table实例
var table = React.createFactory(Table);
// 数据源
var datas = require('./datas');

// render方法把react实例渲染到页面中 https://facebook.github.io/react/docs/top-level-api.html#reactdom
ReactDOM.render(
    table({datas: datas}),
    document.body
);

我们把React基础库Table.jsdatas.jsrender-client.js等打包成pack.js,引用到页面中:

<!doctype html>
<html>
    <head>
        <title>react</title>
    </head>
    <body>
    </body>
    <script src="pack.js"></script>
</html>'

这样页面便可按数据结构渲染出一个表格来

这里 pack.js
的具体打包工具可以是grunt/gulp/webpack/browerify等,打包方法不在这里赘述

这个例子的关键点是使用props来传递单向数据流。例如,通过遍历从`props传来的数据`datas“`生成表格的每一行数据:

this.props.datas.map...

组件的每一次变更(比如有新增数据),都会调用组件内部的render方法,更改其DOM结构。上面这个例子中,当给datas
push新数据时,react会自动为页面中的表格新增数据行。

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

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

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

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

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

服务端React

上面的例子中创建的Table组件,出于性能、SEO等因素考虑,我们会考虑在服务端直接生成HTML结构,这样就可以在浏览器端直接渲染DOM了。

这时候,我们的Table组件,就可以同时在客户端和服务端使用了。

只不过与浏览器端使用ReactDOM.render指定组件的渲染目标不同,在服务器中渲染,使用的是ReactDOMServer这个模块,它有两个生成HTML字符串的方法:

  • renderToString
  • renderToStaticMarkup

关于这两个方法的区别,我想放到后面再来解释,因为跟后面介绍的内容很有关系。

有了这两个方法,我们来创建一个在服务端nodejs环境运行的文件,使之可以直接在服务端生成表格的HTML结构。

render-server.js:

var React = require('react');

// 与客户端require('react-dom')略有不同
var React = require('react');

// 与客户端require('react-dom')略有不同
var ReactDOMServer = require('react-dom/server');

// table类
var Table = require('./Table');
// table实例
var table = React.createFactory(Table);

module.exports = function () {
    return ReactDOMServer.renderToString(table(datas));
};

上面这段代码复用了同一个Table组件,生成浏览器可以直接渲染的HTML结构,下面我们通过改改nodejs的官方Hello
World来做一个真实的页面。

server.js :

var makeTable = require('./render-server');

var http = require('http');

http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/html'});

  var table = makeTable();
  var html = '<!doctype html>\n\
              <html>\
                <head>\
                    <title>react server render</title>\
                </head>\
                <body>' +
                    table +
                '</body>\
              </html>';

  res.end(html);
}).listen(1337, "127.0.0.1");

console.log('Server running at http://127.0.0.1:1337/');

这时候运行node server.js就能看到,不实用js,达到了同样的表格效果,这里我使用了同一个Table.js,完成客户端及服务端的同构,一份代码,两处使用。

这里我们通过查看页面的HTML源码,发现表格的DOM中带了一些数据:
图片 3

data-reactid / data-react-checksum
都是些啥?这里同样先留点悬念,后面再解释。

缓存静态资源

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

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

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

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

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

服务端 + 客户端渲染

上面的这个例子,通过在服务端调用同一个React组件,达到了同样的界面效果,但是有人可能会不开心了:貌似有点弱啊!

上面的例子有两个明显的问题:

  • datas.js 数据源是写死的,不符合大部分真实生产环境

  • 服务端生成HTML结构有时候并不完善,有时候不借助js是不行的。比如当我们的表格需要轮询服务器的数据接口,实现表格数据与服务器同步的时候,怎么实现一个组件两端使用。

为了解决这个问题,我们的Table组件需要变得更复杂。

缓存页面

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

图片 4

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

页面加载速度的改善

数据源

假设我们的表格数据每过一段时间要和服务端同步,在浏览器端,我们必须借助ajax,React官方给我们指明了这类需求的方向,通过componentDidMount这一生存周期方法来拉取数据。

componentDidMount
方法,我个人把它比喻成一个“善后”的方法,就是在React把基本的HTML结构挂载到DOM中后,再通过它来做一些善后的事情,例如拉取数据更新DOM等等。

于是我们改一下我们的`Table组件,去掉假数据datas.js,在`componentDidMount“`中调用我们封装好的抓取数据方法,每三秒去服务器抓取一次数据并更新到页面中。

Table.js

var React = require('react');
var ReactDOM = require('react-dom');

var DOM = React.DOM;
var table = DOM.table, tr = DOM.tr, td = DOM.td;

var Data = require('./data');

module.exports = React.createClass({
    render: function () {
        return table({
                children: this.props.datas.map(function (data) {
                    return tr(null,
                        td(null, data.name),
                        td(null, data.age),
                        td(null, data.gender)
                    );
                })
            });
    },
    componentDidMount: function () {
        setInterval(function () {
            Data.fetch('http://datas.url.com').then(function (datas) {
                this.setProps({
                    datas: datas
                });
            });
        }, 3000)
    }
});

这里假设我们已经封装了一个拉取数据的Data.fetch方法,例如Data.fetch = jQuery.ajax

到这一步,我们实现了客户端的每3秒自动更新表格数据。那么上面这个Table组件是不是可以直接复用到服务端,实现数据拉取呢,不好意思,答案是“不”

React的奇葩之一,就是其组件有“生存周期”这一说法,在组件的生命的不同时期,例如异步数据更新,DOM销毁等等过程,都会调用不同的生命周期方法。

然而服务端情况不同,对服务端来说,它要做的事情便是:去数据库拉取数据
-> 根据数据生成HTML ->
吐给客户端。这是一个固定的过程,拉取数据和生成HTML过程是不可打乱顺序的,不存在先把内容吐给客户端,再拉取数据这样的异步过程。

所以,componentDidMount这样的“善后”方法,React在服务器渲染组件的时候,就不适用了。

而且我还要告诉你,componentDidMount这个方法,在服务端确实永远都不会执行!

看到这里,你可能要想,这步坑爹吗!搞了半天,这个东西只能在客户端用,说好的同构呢!

别急,拉取数据,我们需要另外的方法。

React中可以通过statics定义“静态方法”,学过面向对象编程的同学,自然懂statics方法的意思,没学过的,拉出去打三十大板。

我们再来改一下Table组件,把拉取数据的Data.fetch逻辑放到这里来。

Table.js:

var React = require('react');

var DOM = React.DOM;
var table = DOM.table, tr = DOM.tr, td = DOM.td;

var Data = require('./data');

module.exports = React.createClass({
    statics: {
        fetchData: function (callback) {
            Data.fetch().then(function (datas) {
                callback.call(null, datas);
            });
        }
    },
    render: function () {
        return table({
                children: this.props.datas.map(function (data) {
                    return tr(null,
                        td(null, data.name),
                        td(null, data.age),
                        td(null, data.gender)
                    );
                })
            });
    },
    componentDidMount: function () {
        setInterval(function () {

            // 组件内部调用statics方法时,使用this.constructor.xxx...
            this.constructor.fetchData(function (datas) {
                this.setProps({
                    datas: datas
                });
            });
        }, 3000);
    }
});

非常重要:Table组件能在客户端和服务端复用fetchData方法拉取数据的关键在于,Data.fetch必须在客户端和服务端有不同的实现!例如在客户端调用Data.fetch时,是发起ajax请求,而在服务端调用Data.fetch时,有可能是通过UDP协议从其他数据服务器获取数据、查询数据库等实现

由于服务端React不会调用componentDidMount,需要改一下服务端渲染的文件,同样不再通过datas.js获取数据,而是调用Table的静态方法fetchData,获取数据后,再传递给服务端渲染方法renderToString,获取数据在实际生产环境中是个异步过程,所以我们的代码也需要是异步的:

render-server.js:

var React = require('react');
var ReactDOMServer = require('react-dom/server');

// table类
var Table = require('./Table');
// table实例
var table = React.createFactory(Table);

module.exports = function (callback) {
    Table.fetchData(function (datas) {
        var html = ReactDOMServer.renderToString(table({datas: datas}));
        callback.call(null, html);
    });
};

这时候,我们的Table组件已经实现了每3秒更新一次数据,所以,我们既需要在服务端调用React初始html数据,还需要在客户端调用React实时更新,所以需要在页面中引入我们打包后的js。

server.js

var makeTable = require('./render-server');

var http = require('http');

http.createServer(function (req, res) {
    if (req.url === '/') {
        res.writeHead(200, {'Content-Type': 'text/html'});

        makeTable(function (table) {
            var html = '<!doctype html>\n\
                      <html>\
                        <head>\
                            <title>react server render</title>\
                        </head>\
                        <body>' +
                            table +
                            '<script src="pack.js"></script>\
                        </body>\
                      </html>';

            res.end(html);
        });
    } else {
        res.statusCode = 404;
        res.end();
    }

}).listen(1337, "127.0.0.1");

console.log('Server running at http://127.0.0.1:1337/');

缓存后端接口数据

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

改善点

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

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

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

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

成果

通过上面的改动,我们在服务端获取表格数据,生成HTML供浏览器直接渲染;页面渲染后,Table组件每隔3秒会通过ajax获取新的表格数据,有数据更新的话,会直接更新到页面DOM中。

缓存策略

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

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普及之后,初始页面将更加高速化,而且可以实现离线浏览。

图片 5

z-ssrspa.png

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

 图片 6

z-spa-speed.gif

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

checksum的作用

还记得前面的问题么?

ReactDOMServer.renderToStringReactDOMServer.renderToStaticMarkup
有什么不同?服务端生成的data-react-checksum是干嘛使的?

我们想一想,就算服务端没有初始化HTML数据,仅仅依靠客户端的React也完全可以实现渲染我们的表格,那服务端生成了HTML数据,会不会在客户端React执行的时候被重新渲染呢?我们服务端辛辛苦苦生成的东西,被客户端无情地覆盖了?

当然不会!React在服务端渲染的时候,会为组件生成相应的校验和(checksum),这样客户端React在处理同一个组件的时候,会复用服务端已生成的初始DOM,增量更新,这就是data-react-checksum的作用。

ReactDOMServer.renderToStringReactDOMServer.renderToStaticMarkup
的区别在这个时候就很好解释了,前者会为组件生成checksum,而后者不会,后者仅仅生成HTML结构数据。

所以,只有你不想在客户端-服务端同时操作同一个组件的时候,方可使用renderToStaticMarkup

原文链接:

页面缓存策略

因为是 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);
    }
  }
}

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

图片 7

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

图片 8

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

图片 9

延迟加载

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

 图片 10

z-lazyload.png

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

接口缓存策略

谈完页面缓存,再来讲讲接口缓存,接口缓存就跟页面缓存很类似了,唯一的不同在于:页面第一次加载的时候不一定有缓存,但是会有接口缓存的存在(因为伪造了
cache 中的数据),所以缓存策略跟页面缓存类似:

  1. 网络优先的方式,即优先获取网络上接口数据。当网络请求失败的时候,再去获取
    service worker 里之前缓存的接口数据
  2. 当网络加载成功之后,就更新 cache
    中对应的缓存接口数据,保证下次每次加载页面,都是上次访问的最新接口数据

所以代码就像这样(代码类似,不再赘述):

self.addEventListener(‘fetch’, (e) => { console.log(‘现在正在请求:’

  • e.request.url); const currentUrl = e.request.url; if
    (matchHtml(currentUrl)) { // … } else if (matchApi(currentUrl)) {
    const requestToCache = e.request.clone(); e.respondWith(
    fetch(requestToCache).then((response) => { if (!response ||
    response.status !== 200) { return response; } const responseToCache =
    response.clone(); caches.open(apiCacheName).then((cache) => {
    cache.put(requestToCache, responseToCache); }); return response;
    }).catch(function() { return caches.match(e.request); }) ); } });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
self.addEventListener(‘fetch’, (e) => {
  console.log(‘现在正在请求:’ + e.request.url);
  const currentUrl = e.request.url;
  if (matchHtml(currentUrl)) {
    // …
  } else if (matchApi(currentUrl)) {
    const requestToCache = e.request.clone();
    e.respondWith(
      fetch(requestToCache).then((response) => {
        if (!response || response.status !== 200) {
          return response;
        }
        const responseToCache = response.clone();
        caches.open(apiCacheName).then((cache) => {
          cache.put(requestToCache, responseToCache);
        });
        return response;
      }).catch(function() {
        return caches.match(e.request);
      })
    );
  }
});

这里其实可以再进行优化的,比如在获取数据接口的时候,可以先读取缓存中的接口数据进行渲染,当真正的网络接口数据返回之后再进行替换,这样也能有效减少用户的首屏渲染时间。当然这可能会发生页面闪烁的效果,可以添加一些动画来进行过渡。

HTML缓存

博客文章是静态文档,对于特定URL的请求会返回固定的内容,因此非常适合进行缓存。缓存使得服务器处理内容减少,在提高页面响应速度的同时减轻了服务器的负担。我们将不变的内容(文章等)生成的HTML进行缓存返回,对于由于变化的内容能过JavaScript、CSS等进行操作(比如显示、隐藏等)。

 图片 11

z-newrelic-entrylist.png

这张图显示了2016年9月最后一周New
relic上的统计数据。文章列表页面的HTML的响应时间基本在50ms以下。

 图片 12

z-newrelic-entry.png

这张图是文章详细页面的统计数据。可以看出,这个页面的响应时间也基本上是在50ms以下。由于存在文章过长的时候会造成页面体积变大,以及文章页面不能完全缓存等情况,所以相比列表页面会存在更多较慢的响应。

对于因请求的客户端而产生变化部分的处理,我们在HTML的body标签中通过加入相应的class,然后在客户端通过JavaScript和CSS等进行操作。比如,一些内容不想在某些操作系统上显示,我们就用CSS对这些内容进行隐藏。由于CSS样式表会先载入,页面布局确定下来之后再进行页面渲染,所以这个也可以解决后面要提到的「咯噔」问题。

<!– html –> <body class=”OsAndroid”>

1
2
3
<!– html –>
 
<body class="OsAndroid">

CSS

/* main.css */ body.OsAndroid .BannerForIos { dsplay: none; }

1
2
3
4
5
/* main.css */
 
body.OsAndroid .BannerForIos {
  dsplay: none;
}

其它问题

到现在为止,已经基本上可以实现 service worker
离线缓存应用的效果了,但是还有仍然存在一些问题:

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

快速激活 service worker

默认情况下,页面的请求(fetch)不会通过 sw,除非它本身是通过 sw
获取的,也就是说,在安装 sw 之后,需要刷新页面才能有效果。sw
在安装成功并激活之前,不会响应 fetch或push等事件。

因为站点是单页面应用,这就导致了你在切换路由(没有刷新页面)的时候没有缓存接口数据,因为这时候
service worker 还没有开始工作,所以在加载 service worker
的时候需要快速地激活它。代码如下:

self.addEventListener(‘activate’, (e) => { console.log(‘Service
Worker 状态: activate’); const cachePromise = caches.keys().then((keys)
=> { return Promise.all(keys.map((key) => { if (key !== cacheName
&& key !== apiCacheName) { return caches.delete(key); } return null;
})); }); e.waitUntil(cachePromise); // 快速激活 sw,使其能够响应 fetch
事件 return self.clients.claim(); });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener(‘activate’, (e) => {
  console.log(‘Service Worker 状态: activate’);
  const cachePromise = caches.keys().then((keys) => {
    return Promise.all(keys.map((key) => {
      if (key !== cacheName && key !== apiCacheName) {
        return caches.delete(key);
      }
      return null;
    }));
  });
  e.waitUntil(cachePromise);
  // 快速激活 sw,使其能够响应 fetch 事件
  return self.clients.claim();
});

有的文章说还需要在 install
事件中添加 self.skipWaiting(); 来跳过等待时间,但是我在实践中发现即使不添加也可以正常激活
service worker,原因不详,有读者知道的话可以交流下。

现在当你第一次加载页面,跳转路由,立刻离线访问的页面,也可以顺利地加载页面了。

技术选型

这次项目的技术选择时,遵循了尽可能采用当前当前市场上已经存在的普遍使用的技术这一原则。暗号就是:「活脱脱像范例应用一样Start」。这样一来,无论是谁都可以轻松的获取到相应的文档等信息,同时其它的团队和公司如果要参与到项目中来也能很快的上手。然而在真正进行开发的时候,一些细节实现上因为各种各样的原因存在一些例外的情况,但是在极大程度上保持了各个模块的独立性。最终系统的大体构成如下图所示:

 图片 13

z-bigpicture.png

(有些地方做了省略)

不要强缓存 sw.js

用户每次访问页面的时候都会去重新获取
sw.js,根据文件内容跟之前的版本是否一致来判断 service worker
是否有更新。所以如果你对 sw.js
开启强缓存的话,就将陷入死循环,因为每次页面获取到的 sw.js
都是一样,这样就无法升级你的 service worker。

另外对 sw.js 开启强缓存也是没有必要的:

  1. 本身 sw.js
    文件本身就很小,浪费不了多少带宽,觉得浪费可以使用协商缓存,但额外增加开发负担
  2. sw.js 是在页面空闲的时候才去加载的,并不会影响用户首屏渲染速度

React with Redux

使用React和React进行开发的的时候,很多地方可以用 纯函数
的形式进行组合。纯函数是指特定的参数总是返回特定的结果,不会对函数以外的范围造成污染。使用纯函数进行开发可以保证各个处理模块最小化,不用担心会无意间改变引用对象的值。这样一来,十分有助于大规模开发以及在同一客户端中维持多个状态。

界面更新的流程是:
Action(Event) -> Reducer (返回新的state(状态)) -> React (基于更新后的store内的state更新显示内容)

这是一个Redux Action的例子,演示了React Action (Action Creator)
基于参数返回一个Plain Object。处理异步请求的时候,我们参考
官方文档
,分别定义了成功请求和失败请求。获取数据时使用了
redux-dataloader

JavaScript

// actions/blogAction.js export const FETCH_BLOG_REQUEST =
‘blog/FETCH_BLOG/REQUEST’; export function fetchBlogRequest(blogId) {
return load({ type: FETCH_BLOG_REQUEST, payload: { blogId, }, }); }

1
2
3
4
5
6
7
8
9
10
11
12
// actions/blogAction.js
 
export const FETCH_BLOG_REQUEST = ‘blog/FETCH_BLOG/REQUEST’;
 
export function fetchBlogRequest(blogId) {
  return load({
    type: FETCH_BLOG_REQUEST,
    payload: {
      blogId,
    },
  });
}

Redux
Reducer是一完全基于Action中携带的数据,对已有state进行复制并更新的函数。

JavaScript

// reducers/blogReducer.js import as blogAction from
‘../actions/blogAction’; const initialState = {}; function
createReducer(initialState, handlers) { return (state = initialState,
action) => { const handler = (action && action.type) ?
handlers[action.type] : undefined; if (!handler) { return state; }
return handler(state, action); }; } export default
createReducer(initialState, { [blogAction.FETCH_BLOG_SUCCESS]:
(state, action) => { const { blogId, data } = action.payload; return
{ …state, [blogId]: 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
// reducers/blogReducer.js
 
import  as blogAction from ‘../actions/blogAction’;
 
const initialState = {};
 
function createReducer(initialState, handlers) {
  return (state = initialState, action) => {
    const handler = (action && action.type) ? handlers[action.type] : undefined;
    if (!handler) {
      return state;
    }
    return handler(state, action);
  };
}
 
export default createReducer(initialState, {
  [blogAction.FETCH_BLOG_SUCCESS]: (state, action) => {
    const { blogId, data } = action.payload;
    return {
      …state,
      [blogId]: data,
    };
  },
});

React/Redux基于更新后的store中的数据,对UI进行更新。各个组件依据传递过来的props值,总是以相同的结果返回HTML。React将View组件也作为函数来对待。

JavaScript

// main.js <SpBlogTitle blogTitle=”渋谷のブログ” /> //
SpBlogTitle.js import React from ‘react’; export class SpBlogTitle
extends React.Component { static propTypes = { blogTitle:
React.PropTypes.string, }; shouldComponentUpdate(nextProps) { return
this.props.blogTitle !== nextProps.blogTitle; } render() { return (
<h1>{this.props.blogTitle}</h1> ); } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// main.js
<SpBlogTitle blogTitle="渋谷のブログ" />
 
// SpBlogTitle.js
import React from ‘react’;
 
export class SpBlogTitle extends React.Component {
  static propTypes = {
    blogTitle: React.PropTypes.string,
  };
 
  shouldComponentUpdate(nextProps) {
    return this.props.blogTitle !== nextProps.blogTitle;
  }
 
  render() {
    return (
      <h1>{this.props.blogTitle}</h1>
    );
  }
}

有关Redux的信息在
官方文档
中说明得非常详细,推荐随时参考一下这个文档。

避免改变 sw 的 URL

在 sw 中这么做是“最差实践”,要在原地址上修改 sw。

举个例子来说明为什么:

  1. index.html 注册了 sw-v1.js 作为 sw
  2. sw-v1.js 对 index.html 做了缓存,也就是缓存优先(offline-first)
  3. 你更新了 index.html 重新注册了在新地址的 sw sw-v2.js

如果你像上面那么做,用户永远也拿不到 sw-v2.js,因为 index.html 在
sw-v1.js 缓存中,这样的话,如果你想更新为 sw-v2.js,还需要更改原来的
sw-v1.js。

同构Web应用(Isomorphic web app)

Ameblo
2016年版基本上完全是用JavaScript重写的。无论是Node服务器上还是客户端上都使用了相同的代码和流程,也就是所谓的同构Web应用。项目的目录结构大体上如下所示,服务器端的入口文件是
server.js ,浏览器的入口文件是 client.js

  • actions/ Redux Action (服务器,客户端共用)
  • api/ 封装的API接口
  • components/ React组件 (服务器,客户端共用)
  • reducer/ <span class=”underline”>Redux
    Reducers</span> (服务器,客户端共用)
  • services/ 服务层模型,使用
    Fetchr
    对数据请求进行适当粒度的划分。同时这个也使得node.js作为代理,间接请求API(服务器专用)。
  • server.js 服务器入口(服务器专用)
  • app.js node服务器的配置、启动,由server.js调用(服务器专用)
  • client.js 客户端入口(客户端专用)

 图片 14

z-isomorphic.png

写好的JavaScript同时运行在服务器端还是客户端上的运行行为、以及从数据读取直到在页面上显示为止的整个浏程,都以相同的形式进行。

图片 15

z-code-stats.png

使用Github的语言统计可以看出
,JavaScript占了整个项目的94.0%,几乎全部都是由JavaScript写成的。

测试

自此,我们已经完成了使用 service worker
对页面进行离线缓存的功能,如果想体验功能的话,访问我的博客:

随意浏览任意的页面,然后关掉网络,再次访问,之前你浏览过的页面都可以在离线的状态下进行访问了。

IOS 需要 11.3 的版本才支持,使用 Safari 进行访问,Android 请选择支持
service worker 的浏览器

原子设计(Atomic Design)

对于组件的规划,我们采用了
原子设计
理念。其实项目并没有一开始就采用原子设计,而是根据 Presentational and
Container
Components
,对 containercomponent
进行了两层划分。然而Ameblo中的组件实在是太多,很容易造成职责不明确的情况,因此最终采用了原子设计理念。项目的实际运用中,采用了以下的规则。

 图片 16

z-atomic-design.png

manifest 桌面应用

前面讲完了如何使用 service worker 来离线缓存你的同构应用,但是 PWA
不仅限于此,你还可以使用设置 manifest
文件来将你的站点添加到移动端的桌面上,从而达到趋近于原生应用的体验。

Atoms

组件的最小单位,比如Icon、Button等。原则上不具有状态,从父组件中获取传递过来的props,并返回HTML。

使用 webpack-pwa-manifest 插件

我的博客站点是通过 webpack 来构建前端代码的,所以我在社区里找到
webpack-pwa-manifest 插件用来生成 manifest.json。

首先安装好 webpack-pwa-manifest 插件,然后在你的 webpack
配置文件中添加:

// webpack.config.prod.js const WebpackPwaManifest =
require(‘webpack-pwa-manifest’); module.exports =
webpackMerge(baseConfig, { plugins: [ new WebpackPwaManifest({ name:
‘Lindz\’s Blog’, short_name: ‘Blog’, description: ‘An isomorphic
progressive web blog built by React & Node’, background_color: ‘#333’,
theme_color: ‘#333’, filename: ‘manifest.[hash:8].json’, publicPath:
‘/’, icons: [ { src: path.resolve(constants.publicPath, ‘icon.png’),
sizes: [96, 128, 192, 256, 384, 512], // multiple sizes destination:
path.join(‘icons’) } ], ios: { ‘apple-mobile-web-app-title’: ‘Lindz\’s
Blog’, ‘apple-mobile-web-app-status-bar-style’: ‘#000’,
‘apple-mobile-web-app-capable’: ‘yes’, ‘apple-touch-icon’:
‘//xxx.com/icon.png’, }, }) ] })

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
// webpack.config.prod.js
const WebpackPwaManifest = require(‘webpack-pwa-manifest’);
module.exports = webpackMerge(baseConfig, {
  plugins: [
    new WebpackPwaManifest({
      name: ‘Lindz\’s Blog’,
      short_name: ‘Blog’,
      description: ‘An isomorphic progressive web blog built by React & Node’,
      background_color: ‘#333’,
      theme_color: ‘#333’,
      filename: ‘manifest.[hash:8].json’,
      publicPath: ‘/’,
      icons: [
        {
          src: path.resolve(constants.publicPath, ‘icon.png’),
          sizes: [96, 128, 192, 256, 384, 512], // multiple sizes
          destination: path.join(‘icons’)
        }
      ],
      ios: {
        ‘apple-mobile-web-app-title’: ‘Lindz\’s Blog’,
        ‘apple-mobile-web-app-status-bar-style’: ‘#000’,
        ‘apple-mobile-web-app-capable’: ‘yes’,
        ‘apple-touch-icon’: ‘//xxx.com/icon.png’,
      },
    })
  ]
})

简单地阐述下配置信息:

  1. name: 应用名称,就是图标下面的显示名称
  2. short_name: 应用名称,但 name 无法显示完全时候则显示这个
  3. background_color、theme_color:顾名思义,相应的颜色
  4. publicPath: 设置 cdn 路径,跟 webpack 里的 publicPath 一样
  5. icons: 设置图标,插件会自动帮你生成不同 size
    的图片,但是图片大小必须大于最大 sizes
  6. ios: 设置在 safari 中如何去添加桌面应用

设置完之后,webpack 会在构建过程中生成相应的 manifest 文件,并在 html
文件中引用,下面就是生成 manifest 文件:

{ “icons”: [ { “src”:
“/icons/icon_512x512.79ddc5874efb8b481d9a3d06133b6213.png”, “sizes”:
“512×512”, “type”: “image/png” }, { “src”:
“/icons/icon_384x384.09826bd1a5d143e05062571f0e0e86e7.png”, “sizes”:
“384×384”, “type”: “image/png” }, { “src”:
“/icons/icon_256x256.d641a3644ce20c06855db39cfb2f7b40.png”, “sizes”:
“256×256”, “type”: “image/png” }, { “src”:
“/icons/icon_192x192.8f11e077242cccd9c42c0cbbecd5149c.png”, “sizes”:
“192×192”, “type”: “image/png” }, { “src”:
“/icons/icon_128x128.cc0714ab18fa6ee6de42ef3d5ca8fd09.png”, “sizes”:
“128×128”, “type”: “image/png” }, { “src”:
“/icons/icon_96x96.dbfccb1a5cef8093a77c079f761b2d63.png”, “sizes”:
“96×96”, “type”: “image/png” } ], “name”: “Lindz’s Blog”,
“short_name”: “Blog”, “orientation”: “portrait”, “display”:
“standalone”, “start_url”: “.”, “description”: “An isomorphic
progressive web blog built by React & Node”, “background_color”:
“#333”, “theme_color”: “#333” }

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
31
32
33
34
35
36
37
38
39
40
41
42
{
  "icons": [
    {
      "src": "/icons/icon_512x512.79ddc5874efb8b481d9a3d06133b6213.png",
      "sizes": "512×512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon_384x384.09826bd1a5d143e05062571f0e0e86e7.png",
      "sizes": "384×384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon_256x256.d641a3644ce20c06855db39cfb2f7b40.png",
      "sizes": "256×256",
      "type": "image/png"
    },
    {
      "src": "/icons/icon_192x192.8f11e077242cccd9c42c0cbbecd5149c.png",
      "sizes": "192×192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon_128x128.cc0714ab18fa6ee6de42ef3d5ca8fd09.png",
      "sizes": "128×128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon_96x96.dbfccb1a5cef8093a77c079f761b2d63.png",
      "sizes": "96×96",
      "type": "image/png"
    }
  ],
  "name": "Lindz’s Blog",
  "short_name": "Blog",
  "orientation": "portrait",
  "display": "standalone",
  "start_url": ".",
  "description": "An isomorphic progressive web blog built by React & Node",
  "background_color": "#333",
  "theme_color": "#333"
}

html 中会引用这个文件,并且加上对 ios 添加桌面应用的支持,就像这样。

<!DOCTYPE html> <html lang=en> <head> <meta
name=apple-mobile-web-app-title content=”Lindz’s Blog”> <meta
name=apple-mobile-web-app-capable content=yes> <meta
name=apple-mobile-web-app-status-bar-style content=#838a88> <link
rel=apple-touch-icon href=xxxxx> <link rel=manifest
href=/manifest.21d63735.json> </head> </html>

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang=en>
<head>
  <meta name=apple-mobile-web-app-title content="Lindz’s Blog">
  <meta name=apple-mobile-web-app-capable content=yes>
  <meta name=apple-mobile-web-app-status-bar-style content=#838a88>
  <link rel=apple-touch-icon href=xxxxx>
  <link rel=manifest href=/manifest.21d63735.json>
</head>
</html>

就这么简单,你就可以使用 webpack 来添加你的桌面应用了。

Molecules

以复用为前提的组件,比如List、Modal、User
thunmbnail等。原则上不具有状态,从父组件中获取传递过来的props,并返回HTML。

测试

添加完之后你可以通过 chrome 开发者工具 Application – Manifest 来查看你的
mainfest 文件是否生效:

图片 17

这样说明你的配置生效了,安卓机会自动识别你的配置文件,并询问用户是否添加。

Organisms

页面上较大的一块组件,比如Header,Entry,Navi等。对于这一层的组件,可以在其中进行数据获取处理,以及使用Redux
State 和
connect
,维护组件的状态。这里获取的组件状态以props的形式,传递给 Molecules
Atom

JavaScript

// components/organisms/SpProfile.js import React from ‘react’; import {
connect } from ‘react-redux’; import { routerHooks } from
‘react-router-hook’; import { fetchBloggerRequest } from
‘../../../actions/bloggerAction’; // 数据获取处理
(使用react-router-hook) const defer = async ({ dispatch }) => { await
dispatch(fetchBloggerRequest()); }; // Redu store的state作为props const
mapStateToProps = (state, owndProps) => { const amebaId =
owndProps.params.amebaId; const bloggerMap = state.bloggerMap; const
blogger = bloggerMap[amebaId]; const nickName = blogger.nickName;
return { nickName, }; }; @connect(mapStateToProps) @routerHooks({ done
}) export class SpProfileInfo extends React.Component { static propTypes
= { nickName: React.PropTypes.string.isRequired, }; render() { return (
<div>{this.props.nickName}</div> ); } }

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
31
32
33
34
35
36
37
38
// components/organisms/SpProfile.js
 
import React from ‘react’;
import { connect } from ‘react-redux’;
import { routerHooks } from ‘react-router-hook’;
 
import { fetchBloggerRequest } from ‘../../../actions/bloggerAction’;
 
// 数据获取处理 (使用react-router-hook)
const defer = async ({ dispatch }) => {
  await dispatch(fetchBloggerRequest());
};
 
// Redu store的state作为props
const mapStateToProps = (state, owndProps) => {
  const amebaId = owndProps.params.amebaId;
  const bloggerMap = state.bloggerMap;
  const blogger = bloggerMap[amebaId];
  const nickName = blogger.nickName;
 
  return {
    nickName,
  };
};
 
@connect(mapStateToProps)
@routerHooks({ done })
export class SpProfileInfo extends React.Component {
  static propTypes = {
    nickName: React.PropTypes.string.isRequired,
  };
 
  render() {
    return (
      <div>{this.props.nickName}</div>
    );
  }
}

结尾

讲到这差不多就完了,等以后 IOS 支持 PWA
的其它功能的时候,到时候我也会相应地去实践其它 PWA 的特性的。现在 IOS
11.3 也仅仅支持 PWA 中的 service worker 和 app manifest
的功能,但是相信在不久的将来,其它的功能也会相应得到支持,到时候相信 PWA
将会在移动端绽放异彩的。

1 赞 收藏
评论

图片 18

Template

各个请求路径(URL)所对应的组件。其职责是将所需的部件从Organisms中import过来,以一定的顺序和格式整合在一起。

Pages

作为页面的页面组件。基本上是把传递过来的 this.props.children
原原本本的显示出来。由于Ameblo是单页面应用,因而只有一个页面组件。

CSS Modules

CSS样式表使用 CSS
Modules
将CSS样式规则的作用范围严格限制到了各个组件内。各个样式规则的作用范围进行限制使得样式的变更和删除更加容易。因为Ameblo是由许多人协同开发完成,不一定每个人都精通CSS,而且不免要时常对一些不知是谁何时写的代码进行更改,在这个时候将作用范围限制到组件的CSS
Modules就发挥其作用了。

CSS

/ components/organisms/SpNavigationBar.css / .Nav { background: #fff;
border-bottom: 1px solid #e3e5e4; display: flex; height: 40px; width:
100%; } .Logo { text-align: center; }

1
2
3
4
5
6
7
8
9
10
11
12
13
/ components/organisms/SpNavigationBar.css /
 
.Nav {
  background: #fff;
  border-bottom: 1px solid #e3e5e4;
  display: flex;
  height: 40px;
  width: 100%;
}
 
.Logo {
  text-align: center;
}

JavaScript

// components/organisms/SpNavigationBar.js import React from ‘react’;
import style from ‘./SpNavigationBar.css’ export class SpBlogInfo
extends React.Component { render() { return ( <nav
className={style.Nav}> <div className={style.Logo}> <img
alt=”Ameba” height=”24″ src=”logo.svg” width=”71″ /> </div>
<div …> </nav> ); } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// components/organisms/SpNavigationBar.js
 
import React from ‘react’;
import style from ‘./SpNavigationBar.css’
 
export class SpBlogInfo extends React.Component {
  render() {
    return (
      <nav className={style.Nav}>
        <div className={style.Logo}>
          <img
            alt="Ameba"
            height="24"
            src="logo.svg"
            width="71"
           />
        </div>
        <div …>
      </nav>
    );
  }
}

各个class的名称经过webpack编译之后,变成像
SpNavigationBar__Nav___3g5MH 这样含hash值的全局唯一名称。

ESLint, stylelint

这次的项目将ESLint和stylelint放到了必须的位置,即便一个字母出错,整个项目也无法测试通过。目的就在于统一代码风格,节约代码审查时的麻烦。具体规则分别继承自
eslint-config-airbnb

stylelint-config-standard
,对于一些必要的细节做了少许定制。因为规则较严,起初的时候或许有点不便。新成员加入项目组时,代码通过Lint测试便成了要通过的第一关😬。

 图片 19

z-code-review.png

防止了代码审查时对于这些细微写法挑错。被机器告知错误时,心理上会感觉稍好一些。

图片 20

z-ci-error.png

加入项目组之后,最初的这段时间里发生Lint错误是常有的事。

CI, Build, Tesing

代码的
构建
、测试

部署
统一使用CI(公司内部使用
CircleCI
)来完成。各个分支向GHE(Github
Enterprise)PUSH之后,依据各个分支产生不同的动作。这个流程的好处就是构建相关的处理不需要专门人员来完成,而是统一写在
circle.ymlpackage.json (node环境下)里。

  • develop
    开发(下次发布)用分支。构建、测试之后自动部署到staging环境中。
  • release/vX.X.X
    发布分支。由develop分支派生,构建、测试之后,自动部署到semi(准生产)环境中。
  • hotfix/vX.X.X
    hotfix分支。由master分支派生,构建、测试之后,自动部署到semi(准生产)环境中。
  • deploy/${SERVER_NAME}
    部署到分支所指定的相应服务器上。主要是在开发环境中使用。
  • master
    这个分支构建之后生成可以用于部署到production(生产)环境的docker镜像。
  • 其它 开发用分支。仅进行构建和测试。

Docker

本次系统重构,也对node.js应用进行docker化构建。这次重构的是前端系统,我们希望可以在细小修正之后立即进行部署。docker化之后,一旦将镜像构建完成,可以不受node模块版本的左右进行部署,回滚也很容易。

此外,node.js本身发布非常频繁,如果放置不管,不知不觉之间系统就成古董了。docker化之后,可以不受各主机环境的影响自由的进行升级。

更重要的是,设置docker容器数是比较容易的,这对于系统横向扩容以及对服务器配置作优化时也十分方便。

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

不再「咯噔」

系统重构之前的Ameblo由于存在一些高度没有固定的模块,出现了「咯噔」现象。这种「咯噔」会导致误点击以及页面的重绘,十分令人厌烦。而此模块高度固定也做为本次系统重构的UI设计的前提。特别是页面间导航作为十分重要的元素,我们经过努力使得在页面跳转时每次都可以触击到相同的位置。

图片 21

z-gatan.gif

「咯噔」的一个例子。点击[次のページ](下一页)的时候,额外的元素由于加载缓慢,造成误点击。

 图片 22

z-paging-fixed.gif

系统重构之后,元素的位置被固定下来,减轻了页面跳转时给用户心理上带来的负担。

智能手机时代的用户界面

2016年在移动环境下使用的用户几乎都在使用智能手机。在智能手机上,由于各个平台的提供者制定了各自不同的用户界面规范,用户已经习惯并适应了用户界面。相比之下,虽说浏览器上的规范非常少,但是如果和当今流行的界面差距太大的话,就会变得很难用。

Ameblo的手机版在2010年进行改版之后,自然对一些细节进行了改善,但是由于没有太大的变动,所以现在看来很多地方已经给人一种很旧的印象。用户在浏览的时候,对于界面并不区别是原生应用还是浏览器,因而制作出适应当前时代这个平台的用户界面显得尤为重要。这里介绍一下本次重构中,对于界面的一些升级。

 图片 23

z-update-design.png

内容占据界面上横向整个空间。2010年的时候,一般采用Twitter倡导的「将各个模块圈起来的设计」。

图片 24

z-searchbar.gif

增加了导航栏,把导航相关操作集中放置在这里。

可访问性

这次系统重构正值可访问性成为热点话题的时候。仔细的为HTML增加相当标签属生就可以使整个系统足够可访问。首先在HTML标签属性添加上时要用心斟酌。对于标题、
img 等添加适当的 alt 属性,对于可点击的元素一定要使用 a button
等可点击的标签。如果能自动对可访问性进行检验就再好不过了,ESlint的
jsx-a11y
插件可以帮助完成这一点。

在项目进行的时候,正好公司内开展了一次可访问性的学习活动( Designing
Web
Accessibility
的作者太田先生和伊原先生也参加了这次活动),在这次活动上也尝试了Ameblo到目前为止没有注意过的语音朗读器。当时用语音朗读器在Ameblo上进行朗读时,有几处有问题的地方,使用
WAI-ARIA
对这几处加以修正(与 data-* 相同,JSX也支持 aria-* 属性)。

这里
的PPT中有详细的介绍,欢迎阅览(日文)。

结果

OK,上面介绍了本次重构带来的很多变化,那么结果如何呢?

首先是性能相关指标(测试的URL都是Ameblo中单一页面请求资源最多,展示速度最慢的页面)。

阻塞渲染的资源(Critical Blocking Resources)

 图片 25

z-speed-blocking.png

阻塞渲染的资源数 减少了75%
!JavaScript全部变成了异步读取与执行。CSS样式因为运营的原因,维持了重构前的状态。

内容请求(Content Requests)

 图片 26

z-speed-requests.png

资源请求数 减少了58.04%
!由于使用了延迟加载,首屏显示只加载必要的资源,与此同时对文件进行适当的整理,并删除了一些不必要的模块,最终达成了这个状态。

渲染(Rendering)

 图片 27

z-speed-rendering.png

渲染速度做为前端的主要性能指标,本次 提升了44.68%

页面加载时间(Page Load Time)

 图片 28

z-speed-pageload.png

页面加载时间 缩短了40.5 !此外,后端的返回时间也维持在了0.2ms ~
0.3ms之间。

接下来介绍一下相关的业务指标。

网页浏览量(Pageviews)

 图片 29

z-ga-pv.png

因为2016年9月有一位有名的博客主成为了热点话题,所以这个指标内含有特殊情况。网页浏览量提升了57.15%。如果将热点话题所带来的数值除去后,实际上单纯由系统重构所带来的提升在10%到20%之间。

每次会话浏览页数 (Pages / Session)

 图片 30

z-ga-pps.png

Pages / Session是指在单个会话内页面的浏览数,这个指标 提升了35.54
。SPA改善了页面间跳转的速度,获取了显著的效果。

跳出率(Bounce Rate)

 图片 31

z-ga-bounce.png

跳出率指在一个会话内,仅看了一个页面的比率,这个指标 改善了44.44%
。我们认为这是由于首屏和页面跳转速度的改善,用户界面升级(更容易理解的分页),「咯噔」改进所带来的结果。

然而还存在很多改进的余地,任何一个指标都可以再次提升。我们想以此表明
网站性能的提升会带来业务指标的提升💪

上述数据是在以下条件下取得的:

  • 页面性能
    • 使用
      SpeedCurve
    • 测试的URL是
      http://s.ameblo.jp/ebizo-ichikawa/entry-12152370365.html
    • 浏览器指定为 Chrome, 53.0.2785.143移动端模拟模式
    • 网络指定为4G模拟模式(14.6 Mbps,Upload 7.8Mbps,Latency 53ms)
  • 业务指标
    • 使用 Google
      Analytics
    • 获取自 s.ameblo.jp 内的全部数据
    • 对2016年8月和2016年9月的数值进行比较

写在最后

这次系统重构的出发点是对技术的挑战,结果获得了良好的用户反馈,并对业务作出了贡献,我们自身也感到非常有价值,获得了极大的成就感。采用最新迎合时代潮流的技术自然提升服务的质量,也使得这种文化在公司在生根。在此,对及早导入Isomorphic
JavaScript,并向日本国内推广的同事
@ahomu
表示感谢!🍣 🙏

作者介绍:

作者:原 一成(Hara Kazunari),2008年加入日本CyberAgent公司。担任Ameblo
2016移动前端改版项目总负责人。著有《GitHubの教科書》,《CSS3逆引きデザインレシピ》,《フロントエンドエンジニア育成読本》。

译者:侯 斌(Hou
Bin),2014年入职日本CyberAgent公司。现任Ameblo前端开发。在本次Ameblo
2016移动前端改版项目中担任主要开发,负责基础架构和技术选型以及关键模块开发等。

1 赞 收藏
评论

图片 18

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图