AbemaTV の番組表リニューアルに伴うパフォーマンス改善

以前の番組表
![スクリーンショット 2016-10-08 07.43.42.png](https://qiita-image-store.s3.amazonaws.com/0/3989/75f03bc8-fcb9-d221-4670-107be8124c53.png)

↓↓↓

[AbemaTV 新番組表](https://abema.tv/timetable)
![スクリーンショット 2016-10-08 07.44.28.png](https://qiita-image-store.s3.amazonaws.com/0/3989/ec130dae-c8ca-e894-c17e-da14592ec964.png)

##大きく変わったところ
- チャンネルが**横スクロールですべて見れる**ようになった
- サイドバーに**チャンネル一覧**ができた
- 日付切り替えがセレクトボックスから**カレンダー表示**になった

etc.

今まではチャンネルが 5ch 毎に区切られていたので、わざわざページを切り替えて見ないといけなかったのを、横スクロールですべてのチャンネルが一覧で見られるようになりました。
さらにチャンネル毎の番組表にもアクセスしやすくなり、日付の切り替えもしやすくなったかと思います。
このリリースによって、ユーザービリティが少しでも向上していれば幸いです。

しかし、便利になった反面、このリリースに至るまでに、パフォーマンス面で様々なところで問題になりました。
今回は実際にリニューアル中に課題となった箇所と解決方法の一部を紹介します。
AbemaTV 以外であまり参考にならないかもしれませんが備忘録として…。


##今回のリニューアルでパフォーマンスの改善ポイント
> - チャンネルが横スクロールですべて見れるようになった

特にパフォーマンス面で顕著に現れたのは、上記で、
今までは 5ch ごとに分割表示をしてたところを全件表示にしたので何も考えないで今まで通りに実装すると、単純に表示待ち時間が増大しました。

![Untitled.gif](https://qiita-image-store.s3.amazonaws.com/0/3989/7d0d796b-659a-e718-05e7-e4ef8a8ebebd.gif)

↑ざっくり今までのロジックでチャンネル全件並べた例

![スクリーンショット 2016-10-08 08.39.29.png](https://qiita-image-store.s3.amazonaws.com/0/3989/c3cdf175-a161-5e12-da8a-695935d64203.png)
react-addons-perf での計測結果

初回レンダーに入るまでに処理が重すぎて真っ白になってしまいました…


##前提条件
〜なぜそもそも普通に実装するだけでは重くなるのか〜

番組表のひとつひとつのセルの表示条件
![スクリーンショット 2016-10-08 10.41.12.png](https://qiita-image-store.s3.amazonaws.com/0/3989/cb5760dd-ac32-c2e5-e236-b5110d4e3bb9.png)

api

```json
slots: [
 {
    timetableStartAt: 1475910000,
    tableEndAt: 1475918400,
    title: "AbemaNews午後③"
    detailHighlight: "注目ニュース&話題の情報を24時間配信中!緊急速報や注目の会見は随時、まるごと生中継!何か起きたらAbemaNewsでチェック!",
    thumbImg: "hogehoge"
  }
  …
]
```

_（なんとなくの雰囲気json）_

番組枠情報（セル）を `slot` として、↑のようなデータに対して

```
表示優先順位
1. タイトル
2. サムネイル画像
3. 見どころ
```

というのは決まっており、最初に `title` （タイトル）、 `thumbImg` （サムネイル画像）、 `detailHighlight` （見どころ）の要素の高さを取得するために一度レンダーし、その後 `timetableStartAt` （開始時間）、 `tableEndAt` （終了時間）をもとにセルの高さを計算し、上記の優先順位をもとにセルの高さ内に収まるように配置していくために再レンダーさせる必要がありました。

<img width="194" alt="スクリーンショット 2016-10-11 7.00.51 PM.png" src="https://qiita-image-store.s3.amazonaws.com/0/3989/e0f44ec7-6558-822e-9d16-8ee7c1204aae.png">
タイトル、見どころ、サムネイル画像全部入りの例

<img width="189" alt="スクリーンショット 2016-10-11 7.02.59 PM.png" src="https://qiita-image-store.s3.amazonaws.com/0/3989/1dbaa85d-3bae-2847-86f4-ffbb35f3fdfa.png">
タイトル、見どころ（途中まで表示）サムネイル画像のみの例

<img width="187" alt="スクリーンショット 2016-10-11 7.01.03 PM.png" src="https://qiita-image-store.s3.amazonaws.com/0/3989/7f03e3a5-0755-3ed9-c468-ca6b62b852de.png">
タイトル、サムネイル画像のみの例

<img width="186" alt="スクリーンショット 2016-10-11 7.01.29 PM.png" src="https://qiita-image-store.s3.amazonaws.com/0/3989/f1949a29-480a-55b6-a93d-5eba8ee77221.png">
タイトル（途中まで表示）のみの例

```javascript

state = {
  heights: {},
  showConditions: { // 初期値では全部表示する
    title: true,
    poster: true,
    tableHighlight: true
  }
};

componentDidMount() {
  const heights = {
    title: this.refs.title.offsetHeight
  };

  // 表示の優先度を決める関数に渡す
  const showConditions = showPriority(this.props.slot, heights);

  this.setState({
    isRendered: true,
    heights,
    showConditions
  });
}
…

/**
 * タイトル表示
 */
// タイトル、サムネイル画像、見どころ それぞれに同じような処理をする
renderTitle() {
  const { showConditions } = this.state;
  const { title } = this.props.slot;

  let el = null;
  if (showConditions.title) {
    el = (
      <p ref="title">
       {title}
      </p>
    );
  } else if (showConditions.lessTitle) {
    let heightStyle = {
      height: `${showConditions.lessTitle * LINE_HEIGHT}px`,
      lineHeight: `${LINE_HEIGHT}px`
    };

    el = (
      <p style={heightStyle}>
        {title}
      </p>
    );
  }
  return el;
}

…

render() {
  const fixedHeight = isRendered ? timeTableMaxHeight(tableStartAt, tableEndAt) : "auto";
  return (
    <div style={{ height: fixedHeight }}>
      {this.renderTitle()}
      {this.renderTableHighlight()}
      {this.renderPoster()}
    </div>
  );
}

```

ここがセル分どうしても計算しないと行けないので、レンダリングに時間がかかってします…。セルの数が 500 個あると 500 * 2 回レンダーしないといけなくなります。

##レンダリングの最適化

###改善その 1.　初期ロードの高速化

前述のセルのレンダリングの前提条件は変えられそうにないため、ユーザーの体感速度をできるだけ早くなるように、番組表のロジックに引きずられないヘッダー、サイドカラムを先に表示するように変更してみました。

![Untitled.gif](https://qiita-image-store.s3.amazonaws.com/0/3989/a470b290-353b-9165-dd96-f92675e91942.gif)
セル部分がブロッキングしてたからか、体感速度が若干あがったような…？

```javascript
componentDidMount() {
  …
  this.isRendered = true;
}

render() {
  …
  <div>
    <Header />
    <div>
      <SideColumn />

      {this.isRendered ? (
        <Timetable />
      ) : null}

    </div>
  </div>
}
```

###改善その 2.　分割レンダリング

旧番組表では 5ch ずつに分割されていたので一回のレンダリングするセルの数がその分少なくすることができていました。

計算するまでもないですが…
全 30ch で 1ch あたり 20 個セルがあったとして、
30 * 20 = 600 
セルの数は 600 個になります。

5ch ずつ表示するようにすれば、一度あたりの表示数は 100 個で済みます。

同じように、新番組表でもそこに習って 5ch ずつ時差式にレンダリングするようにしてみました。

![Untitled.gif](https://qiita-image-store.s3.amazonaws.com/0/3989/b49ed877-5e0c-d8f0-8af3-6eae1ea9a3d7.gif)
初期ロードの体感速度はほぼ旧番組表と同じぐらいになりました。

```javascript
timetableListStepRender() {

  clearTimeout(this.timers.stepRender);

  // セルの計算が重いので、データを段階を踏んでレンダーする
  this.timers.stepRender = setTimeout(() => {

    // 5 チャンネル毎データを増やしていく
    if (this.showChannelSchedules < MediaStore.channelSchedules().length) {
      this.showChannelSchedules = this.showChannelSchedules + 5;
      this.setState({
        displayChannelSchedules: MediaStore.channelSchedules().slice(0, this.showChannelSchedules)
      });
    }
  }, INTERVAL_TIME);

}

render() {
  <Timetable channels={displayChannelSchedules} />
}
```


###改善その 3.　`shouldComponentUpdate` のチューニング

AbemaTVでは React v15.3 を使っていて（2016/10現在） `React.PureComponent` を導入しています。
PureComponent は内部的に `shallowEqual` を使って `props` と `state` を[比較しているようなので](https://github.com/facebook/react/blob/c8fbdac22717e91d1f1638a932eed3ce4cc39ff7/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js#L877-L878) `React.PropTypes.node` や `React.PropTypes.func` だと毎回レンダリングが走ってしまうので必要な箇所だけ手動で `shouldComponentUpdate` に追加するようにしました。[参考：[Perf] shouldComponentUpdate/pure components do not work with react element/node type props](https://github.com/facebook/react/issues/7412)

```javascript
shouldComponentUpdate(nextProps) {
  // React.PropTypes.func, node の場合は更新を無視する （≒ それ以外のものを全部書く）
  return (nextProps.className !== this.props.className ||
        nextProps.channels.length !== this.props.channels.length ||
        nextProps.startTime !== this.props.startTime ||
        nextProps.endTime !== this.props.endTime ||
        nextProps.showOndemandInfo !== this.props.showOndemandInfo ||
        nextProps.slotReservationIds.length !== this.props.slotReservationIds.length ||
        nextProps.slotOndemands.length !== this.props.slotOndemands.length);
}
```

![スクリーンショット 2016-10-09 00.49.06.png](https://qiita-image-store.s3.amazonaws.com/0/3989/90f34d23-aaad-a15f-e85c-ae417fe7d5d8.png)

最初に比べてかなりパフォーマンスが上がりました。

##スクロールの最適化
###改善その 1. GPUアクセラレーション巻き込み防止
Compositing Border を見てみると、意図しないレイヤーがたくさん生成され、パフォーマンスに影響が出てたので `position: relative;` 周りと `z-index` のチューニングをしました。

![47cee9d2-867d-11e6-8b47-afecde79207e.gif](https://qiita-image-store.s3.amazonaws.com/0/3989/dbe30b83-9b45-fe92-76b4-6d81234327e3.gif)
Before
いたるところに意図しないレイヤーが生成されていて、スクロールする度に描画にもたつきが発生しています。（gif だとわかりづらいけど…）

![4bb2393c-867d-11e6-84dc-00280285a3b1.gif](https://qiita-image-store.s3.amazonaws.com/0/3989/6e72f448-69d3-d9ee-96c4-eee0bbd4c499.gif)
After

_`position: relative;` を根本的になくそうとしてみましたが、実装の都合上断念…_

```css
.col {
  background-color: #eeeeee;
  border-right: 1px solid #dddddd
  float: left;
  height: 4320px;
  overflow: hidden;
  position: relative; /* 意図しないレイヤー生成を防ぐため。GPUアクセラレーション巻き込み防止 */
  width: 176px;
}
```
[参考：GPUアクセラレーションとposition: relativeによるレイヤー生成について](https://havelog.ayumusato.com/develop/performance/e556-compositing_border_and_layer.html)

###改善その 2. あえての DOM アクセス
React では基本的に直接 DOM アクセスをしないで、 `state` や `props` を更新することで DOM 更新するように実装します。しかし、ヘッダーや、番組表内の日付のバーなど、fixed 要素のスクロール追尾部分はスクロールする度にイベントが発生するので普通に実装すると `shouldComponentUpdate` が都度走ってしまいます。なので今回、あえて直接 DOM アクセスするように変更しました。
（AbemaTV ではできるだけコンポーネントに [Stateless Functions](https://facebook.github.io/react/docs/reusable-components.html#stateless-functions) を使うようにしているので、関係ないコンポーネントも都度レンダーしてしまっていました。）

```javascript

contentScrollListener() {
  const contentScrollLeft = this.contentWrapperRef.scrollLeft;
  // scrollLeft を直接 style 反映
  this.channelContentHeaderRef.style.transform = `translateX(-${ contentScrollLeft }px)`;
}

componentDidMount() {
  this.handleContentScroll = this.contentScrollListener.bind(this);
  this.contentWrapperRef = this.refs["content-wrapper"];
  this.channelContentHeaderRef = this.refs["channel-content-header"];
  // scrollイベントを取得
  this.contentWrapperRef.addEventListener("scroll", this.handleContentScroll);
}

render() {
  <div className={ styles["content-wrapper"] }
       ref="content-wrapper">
    <div className={ styles["channel-content-header"] }
         ref="channel-content-header"
         style={{
           width: TIMETABLE_ITEM_WIDTH * channelSchedules.length
         }}>
      <ChannelIconHeader
        channels={ channelSchedules }/>
    </div>
  </div>
}
```

この変更により、 `shouldComponentUpdate` の変更が増加しないので、滑らかにアニメーションさせることができました。

###改善その 3. コストの高い DOM 操作のチューニング

上記のコードで既に対応済みですが、直接 style を書き換えているところも `left` や `top` だと、再レイアウトを引き起こしてしまい非常にコストが高くなってしまうので、 `transform` を使って対応しています。

```javascript
// scrollLeft を直接 style 反映
this.channelContentHeaderRef.style.transform = `translateX(-${ contentScrollLeft }px)`;
```

[参考：High Performance Animations](https://www.html5rocks.com/ja/tutorials/speed/high-performance-animations/)

また、DOM に都度アクセスするのもコストが高いのでメンバ変数に入れて集約しています。

```javascript
this.contentWrapperRef = this.refs["content-wrapper"];
this.channelContentHeaderRef = this.refs["channel-content-header"];
…
```

##その他
上記に当てはまらないパフォーマンスチューニングです。

###改善その 1. addEventListener は throttle して取得する
<img width="441" alt="スクリーンショット 2016-10-12 10.52.56 AM.png" src="https://qiita-image-store.s3.amazonaws.com/0/3989/7b19d02f-3806-158a-749b-817be704af1d.png">

ヘッダーや、番組表内の日付のバーなど、fixed 要素のスクロール追尾部分はリアルタイムでついてきてほしいので、 throttle はしていませんが、番組表内にフロートで出している `前の日を見る` `次の日を見る` `現在時刻に戻る` や 左右への移動ボタンなど、 `mousemove` や `resize` イベントを取得して出し分けしています。
普通にそのまま取得してしまうと、マウスを動かしたり、リサイズする度に高負荷がかかってしまうので、 [lodash の throttle](https://lodash.com/docs/4.16.4#throttle) で間引いて取得するようにしています。

```javascript
import _ from "lodash";

this.handleResize = _.throttle(this.resizeListener.bind(this), THROTTLE_INTERVAL);
this.handleContentMousemove = _.throttle(this.contentMousemoveListener.bind(this), THROTTLE_INTERVAL);

window.addEventListener("resize", this.handleResize);
this.contentWrapperRef.addEventListener("mousemove", this.handleContentMousemove);
```

##まとめ
今回は AbemaTV の番組表リニューアル時に行ったパフォーマンス改善の一部を紹介しました。まだまだ、改善できるところはたくさんあるかと思います。[Passive event listeners](https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md) でスクロールなどのパフォーマンス改善をしたり、 [IntersectionObserver](https://github.com/WICG/IntersectionObserver/blob/gh-pages/explainer.md) の導入をしたり、技術進歩にともなってやれることもたくさんあるかと思います。パフォーマンスチューニングは、基本的に泥臭く地道に向き合っていかないと行けないと思うので、今後も引き続き改善していく予定です