備忘録 - Rails + TypeScript + React + Hypernova で SSR

今回はRailsの素振り
ただ今回のメインはフロントでやりたいことはTypeScript + React + SSR

SSRは今回はAirbnbOSSとして出しているhypernovaを使用しました
github.com

環境

環境は以前次の記事で使用した環境を使用します

hatappi.hateblo.jp

$ ruby -v
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin17]
$ rails -v
Rails 5.1.6

準備

今回は次のコンポーネントSSRで表示したいと思います
コードはapp/javascript/components/hello_react.tsxに追加したとします

import * as React from "react";
import { renderReact } from "hypernova-react";

interface Props {
  text: number;
}
interface State {
  isEnabled: boolean;
}

class HelloReact extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { isEnabled: false };

    setTimeout(() => {
      this.setState({ isEnabled: true });
    }, 5000);
  }

  handleClick(e) {
    alert("click button");
  }

  render() {
    return (
      <div>
        <h1>Hello React!</h1>
        <p>text is {this.props.text || "none"}</p>
        <p>state is {this.state.isEnabled ? "enabled" : "disabled"}</p>
        <button onClick={this.handleClick}>Button!!</button>
      </div>
    );
  }
}

export default renderReact("HelloReact", HelloReact);

Hypernovaの導入には公式のdocに沿って必要なものを整えていきます

node側

まずはpackageの追加から
今回はreactを使用するのでhypernova-reactも一緒に追加しておきます

$ yarn add hypernova hypernova-react

次にhypernova.jsをrootディレクトリに追加します
これはnode hypernova.jsで起動することでコンポーネント名をうけとりHTML化したものを返します

const { environment } = require('@rails/webpacker')
const hypernova = require("hypernova/server");
const requireFromUrl = require("require-from-url/sync");

const config = environment.toWebpackConfig()

hypernova({
  devMode: true,
  port: 3030,
  getComponent(name) {
    if (config.devServer) {
      return requireFromUrl(`http://${config.devServer.public}${config.devServer.publicPath}ssr/${name}`).default
    } else {
      return require(`${config.output.path}/ssr/${name}`).default
    }
  }
});

ポイントとしてはコンポーネント名をうけとって、それを返すgetComponentです
公式のほうではファイルのパスを指定してましたが、開発時に私はwebpack-dev-serverを使用するので、その時はURLからコンポーネントを読み込みたいです
そのために@rails/webpackerを使ってwebpackの設定を取り出してdev-serverの設定が入っていれば起動してるとみなしてrequireFromUrlを使ってrequireしています
※ ちゃんとやるなら疎通チェックとかしたほうが良さそう

Ruby

これでnode側の準備が終わったので次はRuby
まずはgemを追加する

gem 'hypernova'

次に指定のコントローラーにaround_action :hypernova_render_supportを追記します

class WelcomeController < ApplicationController
  around_action :hypernova_render_support
  ~~

後は開発時にdebugしやすいように次の記載をconfig/environments/development.rbに追記します
これによってhypernova側で出たエラーなどをブラウザ上で各員することが出来るようになります

require 'hypernova'
require 'hypernova/plugins/development_mode_plugin'

Hypernova.add_plugin!(DevelopmentModePlugin.new)

後はconfig/initializers/hypernova.rbでhypernova用のinitilizerを追加します
ここでhypernova serverが起動しているhostとportを指定します

Hypernova.configure do |config|
  config.host = "localhost"
  config.port = 3030
end

次にコンポーネントを全部読み込んだものをのちほどwebpackでbuildして一つのファイルにまとめたいのでapp/javascript/packs/application.jsを用意しました

const context = require.context("components", true);
const  obj = {};
context.keys().forEach(function(key) {
  obj[key] = context(key);
});
module.exports = obj;

次はwebpackの設定を行います
webpacker.ymlは今回はwebpackerのinstall時に出てくるものからほとんど変えずに使用しています
ここからがモヤッとしたポイントなんですが、ブラウザ上で読み込むapplication.jsはes5で出力されるのですが、hypernovaで読み込む時のものはcommonjsで出力する必要があるようで、webpack側でブラウザ上で読み込むものとhypernova用でoutputを2種類用意する必要がありました

今回どうしたかで言うとconfig/webpack/environment.jsを次のように変更してoutputをes5, commonjsと2種類用意しました
webpacker.ymlではes5で出力される設定なのでgetSSREnvironmentでlodashのcloneDeepでdeepcopyしたものをSSR用の設定として変更したものを返しています

const { environment } = require("@rails/webpacker");
const typescript = require("./loaders/typescript");
const cloneDeep = require("lodash/cloneDeep");
const { sync } = require("glob");
const extname = require("path-complete-extname");
const { basename, dirname, join, relative, resolve } = require("path");

function getSSREnvironment(environment) {
  const ssrEnv = cloneDeep(environment);
  const rootPath = `${__dirname}/../../app/javascript/components`;
  const paths = sync(
    join(rootPath, "**/*{" + ssrEnv.config.resolve.extensions.join(",") + "}")
  );
  const result = ssrEnv.entry;
  result.delete("application");
  paths.forEach(path => {
    const namespace = relative(join(rootPath), dirname(path));
    const name = join(namespace, basename(path, extname(path)));
    result[name] = resolve(path);
  });
  ssrEnv.entry = result;
  ssrEnv.config.output.path += "/ssr";
  ssrEnv.config.set("output.libraryTarget", "commonjs");
  ssrEnv.config.set("output.filename", "[name].js");
  return ssrEnv;
}

environment.loaders.append("typescript", typescript);
module.exports = [environment, getSSREnvironment(environment)];

ここまできたら後はhypernova gemを追加すると使えるようになるrender_react_componentのビューヘルパーを使って任意のview側に下記の記載を行います

<%= render_react_component('react_sample.js', { text: 'test!!' }) %>

確認

$ export RAILS_ENV=development
$ export NODE_ENV=development
$ node hypernova.js
$ ./bin/webpack-dev-server 
$ bundle exec rails server

f:id:hatappi1225:20180415234610p:plain

もちろんcurlなどで確認してもhtmlがかえってくる!!

最後に

今回はHypernovaを使用してSSRを実現した ひとまず実現はしたけどwebpacker周りの設定がしっくりこないのでやっていく中で良い感じにしていきたい