prerenderについて
なぜprerender?
ブラウザーがSPAのindex.htmlをダウンロードし解析した後からCSSファイルとJavaScriptファイルダウンロードと解析完了まで時間差があります。
特にcode-splitやっていないSPAのJavaScriptファイルのダウンロードはもっと時間かかります。
もしユーザーさんは3Gを使うと、ダウンロード時間がもっと長くなり、数十秒の空白画面が表示されてしまう場合があります。
これにより離脱する可能性があります。
prerenderはwebpackでHTML、CSS、JavaScript、画像をコンバイるした後、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>
これでよりCSSとJavaScriptファイルをダウロードする間、ある程度整ってるページが表示され、ユーザーさんが寂しくないです。
使うライバリー
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は膨大のCSSとJavaScriptファイルのダウンロードによって長い時間でブラウザーを空白にすることがあります。
prerenderによってコードを最初ダウンロードされるHTMLファイルを埋めることができます。
これで空白が解消できます。
JavaScriptでOGP情報を出し分ける時、prerenderが必須です。
prerender-spa-plugin
によっていい感じにprerenderができます。
スクショをベースにするスナップショットテスト
目的
CSSやJSコードをいじった後、見た目の差分を確認できます。
Reactの場合、いっぱい使われている共通のコンポーネント修正した後、既存のコンポーネントへの影響を確認することができます。
必要なパッケージ
- jest https://facebook.github.io/jest/
- puppeteer https://github.com/GoogleChrome/puppeteer
- jest-image-snapshot https://github.com/americanexpress/jest-image-snapshot
テストのカバーするページの定義
/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
でテストを実行します。
差分が検出されたら、下記のようなファイルが作成されます。
差分問題なかったら、下記のコマンドでスナップショットを更新します。
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); }
参考
理想的なモーダル実装
概要
理想的なモーダルを実装してみました。 下記の条件を満たしました。
実装
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; }
効果
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; }