- 原文地址:Rearchitecting Airbnb’s Frontend
- 原文做者:Adam Neary
- 譯文出自:掘金翻譯計劃
- 譯者:sunui
- 校對者:Dalston Xu、yzgyyang
概述:最近,咱們從新思考了 Airbnb 代碼庫中 JavaScript 部分的架構。本文將討論:(1)催生一些變化的產品驅動因素,(2)咱們如何一步步擺脫遺留的 Rails 解決方案,(3)一些新技術棧的關鍵性支柱。彩蛋:咱們將透露一下將來的發展方向。html
Airbnb 天天處理超過 7500 萬次搜索,這使得搜索頁面成爲咱們流量最高的頁面。近十年來,工程師們一直在發展、增強和優化 Rails 輸出頁面的方式。前端
最近,咱們轉移到了主頁之外的垂直頁面,來介紹一些體驗和去處。做爲 web 端新增產品的一部分,咱們花時間從新思考了搜索體驗自己。react
在一個用於寬泛搜索的路由之間過渡android
爲了使用戶體驗流暢,咱們選擇調整用戶瀏覽頁面和縮小搜索範圍的交互方式,而再也不採用之前那樣的多頁交互方式:(1)首先訪問着陸頁 www.airbnb.com,(2)接着進入搜索結果頁,(3)隨後訪問某個列表頁,(4)最後進入預訂流程。每一個頁面都是一個獨立的 Rails 頁面。webpack
設計三種瀏覽搜索頁的狀態:新用戶、老用戶和營銷頁。ios
在標籤頁之間切換和與列表進行交互應該感到愜意而輕鬆。事實上,現在沒有什麼能夠阻止咱們在中小屏幕上提供與原生應用一致的體驗。git
會考慮未來在切換標籤頁時,異步加載相應內容github
爲了實現這種體驗,咱們須要擺脫傳統的頁面切換方法,最終咱們只好全面重構了前端代碼。web
Leland Richardson 最近在 React Conf 大會上發表了演講,稱 React Native 現在正處於和現有的高訪問量原生應用共存的「褐色地帶」這篇文章將會探討如何在相似的限制條件下進行 web 端重構。但願你在遇到相似狀況時,這篇文章對你有所幫助。npm
在咱們的燒烤開火以前,由於咱們的線路圖上存在全部有趣的漸進式 web 應用(PWA),咱們須要從 Rails 中解脫出來(或者至少在 Airbnb 用 Rails 提供單獨頁面的這種方式)。
不幸的是,就在幾個月前,咱們的搜索頁還包含一些很是老舊的代碼,像指環王同樣,觸碰它就要當心自負後果。有趣的事實:我曾嘗試用一個簡單的 React 組件來替換基於 Rails presenter 的 Handlebars 模板,忽然不少徹底不相關的部分都崩掉了 —— 甚至 API 響應都出了問題。原來,presenter 改變了底層 Rails 模型,多年來即便在 UI 沒有渲染的時候,它也影響着全部的下游數據。
簡而言之,咱們在這個項目中,就好像 Indiana Jone 用一袋沙子替換了寶物,忽然間廟宇開始崩塌,咱們正在從石塊中奔跑。
當使用 Rails 在服務器端渲染頁面時,你能夠用任何你喜歡的方式把數據丟給服務器端的 React 組件。Controllers、helpers 和 presenters 能生成任何形式的數據,甚至當你把部分頁面遷移到 React 時,每一個組件都能處理它所需的任何數據。
但一旦你想渲染客戶端路由,你須要可以以預約的形式動態請求所需的數據。未來咱們可能用相似 GraphQL 的東西解決這個問題,可是如今暫且把它放到一邊吧,由於這件事和重構代碼沒太大關係。相反,咱們選擇在咱們的 API 的 「v2」 上進行調整,咱們須要咱們全部的組件來開始處理規範的數據格式。
若是你本身和咱們處在相似的狀況中,在維護一個大型的應用,你可能發現咱們像咱們這樣作,規劃遷移現有的服務器端數據管道是很容易的。只需在任何地方用 Rails 渲染一個 React 組件,並確保數據輸入是 API 所規定的類型。你能夠用客戶端的 React PropTypes 來進一步驗證數據類型是否與 API v2 一致。
對咱們來講棘手的問題是和那些參與客戶預約流程交互的團隊協做:商業旅遊、發展、度假租賃團隊;中國和印度市場團隊,災難恢復團隊等等,咱們須要從新培訓全部這些人,即便在技術上能夠將數據直接傳遞到正在呈現的組件上("是的,我明白,這僅僅是一種實驗,可是..."),全部的數據都要經過 API。
有一類獨特的數據和咱們設想的 API 化的數據不一樣,包括應用配置、用戶試驗任務、國際化、本地化等等相似的問題。近年來,Airbnb 已經創建了一套很棒的工具來支持這些功能,可是把這些數據傳送到前端的機制就不那麼使人愉快了(在革命開始以前,或許就已經很蹩腳了!)。
咱們使用 Hypernova 在服務端渲染渲染 React,可是在咱們這次重構深刻以前,不管服務端渲染時 React 組件中的試驗交付會不會爆發或者客戶端上提供的字符串轉換是否均可以在服務器上可靠地使用,這些都還有點模糊。最重要的是,若是服務器和客戶端輸出匹配不到位,頁面不只會不斷閃爍刷新 diff,還會在加載後從新渲染整個頁面,這對於性能來講很可怕。
更糟糕的是,咱們好久之前寫過一些神奇的 Rails 功能,好比 add_bootstrap_data(key, value)
表面上能夠在 Rails 中的任何地方調用,經過 BootstrapData.get(key)
使數據在客戶端的全局可用(再次強調,對 Hypernova 來講已經沒必要要了)。曾經這些小工具對小團隊來講很實用,但現在隨着團隊規模擴大,應用規模擴張,這些小工具反而變成了累贅。因爲每一個團隊擁有不一樣的頁面或功能,所以「數據清洗」變得愈來愈棘手,所以每一個團隊都會培養出一種不一樣的加載配置的機制,以知足其獨特需求。
顯然, 這套機制已經崩潰了,因此咱們融合了一個用於引導非 API 數據的規範機制,咱們開始將全部應用程序和頁面遷移到 Rails 和 React/Hypernova 之間的這種切換。
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import AirbnbUser from '[our internal user management library]';
import BootstrapData from '[our internal bootstrap library]';
import Experiments from '[our internal experiment library]';
import KillSwitch from '[our internal kill switch library]';
import L10n from '[our internal l10n library]';
import ImagePaths from '[our internal CDN pipeline library]';
import withPhrases from '[our internal i18n library]';
import { forbidExtraProps } from '[our internal propTypes library]';
const propTypes = forbidExtraProps({
behavioralUid: PropTypes.string,
bootstrapData: PropTypes.object,
experimentConfig: PropTypes.object,
i18nInit: PropTypes.object,
images: PropTypes.object,
killSwitches: PropTypes.objectOf(PropTypes.bool),
phrases: PropTypes.object,
userAttributes: PropTypes.object,
});
const defaultProps = {
behavioralUid: null,
bootstrapData: {},
experimentConfig: {},
i18nInit: null,
images: {},
killSwitches: {},
phrases: {},
userAttributes: null,
};
function withHypernovaBootstrap(App) {
class HypernovaBootstrap extends React.Component {
constructor(props) {
super(props);
const {
behavioralUid,
bootstrapData,
experimentConfig,
i18nInit,
images,
killSwitches,
userAttributes,
} = props;
// 清除服務器上的引導數據,以免泄露數據
if (!global.document) {
BootstrapData.clear();
}
BootstrapData.extend(bootstrapData);
ImagePaths.extend(images);
// 在測試中用空對象調用 L10n.init 是不安全的
if (i18nInit) {
L10n.init(i18nInit);
}
if (userAttributes) {
AirbnbUser.setCurrent(userAttributes);
}
if (userAttributes && behavioralUid) {
Experiments.initializeGlobalConfiguration({
experiments: experimentConfig,
userId: userAttributes.id,
visitorId: behavioralUid,
});
} else {
Experiments.setExperiments(experimentConfig);
}
KillSwitches.extend(killSwitches);
}
render() {
// 理想狀況下,咱們只想經過 bootstrapData 傳輸數據
// 若是你使用 redux 或從服務端轉換數據到 bootstrap,你其實能夠將數據看成一個鍵值(key)傳入 bootstrapData,其餘屬性被使用可是不會傳入 app 。
return <App bootstrapData={this.props.bootstrapData} />;
}
}
Bootstrap.propTypes = propTypes;
Bootstrap.defaultProps = defaultProps;
const wrappedComponentName = App.displayName || App.name || 'Component';
Bootstrap.displayName = `withHypernovaBootstrap(${wrappedComponentName})`;
return Bootstrap;
}
export default compose(withPhrases, withHypernovaBootstrap);複製代碼
用於引導非 API 數據規範的更高階的組件
這個很是高階的組件作了兩件更重要的事情:
bootstrapData
的一切 ,它是另外一個簡單的對象,必要時把 <App>
組件傳入 Redux 做爲 children 使用。單純來看,咱們刪除了 add_bootstrap_data
,並阻止工程師將任意鍵傳遞到頂級的 React 組件。秩序被從新恢復,之前咱們在客戶端中動態地導航到路由,而且渲染材料複雜的 content,而不須要Rails來支持它。
服務端的重構已經有了頭緒,如今咱們把目光轉向客戶端。
那段日子已通過去了,朋友們,初始化時帶着可怕 loading 的巨型單頁面應用(SPA)已經不復存在了。當咱們提出用 React Router 作客戶端路由的方案時,可怕的 loading 是不少人提出拒絕的理由。
在 Chrome Timeline 中 route 包的懶加載
可是,再看看上文,你就會發現路由對代碼分割和延遲加載進行捆綁形成的影響。實質上,咱們在服務端渲染頁面而且僅僅傳輸最低限度的一部分用於在瀏覽器端交互的 Javascript 代碼,而後咱們利用瀏覽器的空餘時間主動下載其他部分。
在 Rails 端,咱們有一個 controller 用於經過 SPA 交付的全部路由。每個 action 只負責:(1)觸發客戶端導航中的一切請求,(2)將數據和配置引導到 Hypernova。咱們把每一個 action (controller、helpers 和 presenters 之間)都有上千行的 Ruby 代碼縮減到 20-30 行。實力碾壓。
但這不只僅是代碼的不一樣...
兩種方式加載東京主頁的對比(4-5 倍的差距)
...如今頁面間的過渡像奶油般順滑,而且這一步大幅提高了速度(約 5 倍)。並且咱們咱們能夠實現文章開頭的那張動畫特性。
在(採用)React 以前,咱們須要一次渲染整個頁面,咱們之前的 React 都是這麼作的。但如今咱們使用異步組件,相似這種方式, 掛載(mount)之後加載組件層次結構的部分。
export default class AsyncComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
Component: null,
};
}
componentDidMount() {
this.props.loader().then((Component) => {
this.setState({ Component });
});
}
render() {
const { Component } = this.state;
// `loader` 屬性沒有被使用。 它被提取,因此咱們不會將其傳遞給包裝的組件
// eslint-disable-next-line no-unused-vars
const { renderPlaceholder, placeholderHeight, loader, ...rest } = this.props;
if (Component) {
return <Component {...rest} />;
}
return renderPlaceholder ?
renderPlaceholder() :
<WrappedPlaceholder height={placeholderHeight} />;
}
}
AsyncComponent.propTypes = {
// 注意 loader 是返回一個 promise 的函數。
// 這個 promise 應該處理一個可渲染的組件。
loader: PropTypes.func.isRequired,
placeholderHeight: PropTypes.number,
renderPlaceholder: PropTypes.func,
};複製代碼
這對於最初不可見的重量級元素尤爲有用,好比 Modals 和 Panels。咱們的明確目標是一行也很少地提供初始化頁面可見部分所需的 JavaScript,並使其可交互。這也意味着若是,比方說團隊想使用 D3 用於頁面彈窗的一個圖表,而其餘部分不使用 D3,這時候他們就能夠權衡一下下載倉庫的代碼,能夠把他們的彈窗代碼和其餘代碼隔離出來。
最重要的是,它能夠簡單地在任何須要的地方使用:
import React from 'react';
import AsyncComponent from '../../../components/AsyncComponent';
import scheduleAsyncLoad from '../../../utils/scheduleAsyncLoad';
function mapLoader() {
return new Promise((resolve) => {
if (process.env.LAZY_LOAD) {
return airPORT('./Map', 'HomesSearchMap')
.then(x => x.default || x);
}
});
}
export function scheduleMapLoad() {
scheduleAsyncLoad(searchResultsMapLoader);
}
export default function MapAsync(props) {
return <AsyncComponent loader={mapLoader} {...props} />;
}
view raw複製代碼
這裏咱們能夠簡單地把咱們的同步版本的地圖換成異步版本,這在小斷點上特別有用,用戶經過點擊按鈕顯示地圖。考慮到大多數用戶用手機,在擔憂 Google 地圖以前,讓他們進入互動會縮短加載時的焦慮感。
另外,注意 scheduleAsyncLoad()
組件,在用戶交互以前就要請求包。考慮到地圖如此頻繁地被使用,咱們不須要等待用戶交互纔去請求它。而是在用戶進入主頁和搜索頁的時候就把它加入隊列,若是用戶在下載完成以前就請求了它,他們會看到一個 <Loader />
直到組件可用。沒毛病。
這種方法的最後一個好處是 HomesSearch_Map
成爲瀏覽器能夠緩存的命名包。當咱們分解較大的基於路由的捆綁包時,應用程序中 slowly-changing 的部分在更新時保持不變,從而進一步節省了 JavaScript 下載時間。
毫無疑問,它保證的是一個專有的需求,可是咱們已經開始構建內部組件庫,其中輔助功能被強制爲一個嚴格的約束。在接下來的幾個月中,咱們將替換全部與屏幕閱讀器不兼容的橫跨客流的 UI 界面。
import React, { PropTypes } from 'react';
import { forbidExtraProps } from 'airbnb-prop-types';
import CheckBox from '../CheckBox';
import FlexBar from '../FlexBar';
import Label from '../Label';
import HideAt from '../HideAt';
import ShowAt from '../ShowAt';
import Spacing from '../Spacing';
import Text from '../Text';
import CheckBoxOnly from '../../private/CheckBoxOnly';
import toggleArrayItem from '../../utils/toggleArrayItem';
import ROOM_TYPES from '../../constants/roomTypes';
const propTypes = forbidExtraProps({
id: PropTypes.string.isRequired,
roomTypes: PropTypes.arrayOf(PropTypes.oneOf(ROOM_TYPES.map(roomType => roomType.filterKey))),
onUpdate: PropTypes.func,
});
const defaultProps = {
roomTypes: [],
onUpdate() {},
};
export default function RoomTypeFilter({ id, roomTypes, onUpdate }) {
return (
<div>
{ROOM_TYPES.map(({ id: roomTypeId, filterKey, iconClass: IconClass, title, subtitle }) => {
const inputId = `${id}-${roomTypeId}-Checkbox`;
const titleId = `${id}-${roomTypeId}-title`;
const subtitleId = `${id}-${roomTypeId}-subtitle`;
const selected = roomTypes.includes(filterKey);
const checkbox = (
<Spacing top={0.5} right={1}>
<CheckBoxOnly
id={inputId}
describedById={subtitleId}
name={`${roomTypeId}-only`}
checked={selected}
onChange={() => onUpdate({ roomTypes: toggleArrayItem(roomTypes, filterKey) })}
/>
</Spacing>
);
return (
<div key={roomTypeId}>
<ShowAt breakpoint="mediumAndAbove">
<Label htmlFor={inputId}>
<FlexBar align="top" before={checkbox} after={<IconClass size={28} />}>
<Spacing right={2}>
<div id={titleId}>
<Text light>{title}</Text>
</div>
<div id={subtitleId}>
<Text small light>{subtitle}</Text>
</div>
</Spacing>
</FlexBar>
</Label>
</ShowAt>
<HideAt breakpoint="mediumAndAbove">
<Spacing vertical={2}>
<CheckBox
id={roomTypeId}
name={roomTypeId}
checked={selected}
label={title}
onChange={() => onUpdate({ roomTypes: toggleArrayItem(roomTypes, filterKey) })}
subtitle={subtitle}
/>
</Spacing>
</HideAt>
</div>
);
})}
</div>
);
}
RoomTypeFilter.propTypes = propTypes;
RoomTypeFilter.defaultProps = defaultProps;複製代碼
經過咱們的設計語言系統將無障礙設計加入到產品的例子
這個 UI 很是豐富,咱們不只但願將 CheckBox 與 title 相關聯,還但願與使用了 aria-describedby
的 subtitle 關聯。爲了實現這一點,須要 DOM 中惟一的標識符,這意味着強制關聯一個必須的 ID 做爲任何調用方須要提供的屬性。若是一個組件被用於生產,這些是 UI 是能夠強制約束類型的,它提供內置的可訪問性。
上面的代碼也演示了咱們的響應式實體 HideAt 和 ShowAt,它使咱們可以大幅度地改變用戶在不一樣屏幕尺寸下的體驗,而無需使用 CSS 控制隱藏和顯示。這造就了更精簡的頁面。
不涉及關於如何處理應用程序狀態的爭論的前端文章不是完整的前端文章。
咱們使用 Redux 來處理全部的 API 數據和「全局」數據好比認證狀態和體驗配置。我的來說我喜歡 redux-pack 處理異步,你會發現新大陸。
然而,當遇到頁面上全部的複雜性 —— 特別是圍繞搜索的 —— 對於一些像表單元素這樣低級的用戶交互使用 redux 就沒那麼好用了。咱們發現不管如何優化,Redux 循環依然會形成輸入體驗的卡頓。
咱們的房間類型篩選器 (代碼在上面)
因此對於用戶的全部操做咱們使用組件的本地狀態,除非觸發路由變化或者網絡請求才使用 Redux,而且咱們沒再遇到什麼麻煩。
同時,我喜歡 Redux container 組件的那種感受,而且咱們即便帶有本地狀態,咱們依然能夠構建能夠共享的高階組件。一個偉大的例子就是咱們的篩選功能。搜索在底特律的家,你會在頁面上看見幾個不一樣的面板,每個均可以獨立操做,你能夠更改你的搜索條件。在不一樣的斷點之間,實際上有幾十個組件須要知道當前應用的搜索過濾器以及如何更新它們,在用戶交互期間被暫時或正式地被用戶接受。
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import SearchFiltersShape from '../../shapes/SearchFiltersShape';
import { isDirty } from '../utils/SearchFiltersUtils';
function mapStateToProps({ exploreTab }) {
const {
responseFilters,
} = exploreTab;
return {
responseFilters,
};
}
export const withFiltersPropTypes = {
stagedFilters: SearchFiltersShape.isRequired,
responseFilters: SearchFiltersShape.isRequired,
updateFilters: PropTypes.func.isRequired,
clearFilters: PropTypes.func.isRequired,
};
export const withFiltersDefaultProps = {
stagedFilters: {},
responseFilters: {},
updateFilters() {},
clearFilters() {},
};
export default function withFilters(WrappedComponent) {
class WithFiltersHOC extends React.Component {
constructor(props) {
super(props);
this.state = {
stagedFilters: props.responseFilters,
};
}
componentWillReceiveProps(nextProps) {
if (isDirty(nextProps.responseFilters, this.props.responseFilters)) {
this.setState({ stagedFilters: nextProps.responseFilters });
}
}
render() {
const { responseFilters } = this.props;
const { stagedFilters } = this.state;
return (
<WrappedComponent
{...this.props}
stagedFilters={stagedFilters}
updateFilters={({ updateObj, keysToRemove }, callback) => {
const newStagedFilters = omit({ ...stagedFilters, ...updateObj }, keysToRemove);
this.setState({
stagedFilters: newStagedFilters,
}, () => {
if (callback) {
// setState callback can be called before withFilter state
// propagates to child props.
callback(newStagedFilters);
}
});
}}
clearFilters={() => {
this.setState({
stagedFilters: responseFilters,
});
}}
/>
);
}
}
const wrappedComponentName = WrappedComponent.displayName
|| WrappedComponent.name
|| 'Component';
WithFiltersHOC.WrappedComponent = WrappedComponent;
WithFiltersHOC.displayName = `withFilters(${wrappedComponentName})`;
if (WrappedComponent.propTypes) {
WithFiltersHOC.propTypes = {
...omit(WrappedComponent.propTypes, 'stagedFilters', 'updateFilters', 'clearFilters'),
responseFilters: SearchFiltersShape,
};
}
if (WrappedComponent.defaultProps) {
WithFiltersHOC.defaultProps = { ...WrappedComponent.defaultProps };
}
return connect(mapStateToProps)(WithFiltersHOC);
}複製代碼
這裏咱們有一個利落的技巧。每個須要和篩選交互的組件只需被 HOC 包裹起來,就是這麼簡單。它甚至還有屬性類型。每一個組件都經過 Redux 鏈接到 responseFilters(與當前顯示的結果相關聯),並同時保有一個本地 stagedFilters 狀態對象用於更改。
以這種方式處理狀態,與咱們的價格滑塊進行交互對頁面的其他部分沒有影響,因此表現很好。並且全部過濾器面板都具備相同的功能簽名,所以開發也很簡單。
既然如今繁重的前端改造工做已經接近完成,咱們能夠把目光轉向將來。
歡迎下次繼續圍觀咱們的成果分享。由於這麼多的成果會有一些數量上的衝突,咱們將盡可能選擇一些具體的成果在下篇文章中總結。
天然,若是你欣賞本文並以爲這是一個有趣的挑戰,咱們一直在尋找優秀出色的人加入團隊。若是你只想作一些交流,那麼隨時能夠點擊個人 twitter @adamrneary。
最後,深切地向 Salih Abdul-Karim 和 Hugo Ahlberg 兩位體驗設計師致敬,他們的使人動容的動畫至今讓我目不轉睛。許多工程師在他們的領域值得讚美,做出貢獻的人數衆多,難以一一列出的,但絕對包括 Nick Sorrentino、Joe Lencioni、Michael Landau、Jack Zhang、Walker Henderson 和 Nico Moschopoulos.
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃。