Ameblo2016 ~ React/Redux打造的同构Web应用(译)

96
侯斌
2016.12.15 17:29* 字数 6320

译自 CyberAgent Developers Blog
原文标题 「アメブロ2016 ~ React/ReduxでつくるIsomorphic web app ~」
作者 HERABLOG

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

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

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

twitter_msg.png

系统重构的起因

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

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

目标

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

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

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

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

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

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

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

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

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

页面加载速度的改善

改善点

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

z-ssrspa.png

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

z-spa-speed.gif

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

延迟加载

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

z-lazyload.png

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

HTML缓存

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

z-newrelic-entrylist.png

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

z-newrelic-entry.png

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

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

<!-- html -->

<body class="OsAndroid">
/* main.css */

body.OsAndroid .BannerForIos {
  dsplay: none;
}

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

技术选型

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

z-bigpicture.png

(有些地方做了省略)

React with Redux

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

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

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

// 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进行复制并更新的函数。

// 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组件也作为函数来对待。

// 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的信息在 官方文档 中说明得非常详细,推荐随时参考一下这个文档。

同构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 客户端入口(客户端专用)
z-isomorphic.png

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

z-code-stats.png

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

原子设计(Atomic Design)

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

z-atomic-design.png

Atoms

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

Molecules

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

Organisms

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

// 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>
    );
  }
}

Template

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

Pages

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

CSS Modules

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

/ components/organisms/SpNavigationBar.css /

.Nav {
  background: #fff;
  border-bottom: 1px solid #e3e5e4;
  display: flex;
  height: 40px;
  width: 100%;
}

.Logo {
  text-align: center;
}
// 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-airbnbstylelint-config-standard ,对于一些必要的细节做了少许定制。因为规则较严,起初的时候或许有点不便。新成员加入项目组时,代码通过Lint测试便成了要通过的第一关😬。

z-code-review.png

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

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设计的前提。特别是页面间导航作为十分重要的元素,我们经过努力使得在页面跳转时每次都可以触击到相同的位置。

z-gatan.gif

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

z-paging-fixed.gif

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

智能手机时代的用户界面

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

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

z-update-design.png

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

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)

z-speed-blocking.png

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

内容请求(Content Requests)

z-speed-requests.png

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

渲染(Rendering)

z-speed-rendering.png

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

页面加载时间(Page Load Time)

z-speed-pageload.png

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

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

网页浏览量(Pageviews)

z-ga-pv.png

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

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

z-ga-pps.png

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

跳出率(Bounce Rate)

z-ga-bounce.png

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

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

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

写在最后

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

作者介绍:

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

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

技术博客
Web note ad 1