[譯] Airbnb 的前端重構

磊仔發表於2017-05-31

概述:最近,我們重新思考了 Airbnb 程式碼庫中 JavaScript 部分的架構。本文將討論:(1)催生一些變化的產品驅動因素,(2)我們如何一步步擺脫遺留的 Rails 解決方案,(3)一些新技術棧的關鍵性支柱。彩蛋:我們將透露一下未來的發展方向。

Airbnb 每天處理超過 7500 萬次搜尋,這使得搜尋頁面成為我們流量最高的頁面。近十年來,工程師們一直在發展、加強和優化 Rails 輸出頁面的方式。

最近,我們轉移到了主頁以外的垂直頁面,來介紹一些體驗和去處。作為 web 端新增產品的一部分,我們花時間重新思考了搜尋體驗本身。

在一個用於寬泛搜尋的路由之間過渡

為了使使用者體驗流暢,我們選擇調整使用者瀏覽頁面和縮小搜尋範圍的互動方式,而不再採用以前那樣的多頁互動方式:(1)首先訪問著陸頁 www.airbnb.com,(2)接著進入搜尋結果頁,(3)隨後訪問某個列表頁,(4)最後進入預訂流程。每個頁面都是一個獨立的 Rails 頁面

設計三種瀏覽搜尋頁的狀態:新使用者、老使用者和營銷頁。

在標籤頁之間切換和與列表進行互動應該感到愜意而輕鬆。事實上,如今沒有什麼可以阻止我們在中小螢幕上提供與原生應用一致的體驗。

會考慮將來在切換標籤頁時,非同步載入相應內容

為了實現這種體驗,我們需要擺脫傳統的頁面切換方法,最終我們只好全面重構了前端程式碼。

Leland Richardson 最近在 React Conf 大會上發表了演講,稱 React Native 如今正處於和現有的高訪問量原生應用共存的“褐色地帶”這篇文章將會探討如何在類似的限制條件下進行 web 端重構。希望你在遇到類似情況時,這篇文章對你有所幫助。

從 Rails 之中解脫

在我們的燒烤開火之前,因為我們的線路圖上存在所有有趣的漸進式 web 應用(PWA),我們需要從 Rails 中解脫出來(或者至少在 Airbnb 用 Rails 提供單獨頁面的這種方式)。

不幸的是,就在幾個月前,我們的搜尋頁還包含一些非常老舊的程式碼,像指環王一樣,觸碰它就要小心自負後果。有趣的事實:我曾嘗試用一個簡單的 React 元件來替換基於 Rails presenter 的 Handlebars 模板,突然很多完全不相關的部分都崩掉了 —— 甚至 API 響應都出了問題。原來,presenter 改變了底層 Rails 模型,多年來即使在 UI 沒有渲染的時候,它也影響著所有的下游資料。

簡而言之,我們在這個專案中,就好像 Indiana Jone 用一袋沙子替換了寶物,突然間廟宇開始崩塌,我們正在從石塊中奔跑。

第 1 步: 調整 API 資料

當使用 Rails 在伺服器端渲染頁面時,你可以用任何你喜歡的方式把資料丟給伺服器端的 React 元件。Controllers、helpers 和 presenters 能生成任何形式的資料,甚至當你把部分頁面遷移到 React 時,每個元件都能處理它所需的任何資料。

但一旦你想渲染客戶端路由,你需要能夠以預定的形式動態請求所需的資料。將來我們可能用類似 GraphQL 的東西解決這個問題,但是現在暫且把它放到一邊吧,因為這件事和重構程式碼沒太大關係。相反,我們選擇在我們的 API 的 “v2” 上進行調整,我們需要我們所有的元件來開始處理規範的資料格式。

如果你自己和我們處在類似的情況中,在維護一個大型的應用,你可能發現我們像我們這樣做,規劃遷移現有的伺服器端資料管道是很容易的。只需在任何地方用 Rails 渲染一個 React 元件,並確保資料輸入是 API 所規定的型別。你可以用客戶端的 React PropTypes 來進一步驗證資料型別是否與 API v2 一致。

對我們來說棘手的問題是和那些參與客戶預定流程互動的團隊協作:商業旅遊、發展、度假租賃團隊;中國和印度市場團隊,災難恢復團隊等等,我們需要重新培訓所有這些人,即使在技術上可以將資料直接傳遞到正在呈現的元件上("是的,我明白,這僅僅是一種實驗,但是..."),所有的資料都要通過 API。

第 2 步: 非 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 資料規範的更高階的元件

這個非常高階的元件做了兩件更重要的事情:

  1. 它接收一個引導資料作為普通的舊物件的規範形式,並且正確地初始化所有支援的工具,用於伺服器渲染和客戶端渲染。
  2. 它吞噬除了 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 狀態物件用於更改。

以這種方式處理狀態,與我們的價格滑塊進行互動對頁面的其餘部分沒有影響,所以表現很好。而且所有過濾器皮膚都具有相同的功能簽名,因此開發也很簡單。

未來做些什麼?

既然現在繁重的前端改造工作已經接近完成,我們可以把目光轉向未來。

  • AMP 核心預訂流程中的所有頁面的 AMP 版本將會實現亞秒級(某些情況下)在手機 web 上 Google 搜尋的 可互動時間,通過行動網路和桌面網路,所需的許多更改將在 P50 / P90 / P95 冷負載時間內實現顯著改善。
  • PWA 功能將實現亞秒級(在某些情況下)返回訪客的可互動時間,並將開啟離線優先功能的大門,因此對於具有脆弱網路連線的使用者非常關鍵。
  • 下定決心幹掉老舊的技術和框架可以使包大小減少一半。這不是華而不實的工作,我們最終翻出 jQuery、Alt、Bootstrap、Underscore 以及所有額外的 CSS 請求(他們使渲染停滯,並且將近 97% 的規則是不會被使用!)不僅精簡了我們的程式碼,還精簡了新員工在上升時需要學習的足跡。
  • 最後,yeoman 的手動捕捉瓶頸的工作、非同步載入程式碼在初始渲染時不可見、避免不必要的重新渲染、並降低重新渲染的成本,這些改進正是拖拉機和頂級跑車之間的區別。

歡迎下次繼續圍觀我們的成果分享。因為這麼多的成果會有一些數量上的衝突,我們將盡量選擇一些具體的成果在下篇文章中總結。

自然,如果你欣賞本文並覺得這是一個有趣的挑戰,我們一直在尋找優秀出色的人加入團隊。如果你只想做一些交流,那麼隨時可以點選我的 twitter @adamrneary

最後,深切地向 Salih Abdul-KarimHugo Ahlberg 兩位體驗設計師致敬,他們的令人動容的動畫至今讓我目不轉睛。許多工程師在他們的領域值得讚美,作出貢獻的人數眾多,難以一一列出的,但絕對包括 Nick Sorrentino、Joe LencioniMichael Landau、Jack Zhang、Walker Henderson 和 Nico Moschopoulos.


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章