こんにちは!AI事業本部 Dynalyst 開発エンジニアのフックです。リアクティブなフロントエンドのフレームワークが普及し、その中でも AngularJS, React, Vue.jsは代表的なフレームワークであると思います。
業務でリアクティブなフロントエンドのフレームワークを使いたいのですが、サーバーサイドレンダリングのフレームワーク (Ruby on Rails, Play Frameworkなど) + jQueryで開発されたプロジェクトがいくつかあります。
これらのプロジェクトが大きくなり、jQueryを用いての開発には限界を感じましたが、新規のプロジェクトを開発して、置き換えるのにもかなりの時間がかかります。そのため、本ブログで既存のPlay FrameworkプロジェクトにVue.jsを統合する方法を紹介したいと思います。

Play Frameworkについて

Play Frameworkは ScalaとJava言語で書かれたオープンソースのWebアプリケーションフレームワークであり、Model View Controllerアーキテクチャをサポートしています。

https://www.playframework.com/

Play FrameworkのViewは Twirlテンプレートエンジンで、HTMLを生成しています。

Vue.jsについて

Vue.jsは Webアプリケーションにおけるユーザーインターフェイスを構築するための、オープンソースのJavaScriptフレームワークです。GoogleにおいてAngularJSを使用した開発に携わったエヴァン・ヨー氏によって開発されています。

https://vuejs.org/index.html

Angularの本当に好きだった部分を抽出して、徐分な概念なしに本当に軽いものを作ることができたらどうだろうか?

という考えでVue.jsを開発されたようです。

Vue.jsを導入する背景

Vue.jsを採用する主な理由は3つがあります。
– Vue.jsのテンプレートは HTMLで書けます。元のPlay FrameworkのViewはHTMLなので、Vue.jsに移行しやすいと思います。
– 学習コストが低いです。
– 豊富なサポートツールがあります。

Vue.jsを導入する為、検討したこと

既存のPlay Frameworkのプロジェクトのひとつに、画面数が多く、Play FrameworkのViewが 300個ぐらいあるものがあります。Vue.jsで完全に新規の画面を書き直してから、リプレースするとても時間がかかりそうです。
また、既存のプロジェクトも開発中ですので、既存の画面にVue.jsを統合して、徐々にVue.js化したほうが、効率的であると思います。
統合の方法を下記のように検討してみました。

Play FrameworkのViewに直接にVue.jsのコードを書く

Vue.jsを導入するのが一番簡単なのは Play FrameworkのViewに直接にVue.jsを入れることです。

まずは HTMLのheadvue.min.js を入れます

<!DOCTYPE html>
<html lang="en">
  <head>
  ...
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
  </head>
  <body>...</body>
</html>

そして、HTMLのbody にVueのアプリケーションを定義します。

<html lang="en">
  ...
  <body>
    <div id="vue-app">
      <div>{{ appData }}</div>
    </div>

    <script type="text/javascript">
      var vueApp = new Vue({
        el: '#vue-app',
        data: {
          appData: '...'
        },
        ...
      })
    </script>
  </body>
</html>

そして、各ページごとに上記と同様にVueアプリケーションを定義していけば、Vue化できます。
但し、直接HTMLにVueを定義するのは 下記のようにいくつかの弱点があります。

  • Vueコンポーネントのテンプレートは文字列しかサポートされていません。
  • ブラウザのJavascriptエンジンに依存してしまうため、ECMAScriptとの互換性が異なります。
  • Typescriptで開発するのは困難です。

Play FrameworkのViewから、Vue.jsのコードを分離する

新規のVue.jsのプロジェクトだと、Vue.jsのコードは独立で開発し、webpackとbabelで、各ブラウザと互換できる成果物を同梱するのが 一般的です。なので、同梱された成果物をPlay FrameworkのViewに入れれば、開発しやすいかと考えています。

例えば:仮に下記のVue.jsアプリケーションがあります。

<!-- frontend/src/App.vue -->
<template>
  <div id="app">
    <SampleComponent />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HelloWorld from './components/SampleComponent.vue';

@Component({
  components: {
    SampleComponent,
  },
})
export default class App extends Vue {
}
</script>
// frontend/src/main.ts
import Vue from 'vue';
import App from './App.vue';

Vue.config.productionTip = false;

new Vue({
  render: h => h(App),
}).$mount('#app');

同梱した成果物は dist/js/app.js です。
それで、Play FrameworkのViewに dist/js/app.js へのリンクを追加したら、Vue.js化できます。

<!DOCTYPE html>
<html lang="en">
  <head>
  ...
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
  </head>
  <body>
    <div id="app">
    </div>
    <script src="<link to dist/js/app.js>"></script>
  </body>
</html>

これにより、「Play FrameworkのViewに直接にVue.jsのコードを書く」方法と比べたら、フロントエンドのコードを変えた時、Play FrameworkのViewが変わらないので、再コンパイルが必要なくなります。
また、webpack, babelなど Vue.jsのツールも簡単に設定できます。

Play FrameworkにVue.jsを統合するstep-by-step

0. 環境設定

  • nodejs をインストール
  • yarn (もしくは npm) をインストール
  • @vue/cli (この記事を書いた時、最新は バージョン 4.2.2 です)

1. 既存のPlay Frameworkにフロントエンド用のコードを作成

Vue CLIを用いて、Play Frameworkのプロジェクトフォルダーの配下に vue create frontend を実行したら、色々な設定を選んで、Vue.jsのプロジェクトを簡単に作成できす。

$ ls # Play Frameworkのプロジェクトフォルダーの配下
drwxr-xr-x  4 usera  128 Jan 19 17:02 app
-rw-r--r--  1 usera  681 Jan 28 00:45 build.sbt
drwxr-xr-x  5 usera  160 Jan 19 23:44 conf
drwxr-xr-x  8 usera  256 Jan 28 02:00 project
drwxr-xr-x  5 usera  160 Jan 28 02:55 public
drwxr-xr-x  8 usera  256 Jan 28 00:46 target

$ vue create frontend
Vue CLI v4.2.2
? Please pick a preset: 
  n (babel, router, vuex, eslint, unit-mocha) 
  default (babel, eslint) 
❯ Manually select features

? Please pick a preset: Manually select features
? Check the features needed for your project: 
❯◉ Babel
 ◉ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◉ Unit Testing
 ◯ E2E Testing

? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Linter, Unit
? Use class-style component syntax? (Y/n) Y
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n) Y

? Pick a linter / formatter config: 
  ESLint with error prevention only 
  ESLint + Airbnb config 
  ESLint + Standard config 
❯ ESLint + Prettier 
  TSLint (deprecated) 

? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)Lint on save
? Pick a unit testing solution: Mocha
? Where do you prefer placing config for Babel, ESLint, etc.? In package.json

2. Vue.jsのFolder Structure

Vue.jsのプロジェクトができたら、以下のようにフォルダーを設定します。

$ tree -I 'node_modules'
.
├── babel.config.js
├── package.json
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── pages
│   │   ├── app1
│   │   │   ├── App1.vue
│   │   │   └── main.ts
│   │   └── app2
│   │       ├── App2.vue
│   │       └── main.ts
│   ├── shims-tsx.d.ts
│   ├── shims-vue.d.ts
│   └── store
│       └── index.ts
├── tsconfig.json
├── vue.config.js
└── yarn.lock

src/pages の配下に複数のアプリケーションを定義するためです。
Play FrameworkのView毎に、別々のアプリケーションにします。

3. vue.config.js を設定

// Play FrameworkのView毎に、Vueのアプリの定義する
const pages = {
  app1: {
    entry: 'src/pages/app1/main.ts',
    template: 'public/index.html',
    filename: 'app1.html',
  },
  app2: {
    entry: 'src/pages/app2/main.ts',
    template: 'public/index.html',
    filename: 'app2.html',
  },
};

module.exports = {
  configureWebpack: {
    devtool: 'source-map',
  },

  pages: pages,

  // Play Frameworkのpackageに統合しやすいため、`../public/` の配下にフロントエンドの成果物を生成させる
  outputDir: '../public/vuejs',

  // ファイル名のハッシングを無効化
  filenameHashing: false,

  // Play FrameworkのViewを利用するので、HTMLファイルは不要です。
  chainWebpack: config => {
    Object.keys(pages).forEach(page => {
      config.plugins.delete(`html-${page}`);
      config.plugins.delete(`preload-${page}`);
      config.plugins.delete(`prefetch-${page}`);
    });
  },
};

4. Play FrameworkのViewに統合する

先述のVue.js導入の際に検討したことにも書きましたが、フロントエンドのリンクをVueに追加すれば、いいです。

<!-- app/views/app1.scala.html -->
@()

@main("") {
  <div id="app"></div>

  <script src="@routes.Assets.versioned("vuejs/js/chunk-vendors.js")" type="text/javascript"></script>
  <script src="@routes.Assets.versioned("vuejs/js/app1.js")" type="text/javascript"></script>
  }

5. Play FrameworkのRunDevHookに統合する

Play Frameworkのプロジェクトフォルダーの配下に project/PlayDevRunHook.scala を定義します。

import java.io.PrintWriter
import play.sbt.PlayRunHook
import sbt._
import scala.io.Source
import scala.sys.process.Process

object PlayDevRunHook {

  object FrontendCommands {
    val install = "yarn install"
    val buildWatch = "yarn build --watch"
  }

  object Shell {
    def execute(cmd: String, cwd: File, envs: (String, String)*): Int = {
      Process(cmd, cwd, envs: _*).!
    }

    def invoke(cmd: String, cwd: File, envs: (String, String)*): Process = {
      Process(cmd, cwd, envs: _*).run
    }
  }

  def apply(base: File): PlayRunHook = {

    val frontendBase = base / "frontend"
    val packageJsonPath = frontendBase / "package.json"

    val frontEndTarget = base / "target" / "frontend"
    val packageJsonHashPath = frontEndTarget / "package.json.hash"

    object FrontendBuildProcess extends PlayRunHook {
      var process: Option[Process] = None

      override def beforeStarted(): Unit = {
        println("Hook to Play Framework dev run -- beforeStarted")
        val currPackageJsonHash = Source.fromFile(packageJsonPath).getLines().mkString.hashCode().toString()
        val oldPackageJsonHash = getStoredPackageJsonHash()

        // frontend/package.json が変更されたら、もう一度 'yarn install` コマンドを実行する
        if (!oldPackageJsonHash.exists(_ == currPackageJsonHash)) {
          println(s"Found new/changed package.json. Run '${FrontendCommands.install}'...")

          Shell.execute(FrontendCommands.install, frontendBase)

          updateStoredPackageJsonHash(currPackageJsonHash)
        }
      }

      override def afterStarted(): Unit = {
        println(s"> Watching frontend changes in ${frontendBase}")
        // フロントエンドのビルド用のプロセスを立ち上げる
        process = Option(Shell.invoke(FrontendCommands.buildWatch, frontendBase))
      }

      override def afterStopped(): Unit = {
        // フロントエンドのビルド用のプロセスを停止する
        process.foreach(_.destroy)
        process = None
      }

      private def getStoredPackageJsonHash(): Option[String] = {
        if (packageJsonHashPath.exists()) {
          val hash = Source.fromFile(packageJsonHashPath).getLines().mkString
          Some(hash)
        } else {
          None
        }
      }

      private def updateStoredPackageJsonHash(hash: String): Unit = {
        val dir = frontEndTarget

        if (!dir.exists)
          dir.mkdirs

        val pw = new PrintWriter(packageJsonHashPath)

        try {
          pw.write(hash)
        } finally {
          pw.close()
        }
      }
    }

    FrontendBuildProcess
  }
}

build.sbt に以下を追記する

PlayKeys.playRunHooks += baseDirectory.map(PlayDevRunHook.apply).value

これで、sbtで開発する時、 run コマンドで バックエンド と フロントエンドを両方コンパイルできます。

sbt>
sbt> run
Found new/changed package.json. Run 'yarn install'...
yarn install v1.22.0
...
Done in 26.18s.
--- (Running the application, auto-reloading is enabled) ---

参考: Hook into Play’s dev mode

6. Play Frameworkの同梱プロセスに統合する

Play Frameworkのbuild.sbtに以下を追記します。

lazy val frontEndBuild = taskKey[Unit]("Execute dashboard frontend build command")

val frontendPath = "frontend"
val frontEndFile = file(frontendPath)

frontEndBuild := {
  val logger = streams.value.log
  logger.info(s"Building dashboard frontend in $path")
  println(Process("yarn install", frontEndFile).!!)
  println(Process("yarn build", frontEndFile).!!)
}

dist := (dist dependsOn frontEndBuild).value
stage := (stage dependsOn dist).value

これで、Play Frameworkのpackageと一緒にフロントエンドの成果物をまとめることができます。

[sbt] $ dist
warning package-lock.json found. Your project contains lock files generated by tools other than Yarn. It is advised not to mix package managers in order to avoid resolution inconsistencies caused by unsynchronized lock files. To clear this warning, remove package-lock.json.
yarn install v1.22.0
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.53s.

-  Building for production...
yarn run v1.22.0
$ vue-cli-service build

Starting type checking service...
Using 1 worker with 2048MB memory limit
 DONE  Compiled successfully in 13467ms3:08:47 AM

  File                                   Size              Gzipped

  ../public/vuejs/js/chunk-vendors.js    102.39 KiB        36.28 KiB
  ../public/vuejs/js/app1.js             2.34 KiB          1.13 KiB
  ../public/vuejs/js/app2.js             2.33 KiB          1.13 KiB

  Images and other types of assets omitted.

 DONE  Build complete. The ../public/vuejs directory is ready to be deployed.
 INFO  Check out deployment instructions at https://cli.vuejs.org/guide/deployment.html

Done in 24.85s.

[info] Wrote xxx/target/scala-2.11/play-vue_2.11-0.1.0-SNAPSHOT.pom
[success] All package validations passed
[info] Your package is ready in xxx/target/universal/play-vue-0.1.0-SNAPSHOT.zip

参考: Play Frameworkのプロジェクトをデプロイする

まとめ

本ブログではVue.jsを既存のPlay Frameworkに統合する方法を紹介しましたが、AngularJS、Reactを統合する方法もほぼ同じです。ぜひこれを参考に、様々な Modern WebSiteを作っていきましょう。

Happy Frontend Coding