prerenderについて

なぜprerender?

ブラウザーがSPAのindex.htmlをダウンロードし解析した後からCSSファイルとJavaScriptファイルダウンロードと解析完了まで時間差があります。

特にcode-splitやっていないSPAのJavaScriptファイルのダウンロードはもっと時間かかります。

もしユーザーさんは3Gを使うと、ダウンロード時間がもっと長くなり、数十秒の空白画面が表示されてしまう場合があります。

これにより離脱する可能性があります。

prerenderはwebpackでHTML、CSSJavaScript、画像をコンバイるした後、Headless Browserを立ち上げて、実際のページを解析し、JavaScriptが実行した後の結果(CSSコードやJavaScriptに よって作成されたDOMなど)をHTMLファイルに追加する過程です。

prerenderの前のHTML

<!doctype html>
<html>
<head>
  <link href="/bundle.css" ref="stylesheet" />
</head>
<body>
  <div id="main"></div>
  <script type="text/javascript" src="/bundle.js"></script>
</body>
</html>

prerenderの後のHTML

<!doctype html>
<html>
<head>
  <style>
    <!-- 具体的なCSSコード -->
  </style>
  <link href="/bundle.css" ref="stylesheet" />
</head>
<body>
  <div id="main">
    <div data-reactroot>
      <span>
        <!-- 具体的なJavaScriptにより作成されたDOM -->
      </span>
    </div>
  </div>
  <script type="text/javascript" src="/bundle.js"></script>
</body>
</html>

これでよりCSSJavaScriptファイルをダウロードする間、ある程度整ってるページが表示され、ユーザーさんが寂しくないです。

使うライバリー

prerender-spa-plugin https://github.com/chrisvfritz/prerender-spa-plugin

webpack.config.js

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      // webpackのアウトプット
      staticDir: path.join(__dirname, 'dist'),
      // prerenderが必要なページ
      routes: [ '/', '/about', '/some/deep/nested/route' ],
    })
  ]
}

どんなページがprerender必須?

rootページ

特にprerender指定しない時、全部のpathに対するリクエストはrootページのHTMLファイルを返しています。

react-helmetによってOGP情報を変えたページ

だいたい全部SNSサイトのクローラーJavaScript実行していません。

正しいOGP情報を出し分けるためprerenderが必要です。

いい感じにprerenderできるため

prerender-spa-pluginは1つの素晴らしい特性があります。

prerenderする時、ページのwindowオブジェクトに”prerenderですよ”を示す変数を入れることができます。

webpack.config.js

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      ...
      // window.__PRERENDER_INJECTED && window.__PRERENDER_INJECTED.isPrerendering
      // によってprerender中かどうかを判断できる
      renderer: new Renderer({
        injectProperty: "__PRERENDER_INJECTED",
        inject: {
          isPrerendering: true,
        },
      })
    })
  ]
}

これにより、prerenderの時ローディングUIなどをだすことが可能となります。

まとめ

SPAは膨大のCSSJavaScriptファイルのダウンロードによって長い時間でブラウザーを空白にすることがあります。

prerenderによってコードを最初ダウンロードされるHTMLファイルを埋めることができます。

これで空白が解消できます。

JavaScriptでOGP情報を出し分ける時、prerenderが必須です。

prerender-spa-pluginによっていい感じにprerenderができます。

スクショをベースにするスナップショットテスト

目的

CSSやJSコードをいじった後、見た目の差分を確認できます。

Reactの場合、いっぱい使われている共通のコンポーネント修正した後、既存のコンポーネントへの影響を確認することができます。

必要なパッケージ

テストのカバーするページの定義

/routes/Test.tsx

interface RouteData {
  // ページ名
  name: string;
  // ページpath
  path: string;
  // ページ表示用のテストContainer Component
  component: React.SFC<{}> | React.ComponentClass<RouteComponentProps<any>>;
  // 横画面のスクショ比較が必要かどうか
  isHorizontalTestNeeded?: boolean;
}

// テスト実行時用データ
export const testRouteDatas: RouteData[] = [
  {
    name: "enzaメンバー登録ページ",
    path: "/register",
    component: UserSimpleRegisterPage,
    isHorizontalTestNeeded: true,
  },
  {
    name: "エントランスページ",
    path: "/entrance",
    component: EntrancePage,
    isHorizontalTestNeeded: true,
  },
  {
    name: "ゲームポータルトップページ",
    path: "/game",
    component: TopPage,
  },
  // ...
];

// テスト用のpathを作る
export const generateTestPath = (path: string) => (
  `/snapshotTest${path}`
);

// Route情報データ
export const testRoute = testRouteDatas.map((data) => (
  <Route
    path={generateTestPath(data.path)}
    key={generateTestPath(data.path)}
    component={data.component}
    exact={true}
  />
));

全体Route定義

App.tsx

import { testRoute } from "routes/Test";
// ...
<Switch location={location}>
  {/* develop環境だけテストのRouteにアクセスできる */}
  {ENV === "develop" ? testRoute : null}
</Switch>
// ...

スナップショットテスト定義

test/snapshot.ts

import * as puppeteer from "puppeteer";
import { generateTestPath, testRouteDatas } from "routes/Test";
import * as QueryString from "query-string";

let browser;

// Headless Chrome Browser を立ち上げ
beforeAll(async () => {
  browser = await puppeteer.launch({
    args: ["--no-sandbox", "--disable-setuid-sandbox"]
  });
});

testRouteDatas.forEach(async (routeData) => {
  const testUrl = `http://develop.env.host.name/${
    generateTestPath(routeData.path)
  }`;

  it(routeData.name, async () => {
    const page = await browser.newPage();
    page.setViewport({
      width: 360,
      height: 640,
    });
    await page.goto(testUrl);
    // 縦画面のページスクショ
    const image = await page.screenshot({ fullPage: true });
    // 現存のスクショと比較
    expect(image).toMatchImageSnapshot();
  });

  if (routeData.isHorizontalTestNeeded) {
    it(`横画面-${routeData.name}`, async () => {
      const page = await browser.newPage();
      page.setViewport({
        width: 640,
        height: 360,
      });
      await page.goto(testUrl);
      // 縦画面のページスクショ
      const image = await page.screenshot({ fullPage: true });
      // 現存のスクショと比較
      expect(image).toMatchImageSnapshot();
    });
  }
});

// Headless Chrome Browser を閉じる
afterAll(async () => {
  await browser.close();
});

jestでtoMatchImageSnapshotメソッドを使えるように

jest-setup.ts

import { toMatchImageSnapshot } from "jest-image-snapshot";

expect.extend({ toMatchImageSnapshot });

コマンド追加

package.json

{
  "scripts": {
    "test:snapshot": "jest /test/snapshot.ts",
    "test:snapshot:update": "jest /test/snapshot.ts --updateSnapshot"
  }
}

使い方

yarn test:snapshot

でテストを実行します。

差分が検出されたら、下記のようなファイルが作成されます。

f:id:cliffzhao:20180619184700p:plain

差分問題なかったら、下記のコマンドでスナップショットを更新します。

yarn test:snapshot:update

Todo

実はpuppeteerでクリックやキーボード入力などイベントを発火できます。

動作を定義できたらもっと前面にテストすることが可能だと思います。

react-cropperのSafari不具合対策

react-cropperとは

cropper.jsをベースとして、画像クロップツールReactコンポーネントです。 https://github.com/roadmanfong/react-cropper

Safariでの不具合

class Demo extends Component {

  _crop(){
    const dataUrl = this.refs.cropper.getCroppedCanvas().toDataURL();
    console.log(dataUrl);
  },

  render() {
    return (
      <Cropper
        ref='cropper'
        crop={this._crop.bind(this)} />
    );
  }
}

上記は基本的な使い方です。

this.refs.cropper.getCroppedCanvas().toDataURL();でクロップ結果をプリントします。

もし画像の色が豊富だと、Safari(PCもスマホも)ではcross-originエラーが出ます。

不具合対策

クロップページで操作完了したあと、直接にthis.refs.cropper.getCroppedCanvas().toDataURL();base64結果を出すじゃなくて、結果のクロップデータ(x,y,width,height)と元画像によってプレビュー用のcanvasを作リます。

ユーザーさんがレビューしたあと保存するとき、プレビュー用のクロップ結果canvasのtoDataURL();を呼び出します。

class CropperResultCanvas extends React.Component<CropperResultCanvasProps, {}> {
  private canvas: HTMLCanvasElement;
  private setCanvasRef = (ref: HTMLCanvasElement) => {
    this.canvas = ref;
  }

  public toDataURL = () => (
    this.canvas.toDataURL()
  )

  componentDidMount() {
    this.drawCanvas();
  }

  private drawCanvas() {
    const { originImageUrl, cropperData } = this.props;
    const canvas = this.canvas;

    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      const context = canvas.getContext("2d");
      (context as any).imageSmoothingQuality = "medium";
      if (context) {
        const horizontalRatio = canvas.width / cropperData.width;
        const verticalRatio = canvas.height / cropperData.height;
        const ratio = Math.max(horizontalRatio, verticalRatio);

        const centerShiftX = (canvas.width - cropperData.width * ratio) / 2;
        const centerShiftY = (canvas.height - cropperData.height * ratio) / 2;

        context.drawImage(
          img,
          cropperData.x,
          cropperData.y,
          cropperData.width,
          cropperData.height,
          centerShiftX,
          centerShiftY,
          cropperData.width * ratio,
          cropperData.height * ratio,
        );
      }
    };

    img.src = originImageUrl;
  }

    render() {
    const { width, height, className } = this.props;

    return (
      <canvas
        {...{ width, height, className }}
        ref={this.setCanvasRef}
      />
    );
  }
}

img.crossOrigin = "anonymous";は重要です。入れないとcross-originが出ます。

toDataURL()メソッドでbase64結果が出力できます。

まとめ

レビュー用canvasのdrawImageによってクロップ結果を描きます。

不具合回避の一方、出力したbase64結果はライバーリーで直接に出力した結果よりファイルサイズが小さくなる利点もあります。

react-routerでページ遷移時アニメーション実装

概要

ページ遷移時スライドやフェードなどアニメーションを追加します。 ReactCSSTransitionGroupによってCSSアニメーション実装です。 locationの変化によってアニメーションを入れるかどうかを決めます。

実装

App.jsx

const HORIZONTAL_SLIDE_TRANSITION_PAGES = new RegExp([
  "/abc",
  "/def",
].join("|"));
const PAGE_TRANSITIOM_DURATION = 300;

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { pageTransition: "none" };
  }

  componentWillReceiveProps(nextProps) {
    if (this.props.location !== nextProps.location) {
      if (HORIZONTAL_SLIDE_TRANSITION_PAGES.test(nextProps.location.pathname)) {
        this.setState({ pageTransition: "slide-up" });
      }
      if (HORIZONTAL_SLIDE_TRANSITION_PAGES.test(this.props.location.pathname)) {
        this.setState({ pageTransition: "slide-down" });
        setTimeout(() => {
          this.setState({ pageTransition: "none" });
        }, PAGE_TRANSITIOM_DURATION);
      }
    }
  }

  render() {
    const { location } = this.props;
    const { pageTransition } = this.state;
    const isPageTransition = pageTransition !== "none";

    return (
      <ReactCSSTransitionGroup
        transitionName={pageTransition}
        transitionEnterTimeout={PAGE_TRANSITIOM_DURATION}
        transitionLeaveTimeout={PAGE_TRANSITIOM_DURATION}
        transitionEnter={isPageTransition}
        transitionLeave={isPageTransition}
      >
        <Switch location={location}>
          <Route path="/abc" component={AbcPage} />
          <Route path="/def" component={DefPage} />
          <Route path="/ghi" component={GhiPage} />
          <Route path="/" component={TopPage} />
        </Switch>
      </ReactCSSTransitionGroup>
    );
  }
}

styles.css

.slide-up-enter {
  position: absolute;
  width: 100%;
  transform: translateY(100vh);
  z-index: 10;
}

.slide-up-enter.slide-up-enter-active {
  transform: translateY(0);
  transition: transform 300ms cubic-bezier(0.14, 0.68, 0.36, 1);
}

.slide-down-leave {
  position: absolute;
  top: 0;
  transform: translateY(0);
  width: 100%;
  z-index: 10;
}

.slide-down-leave.slide-down-leave-active {
  transform: translateY(100vh);
  transition: transform 300ms cubic-bezier(0.14, 0.68, 0.36, 1);
}

参考

https://facebook.github.io/react/docs/animation.html

理想的なモーダル実装

概要

理想的なモーダルを実装してみました。 下記の条件を満たしました。

  • CSSだけでレイアウトをコントロール
  • 内容が多いときスクロールができる
  • 常に真ん中にある
  • クローズボタンを配置できる

実装

index.html

<!doctype html>
<html>
<head>
  <link rel="stylesheet" type="text/css" href="./styles.css">
  <meta charset="utf-8" />
  <title>Ideal Modal</title>
</head>
<body>
  <button>Open Modal</button>
  <div class="outer">
    <div class="inner">
      <div class="inner-inner">
        <p class="content">This is an ideal modal</p>
        <a class="close"></a>
      </div>
    </div>
  </div>
</body>
</html>

styles.css

.outer {
  display: flex;
  justify-content: space-around;
  align-items: center;
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1;
  background: rgba(0, 0, 0, 0.5);
}

.inner {
  position: relative;
}

.inner-inner {
  max-height: 95vh;
  background: #fff;
  border-radius: 10px;
  overflow: auto;
  width: 600px;
}

.content {
  text-align: center;
  padding: 50px;
  font-size: 40px;
}

.close {
  position: absolute;
  top: -30px;
  right: -30px;
  width: 60px;
  height: 60px;
  background: url(./close-button.png) center/cover no-repeat;
}

効果

f:id:cliffzhao:20180619151500p:plain

React Routerにおけるルーティング定義ファイル分割方法

React Routerとは

React RouterはReactによって作るSPAで使うルーティング管理ライバーリーです。 https://reacttraining.com/react-router/

分割前

公式ドキュメントのサンプルでルーティングの定義はこんな感じです。 分割の仕方について紹介されていません。

<Router>
  <Switch>
    <Route path="/" exact component={Home} />
    <Route path="/will-match" component={WillMatch} />
    <Route component={NoMatch} />
  </Switch>
</Router>

分割方法①

ファイル#1

export default const Routes = () => (
  <Switch>
    <Route path="/" exact component={Home} />
    <Route path="/lovely" exact component={Lovely} />
  </Switch>
);

ファイル#2

export default const AnotherRoutes = () => (
  <Switch>
    <Route path="/cute" exact component={Cute} />
    <Route path="/clean" exact component={Clean} />
  </Switch>
);

分割統合ファイル

import Routes from "./Routes";
import AnoterRoutes from "./AnotherRoutes";

export default const App = () => (
  <Router>
    <Routes />
    <AnotherRoutes />
  </Router>
);

この方法で一般的なルーティング定義は大丈夫ですが、複数のSwitchコンポーネントの影響で、404ページの指定ができません。 下記のように公式ドキュメントみたいな感じに404ページを指定すると、404部分のコンポーネントはずっと出ていることとなります。

import Routes from "./Routes";
import AnoterRoutes from "./AnotherRoutes";

export default App = () => (
  <Router>
    <Routes />
    <AnotherRoutes />
    <Route component={NoMatch} />
  </Router>
);

分割方法②

ファイル#1

export const routes = [
  <Route path="/" key="/" exact component={Home} />,
  <Route path="/lovely" key="/lovely" exact component={Lovely} />
];

ファイル#2

export const anotherRoutes = [
  <Route path="/cute" key="/cute" exact component={Cute} />,
  <Route path="/clean" key="/clean" exact component={Clean} />
];

分割統合ファイル

import { routes } from "./Routes";
import { anotherRoutes } from "./AnotherRoutes";

export App = () => (
  <Router>
    <Switch>
      {routes}
      {anotherRoutes}
      <Route component={NoMatch} />
    </Switch>
  </Router>
);

この方法によって、全部のRouteコンポーネントは一つのSwitchコンポーネントの下になります。 404ページの定義は可能となります。

React 16から

React 16ではFragmentという素晴らしい特性を入れました。 https://reactjs.org/blog/2017/11/28/react-v16.2.0-fragment-support.html これによりもっと綺麗にかけます。

ファイル#1

export default const Routes = () => (
  <React.Fragment>
    <Route path="/" exact component={Home} />
    <Route path="/lovely" exact component={Lovely} />
  </React.Fragment>
);

ファイル#2

export default const AnotherRoutes = () => (
  <React.Fragment>
    <Route path="/cute" exact component={Cute} />
    <Route path="/clean" exact component={Clean} />
  </React.Fragment>
);

分割統合ファイル

import Routes from "./Routes";
import AnoterRoutes from "./AnotherRoutes";

export default const App = () => (
  <Router>
    <Routes />
    <AnotherRoutes />
    <Route component={NoMatch} />
  </Router>
);

まとめ

大型SPAを構築するとき、ページ数が多くてルーティング定義を分割するのが必要だと思います。 Switchの定義じゃなくて、Routeコンポーネント配列を複数ファイルに分けた方が良いかと思います。 React 16のFragment特性によりもっと綺麗に分けることができます。

bodyの中のlinkタグでロードされるCSSファイルによる一瞬画面変化を避ける方法

方法

ロードする間表示用ページを用意します。 styleタグの中のコードでローディングデザインを出す。 ロードされるCSSの中でローディングデザインを隠す。

index.html

<!doctype html/>
<html>
<head>
  <meta charset="utf-8" />
  <style>
    .loading {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: gray;
    }
</style>
</head>
<body>
  <style>
    .loading {
      display: block;
    }
  </style>
  <link rel="stylesheet" href="style.css">
  <div class="loading">
    Loading...
  </div>
  <div class="content">
    Content
  </div>
</body>
</html>

style.css

.loading {
  display: none !important;
}