PHPでVueをSSRする方法

これは[PHP Advent Calendar 2018](https://qiita.com/advent-calendar/2018/php)の16日目の記事です。

## 背景

PHPはテンプレートエンジンなんてよく言われますが、昨今ではTwig、Smarty、mustacheなど様々なテンプレートエンジンが存在しています。

しかし昨今のWeb界隈ではHTM、CSS、JavaScriptの三権分立からコンポーネントとして機能で責務を分離するパラダイムにシフトしつつあります。
それを支える技術の一旦にServer-Side-Renderingというサーバ上でJavaScriptを実行してレスポンスを構築する手段が確立され、
今年は特にNuxtJSの反響がおおきかったように感じます。

さて、SSRをしたくてもNuxtを導入するのは超えなければならないハードルがいくつかあり、少々難易度が高く感じます。
せめてVueをPHPでテンプレートエンジンのように使えればなぁ... なんて思ったりしませんか？

この記事は、それを目指してPHPでVueをSSRする方法を試した内容をお話します。

## PHPでJavaScriptを実行する

PHPでJavaScriptを実行するには現在2通りの手段があります。

* [V8Js](https://github.com/phpv8/v8js)
* [PhpExecJs](https://github.com/nacmartin/phpexecjs)

なおこれらについては[PHPのV8JsでSSR](https://qiita.com/koriym/items/2b9d0eeb68ca544f84b7)という記事で詳しく説明されています。
また、Reactを使ったSSRについては[Baracoa](https://github.com/koriym/Koriym.Baracoa)というライブラリを用いれば簡単にできそうなので興味ある人はぜひ読んでみてください。

今回はV8Jsを使ってVueをレンダリングしてみます。

## VueでSSRする方法

Vue 2.xにおけるサーバサイドレンダリングについては[Vue SSR ガイド - 非 Node.js 環境における使用](https://ssr.vuejs.org/ja/guide/non-node.html)にて解説されています。

```php:render.php
<?php
$vue_source = file_get_contents('/path/to/vue.js');
$renderer_source = file_get_contents('/path/to/vue-server-renderer/basic.js');
$app_source = file_get_contents('/path/to/app.js');

$v8 = new V8Js();

$v8->executeString('var process = { env: { VUE_ENV: "server", NODE_ENV: "production" }}; this.global = { process: process };');
$v8->executeString($vue_source);
$v8->executeString($renderer_source);
$v8->executeString($app_source);
```

```javascript:app.js
// app.js
var vm = new Vue({
  template: `<div>{{ msg }}</div>`,
  data: {
    msg: 'hello'
  }
})

// `vue-server-renderer/basic.js` によってエクスポーズ
renderVueComponentToString(vm, (err, res) => {
  print(res)
})
```

### 解説

1. `vue.js` `vue-server-renderer/basic.js` `app.js` をテキストとしてファイルから読み込み
2. V8のインスタンスを作成
3. `process`オブジェクトを作成し、グローバル空間に`process`オブジェクトを格納するJavaScriptコードをV8で実行。
4. `vue` `vue-server-renderer` `app` の順でV8で実行

Reactに比べると少し複雑です。

### vue-server-renderer

[vue-server-renderer](https://github.com/vuejs/vue/tree/dev/packages/vue-server-renderer)はVueをSSRするためのユーティリティです。サーバ環境上でVueを実行し、HTMLのテキストとして出力できます。

`app.js`上で使っている`renderVueComponentToString`はこのvue-server-rendererが生成した関数で、この処理をV8で実行するとコールバックに生成結果が入ってきます。V8の`print`で`res`の値を出力しているのでPHP側で生成済みのHTMLが書き出されるという寸法です。

## 実装してみる

### V8の準備

僕的にはこれが一番こころ折れる作業だと思っています...
昨今は便利で、DockerでV8が使えるイメージが用意されているのでそちらを使いました。

[V8Js docker image](https://hub.docker.com/r/stesie/v8js/)

### ディレクトリ構造

今回はこんな感じで作ってみました。
app.phpでHTMLを出力して煩雑なレンダリング処理は別のファイルに追い出しました。
viewに関してはwebpackでバンドルしていますが、簡単に構造を示すためにVueCLIなどは用いていません。

```
+- src
| +- Renderer.php
+- views/
| +- dist/
| +- src/
| | +- App.vue
| | +- index.js
| +- package.json
| +- webpack.config.js
+- app.php
+- composer.json
```

* `app.php` - HTMLを返すエントリポイント
* `src/Renderer.php` - V8でJavaScriptを実行して結果を返すヤツ
* `views/src/index.js` - renderVueComponentToStringするエントリポイント
* `views/src/App.vue` - Vueファイル

### PHP側の実装

Rendererを呼び出すエイリアスを作っとく。

```json:composer.json
{
  "autoload": {
      "psr-4": {
          "VueSSR\\": "src/"
      }
  }
}
```

Rendererは`node_modules`のパスをわたして初期化します。
`render`メソッドを呼び出す最にエントリポイントとVueにわたすデータ構造を配列で指定できるようにしています。
`render`で渡された`$data`は`__PRELOAD_STATE__`というグローバルオブジェクトとしてJavaScriptに挿入されます。

```php:src/Renderer.php
<?php
namespace VueSSR;
use V8Js;
class Renderer 
{
    private $nodePath;
    private $vueSource;
    private $rendererSource;
    private $v8;
    /**
     * @param string $nodeModulesPath
     * @return void
     */
    public function __construct (string $nodeModulesPath)
    {
        $this->nodePath = $nodeModulesPath;
        $this->v8 = new V8Js();
    }
    /**
     * @param string $entrypoint
     */
    public function render(string $entrypoint, array $data)
    {
        $state = json_encode($data);
        $app = file_get_contents($entrypoint);
        $this->setupVueRendderer();
        $this->v8->executeString("var __PRELOAD_STATE__ = ${state}; this.global.__PRELOAD_STATE__ = __PRELOAD_STATE__;");
        $this->v8->executeString($app);
    }
    private function setupVueRendderer()
    {
        $prepareCode = <<<'EOT'
var process = {
    env: {
        VUE_ENV: "server",
        NODE_ENV: "production"
    }
};
this.global = { process: process };
EOT;
        $vueSource = file_get_contents($this->nodePath . 'vue/dist/vue.js');
        $rendererSource = file_get_contents($this->nodePath . 'vue-server-renderer/basic.js');
        $this->v8->executeString($prepareCode);
        $this->v8->executeString($vueSource);
        $this->v8->executeString($rendererSource);
    }
}
```

出力するHTMLの用意。
以下のように実行するとPartial的に使えます。

```php:app.php
<?php
require_once "vendor/autoload.php";
use VueSSR\Renderer;
$renderer = new Renderer('views/node_modules/');
?>
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width">
    <title>Example SSR Vue on PHP</title>
</head>
<body>
<?php $renderer->render('./views/dist/main.bundle.js', [
    'message' => 'hello vue!'
]); ?>
</body>
</html>
```

### viewsの実装

依存の構築。

```
npm install --save vue vue-server-renderer
npm install -D webpack webpack-cli vue-loader vue-template-compiler
```

WebPackでビルドを設定します。今回はエントリポイントが一つですが、複数のエントリポイントを設定してページを分けることも可能です。
`views/dist/main.bundle.js`に出力されるようになってます。

```js:views/webpack.config.js
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  entry: './src',
  output: {
    path: path.resolve('./dist'),
    filename: '[name].bundle.js',
  },
  module: {
    rules: [
      { test: /\.vue$/, use: 'vue-loader' },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
  ]
}
```

`App.vue`を読み込んで、`renderVueComponentToString`に渡しています。
このときに`__PRELOAD_STATE__`をサーバから受け取って、`App.propsData`に渡しています。
これによって、`App.vue`から`props`経由でサーバから渡らされた値を参照できるようになります。

```javascript:views/src/index.js
import App from './App.vue'

App.propsData = __PRELOAD_STATE__
var vm = new Vue(App);

renderVueComponentToString(vm, (err, res) => {
    if (err) throw new Error(err);
    print(res)
})
```

はい、Vueファイルです。
見ての通り、`props`でサーバから渡した`message`に値が取得できます。

```vue:views/src/App.vue
<template>
  <p>{{message}}</p>
</template>

<script>
export default {
  props: {
    message: { type: String }
  }
}
</script>
```

# まとめ

PHPでVueJSをSSRする方法をご紹介しました。
現状だと簡単とまでは行かないですが、PHPを使っていてVueを試したいって方は見てみると新しい発見があるかもしれません。

今回ご紹介したコードは[example-php-ssr-vue](https://github.com/rymizuki/example-php-ssr-vue)というリポジトリで公開していますので興味ある方は是非読んでみてください。