entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

pixiv SketchのSSRをFluxibleからNext.jsにリプレースしました!

pixiv SketchのWeb版はReact + FluxibleでSSRを実現していました。

今回、FluxibleというライブラリをRedux Toolkit + Next.jsでリプレースした話をさせていただこうと思います。

このリプレースで狙った効果は、クライアントサイドのパフォーマンス向上と、開発容易性・メンテナンス性の向上の2つです。

背景

Next.js化前、つまりFluxibleの時点での状態として以下のものがありました。

  1. Fluxibleの開発が止まってしまっている・Fluxibleの知見が少ない
    1. 致命的なバグや脆弱性があったときに対応できない
  2. FluxibleはReact 15.x までしか対応しておらず、16以上はサポート対象外
  3. Storybookのstoryにできるコンポーネントと、できないコンポーネントにわかれてしまう
    1. コンポーネントにFluxible由来のアクションが使われているとstorybookのビルドが失敗してしまう
  4. SSRの大部分が自己流の実装だったので初めてコードを読む人が辛い
  5. 全画面でSSRしてるのでnodeサーバーへの負荷が高め

解決のアクションとして、Next.js化とRedux Toolkit導入の大きく2つを行いました。

Next.js化

  1. 社内外の知見が使いやすくなって、開発効率向上と、今の開発メンバー以外の新しい人が入った時も開発しやすくなる
  2. Next.jsはめちゃくちゃメンテナンスされてるので、Reactとか各種ライブラリの変更にもついていきやすい
  3. Storybookの問題が解決される
  4. Next.jsにすることでエンジニアの採用にもつながる
  5. SSRの実装のほとんどをNext.jsに寄せることができる

Redux Toolkit導入

  1. 状態管理ライブラリのデファクトスタンダードの一つだと思うので
  2. Reduxを採用することによって業務経験がある人の知見を流用できそう
  3. エンジニアの採用につながる

リリースまでの道のり

「①状態管理を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

こちらのライブラリは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 / チャイブでした!

chive
chive
pixiv Sketch フロントエンドエンジニア 休日はDTMと映像制作をしています。 アマガミというゲームとアニメが好き。アイコンはおそらくポピー。