Ameblo 2016:React/Redux 打造的同構 Web 應用
Ameblo(注: Ameba博客,Ameba Blog,簡稱Ameblo)于2016年9月,將前端部分由原來的Java架構的應用,重構成為以node.js、React為基礎的Web應用。這篇文章介紹了本次重構的起因、目標、系統設計以及最終達成的結果。
新系統發布后,立即就有人注意到了這個變化。
推ter_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
近年來相比于添加到收藏夾中,用戶更傾向于通過搜索結果、非死book、推ter等社交媒體上的分享鏈接打開博客頁面。Google和推ter的 AMP , 非死book的 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>
);
}
}
同構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 ,對 container 和 component 進行了兩層劃分。然而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的形式,傳遞給 Molecules 和 Atom 。
// 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-airbnb 和 stylelint-config-standard ,對于一些必要的細節做了少許定制。因為規則較嚴,起初的時候或許有點不便。新成員加入項目組時,代碼通過Lint測試便成了要通過的第一關:grimacing:。
z-code-review.png
防止了代碼審查時對于這些細微寫法挑錯。被機器告知錯誤時,心理上會感覺稍好一些。
z-ci-error.png
加入項目組之后,最初的這段時間里發生Lint錯誤是常有的事。
CI, Build, Tesing
代碼的 構建 、 測試 和 部署 統一使用CI(公司內部使用 CircleCI )來完成。各個分支向GHE(Github Enterprise)PUSH之后,依據各個分支產生不同的動作。這個流程的好處就是構建相關的處理不需要專門人員來完成,而是統一寫在 circle.yml 和 package.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年的時候,一般采用推ter倡導的「將各個模塊圈起來的設計」。
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% 。我們認為這是由于首屏和頁面跳轉速度的改善,用戶界面升級(更容易理解的分頁),「咯噔」改進所帶來的結果。
然而還存在很多改進的余地,任何一個指標都可以再次提升。我們想以此表明 網站性能的提升會帶來業務指標的提升:muscle: 。
上述數據是在以下條件下取得的:
- 頁面性能
- 使用 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月的數值進行比較
寫在最后
這次系統重構的出發點是對技術的挑戰,結果獲得了良好的用戶反饋,并對業務作出了貢獻,我們自身也感到非常有價值,獲得了極大的成就感。采用最新迎合時代潮流的技術自然提升服務的質量,也使得這種文化在公司在生根。
來自:http://www.jianshu.com/p/750da1c8d132