pixiv SketchのWeb版はReact + FluxibleでSSRを実現していました。
今回、FluxibleというライブラリをRedux Toolkit + Next.jsでリプレースした話をさせていただこうと思います。
このリプレースで狙った効果は、クライアントサイドのパフォーマンス向上と、開発容易性・メンテナンス性の向上の2つです。
背景
Next.js化前、つまりFluxibleの時点での状態として以下のものがありました。
- Fluxibleの開発が止まってしまっている・Fluxibleの知見が少ない
- 致命的なバグや脆弱性があったときに対応できない
- FluxibleはReact 15.x までしか対応しておらず、16以上はサポート対象外
- Storybookのstoryにできるコンポーネントと、できないコンポーネントにわかれてしまう
- コンポーネントにFluxible由来のアクションが使われているとstorybookのビルドが失敗してしまう
- SSRの大部分が自己流の実装だったので初めてコードを読む人が辛い
- 全画面でSSRしてるのでnodeサーバーへの負荷が高め
解決のアクションとして、Next.js化とRedux Toolkit導入の大きく2つを行いました。
Next.js化
- 社内外の知見が使いやすくなって、開発効率向上と、今の開発メンバー以外の新しい人が入った時も開発しやすくなる
- Next.jsはめちゃくちゃメンテナンスされてるので、Reactとか各種ライブラリの変更にもついていきやすい
- Storybookの問題が解決される
- Next.jsにすることでエンジニアの採用にもつながる
- SSRの実装のほとんどをNext.jsに寄せることができる
Redux Toolkit導入
- 状態管理ライブラリのデファクトスタンダードの一つだと思うので
- Reduxを採用することによって業務経験がある人の知見を流用できそう
- エンジニアの採用につながる
リリースまでの道のり
「①状態管理をReduxに置き換える」「②SSRをNext.jsに置き換える」の二つのリリースに分割しました
①状態管理をReduxに置き換える
Fluxibleの状態管理はReduxとかなり似通った作りになっていて、Fluxibleの名前の通りFluxです。
dispatch関数にTypeとPayloadを渡すことによってStoreが更新されます。
const store = { handlers: { SET_SOME_PARAM: 'setSomeParam', }, defaults: { someParam: '', }, setSomeParam: ({someParam}) => { this.set('someParam', someParam) }, getSomeParam: () => { return this.get(['someParam']); } } createStore('someStore', store) // contextにあるdispatch関数を使うとStoreが更新されてViewに渡っていきます // このcontextはFluxibleContextというcontextでFluxibleが生成してくれます context.dispatch('SET_SOME_PARAM', { someParam: 'someParam' })
ざっくりとこんな感じで状態を管理しています。
Viewへの繋ぎこみは
const connectToStores = ( stores, computeState, ) => (ComposedComponent) => class StoreConnector extends PureComponent { static displayName = `storeConnector(${ComposedComponent.displayName})`; static contextType = Context; render = () => { const { children, ...props } = { ...this.props, ...computeState(this.context, this.props) }; return React.createElement(ComposedComponent, props as any, children); }; private handleStoreChange = () => this.forceUpdate(); }; // Decoratorを使うパターン @connectToStores(['SomeStore'], (context, props) => ({ someParam: context.getStore('SomeStore').getSomeParam(), })) class SomeComponent extends PureComponent { constructor(props, context) { super(props, context); } render() { const someParam = this.props.someParam; return <>{someParam}</> } } // HOCを使うパターン const SomeComponent = (props) => { return <>{props.someParam}</> } connectToStores(['SomeStore'], (context, props) => ({ someParam: context.getStore('SomeStore').getSomeParam(), }))(SomeComponent)
こんな感じで繋いでいました。
この状態管理をReduxに置き換えます。
どういった風に置き換えるか、という議論にはFigJamを使って形作っていきました。
FigJam便利ですね〜!
コード的には以下の感じで置き換えました
// createStore相当のコード import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface SomeState { someParam: string } export const initialState: SomeState = { someParam: '' }; const someSlice = createSlice({ name: 'some', initialState, reducers: { setSomeParam: (state, action: PayloadAction<{ someParam: string }>) => { return { ...state, someParam: action.payload.someParam } } }, }); export const actions = someSlice.actions; export default someSlice.reducer; // selector const someSelectors = { getSomeParam: (state) => state.some.someParam }
Viewへの繋ぎこみは
// ClassComponentへの繋ぎこみ class SomeComponent extends PureComponent { constructor(props, context) { super(props, context); } render() { const someParam = this.props.someParam; return <>{someParam}</> } } const mapStateToProps = (state) => { return { someParam: someSelector.getSomeParam(state) } } connect(mapStateToProps)(SomeComponent) // hooksのパターン const SomeComponent = () => { const someParam = useSelector((state) => someSelector.getSomeParam(state)) return <>{someParam}</> }
こんな感じに書くことにしました。
結構見覚えのある感じで安心感がありますね。
Reduxで状態管理をしつつSSRするにはこのままではダメで、preloadStateをHtmlに書き込んであげる必要があります。
Redux公式が出しているServer-Renderingの記事を参考に
const preloadStateScript = `window.__PRELOADED_STATE__ = ${JSON.stringify(preState).replace(/</g, '\\u003c')}`; // Serverで取得したstateをclientに渡した後はStateを初期値に戻してあげないとserverのstateにpreloadedStateが溜まり続けるので初期化する store.dispatch(clearAction()) const element = ( <Context.Provider value={context.getComponentContext()}> <Provider store={store}> <Html preloadStateScript={preloadStateScript} {...renderComponent(context)} /> </Provider> </Context.Provider> ); ReactDOM.renderToNodeStream(element);
こんな感じでpreloadedStateを埋め込みました。
ここまでで一旦テストとリリースを行いました。問題なさそうなことを確認した後、次の工程へ。
②SSRをNext.jsに置き換える
SSRをNext.jsに置き換える際のキーワードとしては
- next-redux-wrapper
- custom server
- getServerSideProps
の三つがあります。一つづつ触れてゆきます。
next-redux-wrapper
こちらのライブラリはreduxをNext.jsで扱えるようにいい感じにラップしてくれるありがたい代物です。
import { configureStore } from '@reduxjs/toolkit'; import { createWrapper } from 'next-redux-wrapper'; import some from './Some/slice'; export const makeStore = () => configureStore({ reducer: { some, }, devTools: process.env.NODE_ENV !== 'production', }); export type RootStore = ReturnType<typeof makeStore>; export type RootState = ReturnType<RootStore['getState']>; export const wrapper = createWrapper<RootStore>(makeStore, { // Immutable.jsやImmerを使っている場合はここでserialize処理をすることができる serializeState: (wrapperState) => JSON.stringify(toJSState(wrapperState)), deserializeState: (wrapperState) => immutableState(JSON.parse(wrapperState)) as RootState; }, });
こんな感じで書かれたものを
// _app.tsx import { wrapper } from '../app/domains/store'; const App = ({ Component, pageProps }: AppProps) => <Component {...pageProps} /> export default wrapper.withRedux(App);
こうすることでgetServerSidePropsやgetStaticPropsなどで
import type { NextPage } from 'next'; import { wrapper } from '../app/domains/store'; import { actions as someActions } from '../app/domains/Some/slice'; import { SomeContainer } from 'app/containers/SomeContainer' const SomePage: NextPage = (props) => ( <SomeContainer /> ); export const getServerSideProps = wrapper.getServerSideProps((store) => async () => { const dispatch = store.dispatch; const someParam = async fetch('url').json() dispatch(someActions.setSomeParam(someParam.data)) return { props: {}, }; }); export default ContactPage;
こんな具合に扱うことができます。preloadState周りの処理をなくすことができました。
custom server
SketchにはSketch LIVEという配信サービスがあります。
LIVE配信にはWebSocketを使ったSocket通信があるためNext.js公式のこの記事を参考にカスタムサーバーを立ててwss周りの処理をexpressにbindすることにしました。
ざっくりこんな感じです。
import express, { Request, Response, NextFunction } from 'express'; import nextServer from 'next'; import bindSocket from '../socket'; const dev = process.env.NODE_ENV === 'development'; const app = nextServer({ dev }); const handle = app.getRequestHandler(); (async () => { await app.prepare(); const server = express(); // nextServerにハンドリングする server.all( '*', async (req: Request, res: Response, next: NextFunction): Promise<void> => { const parsedUrl = parse(req.url, true); await handle(req, res, parsedUrl); }, ); const sketchServer = server.listen(port, (err) => { if (err) throw err; }); sketchServer.on('connect', (req, connectSocket) => { connectSocket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n'); }); // // ここでserverにbindする // ----------------------------------------------------------------------------- bindSocket({ server: sketchServer }); })();
bindSocketの中身はざっくりと
export default function bindSocket({ server }) { let wss; let pubsub; let checkInterval; server.on('close', () => { wss.close(); pubsub.pub.quit(); pubsub.sub.quit(); clearInterval(checkInterval); }); server.on('listening', () => { wss = new Server(); pubsub = new PubSub(); checkInterval = setInterval(() => { // ping pong }, 30 * 1000); }); server.on('upgrade', (upgradeReq, upgradedSocket, head) => { wss.handleUpgrade(upgradeReq, upgradedSocket, head, (ws) => { // upgrade }); }); }
こんな具合にwssでsocket通信を行えるように設定します。
getServerSideProps
Fluxibleでは全ページをSSRしていたのでNext.js化する際にも一旦全ページgetServerSidePropsしていたのですが、nodeサーバーの負荷が高まってしまったり、ページを読み込む際のlatencyが少し高めになってしまったので、OGPやSSR時に動的にheadの中を書き換えたいページ以外のgetServerSidePropsは廃止しました。
いい感じに下がりました。
リリース🎉そして、まとめ
狙いの通り、Sketch実装のSSRからのリプレースによって、クライアントサイドのパフォーマンス向上と、開発容易性・メンテナンス性の向上を実現するとともに、他にも色々なものを削ることができました。
削り取ったもの
- 独自SSR実装
- 暗黙知
- Fluxible(お疲れ様でした…そしてありがとう…。)
- flow type
- babel
LightHouseのスコアもこんな感じで上がったのでユーザーの皆様により快適なSketch体験をお届けできているのではないかと思います!
最近m1 mac対応も行なったので爆速開発ができております!
ここに書いてある事以外にも色々苦労があったので、是非興味のあるエンジニアの方!カジュアルにお話できれば〜!と思っております〜!
pixiv Sketchフロントエンドエンジニアchive / チャイブでした!