お正月なので何か挑戦したいと思い Vue.js と サーバレスアーキテクチャを使った SSR 構成に挑戦してみました。
構想
Vue.js の SPA(SSR対応版)を サーバレスアーキテクチャで組み上げます。
基本となるSPAサイト(主にJSやCSS)はS3経由で、SSR 的なサーバサイドの描画が必要な箇所は、Serverless Frameworkを使用して Lambda + ApiGateway で構築します。
SSR的なHTML配信と静的なAssets配信は同じオリジンで行いたいので最終的には CloudFrontで両者をマージします。
普通にVue.jsでSPAを作る
まずは兎にも角にも普通に Vue.js でSPAを作って行きます。
Vue.js の開発は Vue CLI を使えば高速に始められます。
$ npm i -g vue-cli
$ vue init webpack-simple vuespa
SPAのルーティングは VueRouterで簡単に実装できますが、
Routerコンポーネントへ値を注入するのは面倒なので Vuex も併用すると便利です。
Vue Router のルーティングは Lambda のパスとSPAのパスを共通で利用できるよう、History API モードを使用しておきます。
アプリケーションの作成ポイントの分離
まずは普通にSPAを構築していけば良いのですが、SSRでもコードの再利用が可能なよう、一部以下のような工夫を加えています。
以下のような アプリケーションオブジェクトのファクトリ関数を作成しVueアプリケーション制作のステップを共通化します。
import Vue from 'vue'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
Vue.use(VueRouter)
Vue.use(Vuex)
const router = new VueRouter({
mode:"history",
routes : [ ... ]
})
const store = new Vuex.Store({ ... })
export default (options)=>{
options = Object.assign({},options,{
router,
store,
})
return new Vue(options)
}
通常のSPAアプリケーションでは、 el
をベースにHTMLファイルに記述されたDOMに Vue アプリケーションをマウントするので、 Webpack のエントリーとなる main.js
は以下のように記述しました。
require("./app.js")({}).$mount("#app")
静的コンテンツの配信
SPAが完成したら運用はS3で行いますので、ローカルで開発したら静的サイト設定済みのバケットへ以下のコマンドで同期します。
aws s3 sync . s3://wpapi-vuejs/ --delete --acl public-read --exclude="*node_modules*"
新しいコンソール画面便利なのですが、バケットポリシーと言う概念はどこに行ったのでしょうか?
History API を使用したSPAでは、 error document / index document ともに index.html
を使用し、全てのリクエスト(ただしAssets系は除く)が index.html
に到達するよう設定してやるといいです。
作成されたHTMLを元にLambdaを作成する
SPAの配信が終わったら作成されたHTMLをベースにServerless Frameworkを用いた Lambda の構築を始めていきます。
以下では vuejs
というフォルダにSPA構成が格納されているものとして、コードの一部を紹介していきます。
const renderer = require('vue-server-renderer').createRenderer()
module.exports.index = (event, context, cb)=>{
// SPA側のフォルダからHTMLを読み込む
let html = require("./vuejs/index.html")
// コンポーネントを使用してVueアプリケーションを作成
const listComponent = require("./vuejs/src/post_list.vue")
const app = require("./vuejs/src/app.js")(listComponent)
// 作成されたアプリケーションから Vuex アクションを呼び出し
app.$store.dispatch("updateList").then(()=>{
// action が完了したら SSR 描画をスタート
renderer.renderToString(app, function (error, chunk) {
if (error) throw error
// chunkはコンポーネントの部分HTMLがおちてくるので、必要箇所を代替
html = html.replace("<ssr-view></ssr-view>",chunk)
// state にアクセスして action の結果も覗けるので適宜OGP等でも利用可能
const newTitle = app.$store.state.list[0].title.rendered + "他 10記事"
html = html.replace("<title>....</title>","<title>"+newTitle+"</title>")
cb(null,html)
})
})
}
Serverless では Serverless Webpack を使用して、HTMLや.vue ファイル等をまとめてバンドルしています。
関数内では SPA向けのHTMLにreplaceをかけて、OGP/SEO情報や、初期描画で使用するコンポーネントなどをまとめてHTMLの中に埋め込んでいます。
VueRouterを使用したSPAの場合, router-viewをそのまま置き換えてしまっては、SPAの構成が破綻するので、少し工夫が必要です。この辺標準のやり方とか提供されていないんでしょうか?
serverless.yml
の方では パラメータ付きリクエストパスが処理できるよう以下のような記述を行っています。
functions:
postList:
handler: handler.index
events:
- http:
path: /
method: get
integration: lambda
response:
headers:
Content-Type: "'text/html'"
template: $input.path('$')
postDetail:
handler: handler.detail
events:
- http:
path: /post/{id}
method: get
integration: lambda
response:
headers:
Content-Type: "'text/html'"
template: $input.path('$')
HTML内URLの問題
Lambda はデプロイやsls webpack serve
によってブラウザからHTMLを受信可能になりますが、CSSやIMAGE等のURLを /assets/...
のように記述していると、ドメインが異なるためよみこめなくなります。
あとで CloudFront を経由して同じOrigin にまとめるので問題ないのですが、開発時のデバッグには何かと不便です。
stage だけHTMLの中に base
要素を埋め込むなどの方法で一部回避出来るので、とりあえずそれで対応していました。
CloudFront によるマージ
最終的にはCloudFront を用いて S3の静的Web配信とApiGateway をくっつけます。
それぞれを Origin に登録し,S3をDefault の Behavior に、 ApiGateway 側の Origin は serverless.yml で設定したパスに限り そちらに向くよう Behavior を設定します。
あとはそれぞれ反映されるのを待てば完成です。
雑感
年始めもくもく会の限られた時間内でざーっとチャレンジしてみた内容ですが、Serverless 構成での SSR 構築は十分可能かなぁとう印象です。コードがブレブレなのは時間がなかったからということでお許し下さい。
開発時にドメインがコロコロ変わるので、そのへんのハンドリングとかが結構めんどくさかったです。
一番時間がかかったのは S3 の sync でUPしたファイルが「公開」になっていないことに気づかずずっとアクセスできなかった、というところです。昔のコンソールにはバゲットポリシーとかあったと思うんですが、どこにいったんでしょう?
SPAのSSR化はもうちょっと楽になるといいなぁという印象です。router-view
をそのまま置換or中にchunk 埋め込んできれいに動作してくれたらいいんだけど…