コンパイラかく語りき

import { Fun } from 'programming'

React, Express, Webpack, Relay, GraphQLでつくるSPA

アプリケーションと呼ぶには程遠いですが、ひとまず最低限の形になったのでメモ。

From REST to GraphQLを読んだのが、直接のきっかけっちゃあきっかけ。 JSだけで、一通り作ってみようと。

注意書き

筆者は、ReactもRelayもGraphQLも初心者です。このポストは自分の整理整頓のために書いており、何か間違いやベターな方法がありましたら、ぜひお教えください。

つくったもの

求人一覧のような感じで、職種がただ並んでいるだけの1枚ページを作りました。

Screen Shot 2016-07-03 at 4.01.58 PM.png

設定まわり

package.json

{
  "name": "realtime-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "babel-node devserver.js",
    "build": "NODE_ENV=production webpack",
    "update-schema": "babel-node ./scripts/updateSchema.js"
  },
  "metadata": {
    "graphql": {
      "schema": "./data/schema.json"
    }
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.14.0",
    "express-graphql": "^0.5.3",
    "graphql": "^0.6.0",
    "graphql-relay": "^0.4.2",
    "react": "^15.1.0",
    "react-dom": "^15.1.0",
    "react-relay": "^0.9.1"
  },
  "devDependencies": {
    "babel-cli": "^6.10.1",
    "babel-core": "^6.10.4",
    "babel-loader": "^6.2.4",
    "babel-polyfill": "^6.9.1",
    "babel-preset-es2015": "^6.9.0",
    "babel-preset-react": "^6.11.1",
    "babel-preset-react-hmre": "^1.1.1",
    "babel-preset-stage-0": "^6.5.0",
    "babel-preset-stage-1": "^6.5.0",
    "babel-preset-stage-2": "^6.11.0",
    "babel-preset-stage-3": "^6.11.0",
    "babel-relay-plugin": "^0.9.1",
    "chokidar": "^1.6.0",
    "json-loader": "^0.5.4",
    "webpack": "^1.13.1",
    "webpack-dev-server": "^1.14.1"
  }
}

依存パッケージはこんな感じ。

scriptには、開発モードを起動するstartと、本番ビルド用のbuildと、スキーマ更新のupdate-schemaの3つを用意した。

metadataの項目に、schema.jsonを指定している。

.babelrc

{
  "passPerPreset": true,
  "presets": [
    {
      "plugins": [
        "./build/babelRelayPlugin"
      ]
    },
    "react",
    "react-hmre",
    "es2015",
    "stage-0",
    "stage-1",
    "stage-2",
    "stage-3"
  ]
}

passPerPresetは良いことが起こるおまじないっぽい(よくわかっていない。) preset毎にtraversalが作られて処理出来るようになるらしい。

react-hmreは、WebpackをHMRで起動するんだけど、その際にReactも適用したいので指定。

presetsの項目については後述します。

stageの指定、雑でごめんなさい。。。

webpack.config.js

const webpack = require('webpack')
const path = require('path')

const isProduction = process.env.NODE_ENV === 'production'

const commonLoaders = [
  {
    test: /.js?$/,
    loader: 'babel',
    exclude: /node_modules/
  },
  {
    test: /\.json$/,
    loader: 'json-loader'
  }
]

const clientConfig = {
  entry: {
    client: ["./src/client.js"]
  },
  output: {
    filename: '[name].js',
    path: path.join(__dirname, 'public'),
    publicPath: '/public/'
  },
  module: {
    loaders: commonLoaders
  },
  plugins: []
}

const serverConfig = {
  entry: {
    server: "./src/server.js"
  },
  target: 'node',
  output: {
    filename: '[name].js',
    path: path.join(__dirname, 'public'),
    publicPath: '/public/'
  },
  module: {
    loaders: commonLoaders
  }
}

const config = isProduction ? [clientConfig ,serverConfig] : clientConfig

module.exports = config

webpackの設定は、開発と本番でやや分けている。

devserver.js

今回は、devserver.jsというファイルを用意した。こいつは、webpack.config.jsをimportして、いろいろ載っけてwebpack-dev-serverを起動している。webpack-dev-serverはアプリケーション用に立ち上げ、expressをGraphQLサーバとして立ち上げている。 webpack-dev-serverにproxyという設定項目があり、そこにGraphQLサーバを指定している。

// 外部パッケージをインストール
import webpack from 'webpack'
import WebpackDevServer from "webpack-dev-server"
import express from 'express'
import graphQLHTTP from 'express-graphql'
import chokidar from 'chokidar'

// 標準パッケージをインストール
import path from 'path'
import { exec } from 'child_process'

// webpack config
import config from './webpack.config.js'

// posts
const APP_PORT = 3000
const WEBPACK_GRAPHQL_PORT = 8080

// server variables
let graphQLServer
let appServer


// add webpack dev server config
config.entry.client.push(`webpack-dev-server/client?http://localhost:${APP_PORT}`)
config.entry.client.push("webpack/hot/dev-server")
config.plugins.push(new webpack.HotModuleReplacementPlugin())

// server where we develop
function startAppServer(callback) {
  const compiler = webpack(config)

  appServer = new WebpackDevServer(compiler, {
    proxy: {'/graphql': `http://localhost:${WEBPACK_GRAPHQL_PORT}`},
    publicPath: config.output.publicPath,
    hot: true,
    stats: {colors: true}
  })

  appServer.use('/', express.static(path.resolve(__dirname, 'public')))
  appServer.listen(APP_PORT, () => {
    console.log(`App is now running on http://localhost:${APP_PORT}`)
    if(callback) {
      callback()
    }
  })
}

// server where we see GraphQL
function startGraphQLServer(callback) {
  const { Schema } = require('./data/schema')
  const graphQLApp = express()
  graphQLApp.use('/', graphQLHTTP({
    graphql: true,
    pretty: true,
    schema: Schema,
  }))
  graphQLServer = graphQLApp.listen(WEBPACK_GRAPHQL_PORT, () => {
    console.log(`GraphQL server is now running on http://localhost:${WEBPACK_GRAPHQL_PORT}`)
    if(callback) {
      callback()
    }
  })
}

// start both servers and stay for listening
function startServers(callback) {
  if(appServer) {
    appServer.listeningApp.close()
  }
  if(graphQLServer) {
    graphQLServer.close()
  }

  exec('npm run update-schema', (error, stdout) => {
    console.log(stdout)
    let doneTasks = 0
    function handleTaskDone() {
      doneTasks++
      if(doneTasks === 2 && callback) {
        callback()
      }
    }
    startGraphQLServer(handleTaskDone)
    startAppServer(handleTaskDone)
  })

  // watch
  const watcher = chokidar.watch('./data/{database, schema}.js')
  watcher.on('change', path => {
    console.log(`\`${path}\` changed. Restarting.`);
    startServers(() => {
      console.log('Restart your browser to use the updated schema.')
    })
  })
}

// FIRE!!!
startServers()

たぶん開発周りの設定はこれくらい。

Relay・GraphQLまわり

次に、RelayとGraphQLまわりのファイルについて。 まず、2つのツール系ファイルを用意する。

babelRelayPlugin

まず、bablercで指定したbabelRelayPluginについて。

const getBabelRelayPlugin = require('babel-relay-plugin')
const schema = require('../data/schema.json')

module.exports = getBabelRelayPlugin(schema.data)

RelayからGraphQLのクエリを発行する際に、Relay.QLと書いてテンプレート文字列でクエリを書くんだけど、babelRelayPluginはそれをJSのコードに変換してくれる。

Babel Relay Plugin

requireしている'../data/schema.json'については、後述します。

updateSchema.js

#!/usr/bin/env babel-node --optional es7.asyncFunctions

import fs from 'fs'
import path from 'path'
import { Schema } from '../data/schema.js'
import { graphql } from 'graphql'
import { introspectionQuery, printSchema } from 'graphql/utilities'

(async () => {
  let result = await (graphql(Schema, introspectionQuery))
  if(result.errors) {
    console.error(
      'ERROR introspecting schema: ',
      JSON.stringify(result.errors, null, 2)
    )
  } else {
    fs.writeFileSync(
      path.join(__dirname, '../data/schema.json'),
      JSON.stringify(result, null, 2)
    )
  }
})()

fs.writeFileSync(
  path.join(__dirname, '../data/schema.graphql'),
  printSchema(Schema)
)

schemaの更新を行うJSファイル。npm run update-schemaコマンドを通じて、実行される。 実行に成功すれば、後述するdatabase.jsとschema.jsを元にして、schame.jsonとschema.graphqlが生成される。

database.js

database.jsは、データ定義を擬似的に行っているファイルです。

export class Company {}
export class Job {}

let companyNames = ['aaa', 'bbb', 'ccc']
let companies = companyNames.map((name, id) => {
  let company = new Company()
  company.id = `${id}`
  company.name = name
  return company
})

let jobList = [
  [0, 'manager', 500, 'Tokyo'],
  [1, 'developer', 400, 'Tokyo'],
  [2, 'designer', 300, 'Okinawa'],
  [3, 'senior-designer', 500, 'Okinawa'],
  [4, 'ux-designer', 600, 'Tokyo'],
  [5, 'customer-support', 300, 'Ishikawa']
]

let jobs = jobList.map((job, id) => {
  let newJob = new Job()
  newJob.id = `${id}`
  newJob.name = job[1]
  newJob.salary = `${job[2]}`
  newJob.location = `${job[3]}`
  newJob.hasApplied = false
  return newJob
})

export function getAAA() { return companies[0] }
export function getCompany(id) { return companies.find((user) => user.id === id) }
export function getCompanies() { return companies }
export function getJob(id) { return jobs.find(j => j.id === id) }
export function getJobs() { return jobs }

schema.js

schema.jsでは、database.jsを元にして、スキーマ定義を行います。

import {
  GraphQLBoolean,
  GraphQLFloat,
  GraphQLID,
  GraphQLInt,
  GraphQLList,
  GraphQLNonNull,
  GraphQLObjectType,
  GraphQLSchema,
  GraphQLString,
} from 'graphql'

import {
  connectionArgs,
  connectionDefinitions,
  connectionFromArray,
  fromGlobalId,
  globalIdField,
  mutationWithClientMutationId,
  nodeDefinitions,
} from 'graphql-relay'

import {
  Company,
  Job,
  getAAA,
  getCompanies,
  getCompany,
  getJobs,
  getJob,
  getExperiencedJobs,
} from './database'

var { nodeInterface, nodeField } = nodeDefinitions(
  (globalId) => {
    var {type, id} = fromGlobalId(globalId)
    if(type === 'Company') {
      return getCompany(id)
    } else if(type === 'Job') {
      return getJob(id)
    } else {
      return null
    }
  },
  (obj) => {
    if(obj instanceof User) {
      return companyType
    } else if(obj instanceof Job) {
      return jobType
    } else {
      return null
    }
  }
)


var companyType = new GraphQLObjectType({
  name: 'Company',
  description: 'A company',
  fields: () => ({
    id: globalIdField('Company'),
    name: {
      type: GraphQLString,
      description: 'The name of the company',
      resolve: (company) => company.name
    },
    jobs: {
      type: jobConnection,
      description: 'A company\s collection of jobs',
      args: connectionArgs,
      resolve: (_, args) => connectionFromArray(getJobs(), args)
    }
  }),
  interfaces: [nodeInterface],
})
// console.log(userType['_typeConfig']['fields']()['jobs']['args'])


var jobType = new GraphQLObjectType({
  name: 'Job',
  description: 'A job',
  fields: () => ({
    id: globalIdField('Job'),
    name: {
      type: GraphQLString,
      description: 'The name of the job',
      resolve: (job) => job.name,
    },
    salary: {
      type: GraphQLInt,
      description: 'The salary of the job',
      resolve: (job) => job.salary,
    },
    location: {
      type: GraphQLString,
      description: 'The place of the job',
      resolve: (job) => job.location
    },
    hasApplied: {
      type: GraphQLBoolean,
      description: 'If the user applied for this job or not',
      resolve: (job) => job.hasApplied
    }
  }),
  interfaces: [nodeInterface],
})

var { connectionType: jobConnection } =
  connectionDefinitions({name: 'Job', nodeType: jobType})

var queryType = new GraphQLObjectType({
  name: 'Query',
  fields: () => ({
    node: nodeField,
    aaa: {
      type: companyType,
      resolve: () => getAAA(),
    }
  })
})

export var Schema = new GraphQLSchema({
  query: queryType,
})

以上の2つのファイルが完成したら、npm run update-schemaコマンドでスキーマの更新を行います。

ReactとRelay・GraphQLの結合

さて、RelayとGraphQLの設定が済んだので、Reactと結合します。

コンテナコンポーネントの作成

Relayを通じてGraphQLクエリを発行するコンポーネントを作成します。

import React, { Component } from 'react'
import Relay from 'react-relay'
import JobRow from './JobRow'

class JobBoard extends Component {

  render() {
    return(
      <div>
        <h2>Job board</h2>
        <table>
          <thead>
            <th>Name</th>
            <td>Salary</td>
            <td>Location</td>
            <td>Status</td>
          </thead>
          <tbody>
          {this.props.aaa.jobs.edges.map(job =>
            <JobRow job={job.node} />
          )}
          </tbody>
        </table>
      </div>
    )
  }
}

export default Relay.createContainer(JobBoard, {
  fragments: {
    aaa: () => Relay.QL`
      fragment on Company {
        name,
        jobs(first: 6) {
          edges {
            node {
              id,
              name,
              salary,
              location,
              hasApplied,
            },
          }
        },
      }
    `
  }
})

まず、通常通りにコンポーネントを作成して、それを、createContainerでラップしています。ラップ時の第二引数がクエリですね。 クエリ発行に結果が、propsとしてコンポーネント内で参照することができます。

プレゼンテーションコンポーネントの作成

次に、上記のコンポーネントから呼ばれる子コンポーネントを作成します。 このコンポーネントは、GraphQLとは無関係で、ただ渡されたデータをレンダーするだけのコンポーネントです。

import React, { Component } from 'react'

const JobRow = ({job}) =>
  <tr key={job.id}>
    <th>{job.name}</th>
    <td>{job.salary}</td>
    <td>{job.location}</td>
    <td>{job.hasApplied ? 'applied' : 'not applied'}</td>
  </tr>

export default JobRow

Route

AppRoute.jsという名前で、ファイルを作成します。

import Relay from 'react-relay'

export default class extends Relay.Route {
  static queries = {
    aaa: () => Relay.QL`query { aaa }`,
  }
  static routeName = 'HomeRoute'
}

ルートコンポーネント

コンテナコンポーネントと、Routeを結合する、ルートコンポーネントを作成します。

import 'babel-polyfill'

import React from 'react'
import ReactDOM from 'react-dom'
import Relay from 'react-relay'
import JobBoard from './components/JobBoard'
import AppRoute from './routes/AppRoute'


ReactDOM.render(
  <Relay.RootContainer
    Component={JobBoard}
    route={new AppRoute()}
  />,
  document.querySelector('#app')
)

これで、ReactとRelay・GraphQLの結合が終わりました。

まとめ

以上で一通りの解説はおしまいです。

Githubソースコードを上げてありますので、ご参照ください。

また、順を追った作成手順については、個人の技術ブログに書いてあります。

次にやること

  • 今回は、インテグレーションが目的だったので、簡単なクエリしか使わなかった。次は、ミューテーションや難しいクエリを使いたい。
  • SSR(サーバサイドレンダリング)が機能していないので、直す。
  • テストを書きたい。
  • react-router-relayというものがあるので、使ってみたい。
  • どこかホスティングサービスにアップしたい。