はじめに
この記事は、jsフレームワークを使わずに一定の機能を持ったMVPを作るために、試行錯誤したフロントエンドのスタックを記載するものです。
モチベーションとして以下のようなものがありました。
- 未知の技術要素が複数あり切り分けが自身の力では困難なため、学習が進むまではできるだけ依存を減らしたバニラjsでやりたい。
- プロダクトの像が固まっておらずスクラップ&ビルドを繰り返すので、構造化設計はせずにでっかい塊のモジュールで扱いたい。
- とはいえフロントjsはwebpackに乗せて一定のモジュール化された設計で作りたい。
- 基本的には認証後に動くアプリだが一部OGPを重視したページがあるためにサーバテンプレートエンジンでベタ書きしたページを柔軟に組み合わせたい。
やったこと
上記を解決するために、サーバテンプレートエンジンをベースにhtmlを書きつつ、jsアプリをWebComponentsでモジュール化するアプローチを取りました。
サーバテンプレートエンジン(EJS)とフロントのスタックではcustomElementが中心となります。要するにテンプレートエンジンのプリプロセッサで吐き出したhtmlにインクルードするjsファイルをcustomElementベースでコンポーネント化しています。MPA(multi page application)です。
- SEO/OGP系はEJSだけでやります。実際はcustomElementも搭載されてるのですが、解釈されなくても支障がないようにEJSのテンプレートを作るということです。
- ブラウザはcustomElementが動き出してからシングルアプリケーションに近いノリでjsが中心になって処理を行なっていきます。
ルーターを使わずにveiwライブラリだけに特化したreactでもよかったのですが、生DOMや生windowをベタベタ触るのと、だいぶキャッチアップされましたが過去のreact-DOMのcanvasやmedia系の制限が頭の片隅にこびりついているのと、AngularはElements&ivyに期待してるけどまだ時期尚早であり実際問題このようなルーター外すアプリが作れるのか未知数であるということと、vue&railsの組み合わせの事例なんかは完全に同じ狙いになるのですが私がvueに無知であることと、あと個人的にwebComponentsの未来に期待していることでcustomElementによるモジュール化を採用しました。Use the Platform!
ソースコードサンプル
実際に作ってるものは以下のような感じです。
ライブラリとしては、テンプレートライブラリにlit-html
、UIライブラリに@ionic/core
のv4を採用しています。
@ionic/core
のv4はwebComponentsベースで書き直されているのでheadタグでインクルードするだけで使えます。(というか公式ドキュメントでheadタグでのインクルードが推奨されています。おそらくですがnpmインストールしてwebpackなどでバンドルするとcustomeElementなどの展開に問題が起こり得るからではないだろうかという憶測があります。)
以下のようにEJSのテンプレート記法を交えながら、customElementを記述していきます。
<!DOCTYPE html>
<html lang="en">
<head>
<% include layout/_head %>
</head>
<body>
<ion-app>
<ion-page class="ion-page" main>
<ion-content padding>
<h1 style="text-align: center">Sign In</h1>
<v-signin></v-signin>
</ion-content>
<% include layout/_footer %>
</ion-page>
</ion-app>
<script type="text/javascript" src="/static/vendor.js"></script>
<script type="text/javascript" src="/static/polyfill.js"></script>
<script type="text/javascript" src="/static/firebase.js"></script>
<script type="text/javascript" src="/static/vElements.js"></script></body>
</html>
ion-
タグがionicです。v-
タグがオリジナルcustomElementでvElement.js
にバンドルしています。
続いてオリジナルcustomElementの<v-signin>
の中身です。基本的にreactみたいに書いています。
import { html, render } from 'lit-html';
import firebase from '../../services/firebase';
import { post } from '../../services/api';
export default class SigninComponent extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
render(this.html(), this);
}
html() {
return html`
<ion-item>
<ion-input required type="email" id="signin-email" placeholder="メールアドレス"></ion-item>
</ion-item>
<ion-item>
<ion-input required type="password" id="signin-password" placeholder="パスワード" />
</ion-item>
<ion-button type="button" @click="${() => this.signIn()}">
ログインする
</ion-button>
<p><span style="color: red">${this.errorMessage}</span></p>
`;
}
signIn() {
const payload = {
email: this.querySelector('#signin-email').value,
password: this.querySelector('#signin-password').value
};
post('/api/signin', payload)
.then(res => firebase.auth().signInWithCustomToken(res.customToken))
.then(res => {
this.errorMessage = '';
location.href = '/';
})
.catch(error => {
this.errorMessage = error.message;
render(this.html(), this);
});
}
}
customElements.define('v-signin', SigninComponent);
実際はcustomElements.define()
はエントリーファイルに集めているのですが、だいたいの雰囲気です。
renderがめんどくさいですが、stateのような概念が無いので致し方なさがあります。もし上記のthis.errorMessage
をバインドするなら、RxJSを使ったこの記事が参考になります。また、extends HTMLElement
をextends LitElment
に変えることでreactに近付きます。
shadowDOMは使わずに自身のelementにlit-htmlのrenderをマウントするようにしています。もし、shadowDOMを使うなら以下のようになります。
export default class SigninComponent extends HTMLElement {
...
connectedCallback() {
render(this.html(), this.attachShadow({mode: 'open'}));
}
html() {
return html`
<ion-item>
<ion-input required type="email" id="signin-email" placeholder="メールアドレス"></ion-item>
</ion-item>
...
`;
}
signIn() {
const payload = {
email: this.shadowRoot.querySelector('#signin-email').value,
password: this.shadowRoot.querySelector('#signin-password').value
};
...;
}
}
customElements.define('v-signin', SigninComponent);
lit-htmlによって、生でshadowDOMを書くときに比べてめちゃくちゃシンプルになりました。
最初はこれでやっていたのですが、UIライブラリの選定過程で外しました。UIライブラリのスタイルが効かなくなるからです。shadowDOMの中で<link rel="stylesheet">
や<style> @import "../my/path/style.css"; </style>
が可能になっているので1、頑張ればいけそうですが、エコシステムの追いつきを待ちます。
なお、shadowDOMを外すことで、customElementでもbootstrapのようなclass属性ベースによるグローバルCSSフレームワークもそんなに支障なく適用可能です。(タグ構造でcustomElementが挟まることによるスタイル崩れは発生し得ます。)
webpack.config.js
これらをwebpackでビルドするために泥臭くなりました。EJSをraw fileとして読み込み、jsファイルをインクルードして、expressサーバでstaticフォルダとしてマウントしている場所に出力するようにしています。
webpackでそのままEJSからhtmlにコンパイルする方法もありますが、動的にデータバインドするためにあくまでEJSとして吐き出しhtmlコンパイルはexpressサーバに任せます。
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const workDir = 'src';
const common = ['polyfill', 'vendor'];
module.exports = {
mode: 'development',
devtool: 'source-map',
context: path.resolve(__dirname, workDir),
entry: {
polyfill: [
'@babel/polyfill',
'whatwg-fetch'
],
firebase: './services/firebase.js',
vElements: [
'./signin/index.js',
],
style: './app.scss'
},
output: {
path: path.resolve(__dirname, '../server/build/public'),
publicPath: '/static',
filename: '[name].js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.scss$/,
use: [
{
loader: 'file-loader',
options: {
name: 'bundle.css'
}
},
{ loader: 'extract-loader' },
{ loader: 'css-loader' },
{ loader: 'sass-loader' }
]
}
]
},
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /node_modules/,
name: 'vendor',
chunks: 'initial',
enforce: true
}
}
}
},
plugins: [
new CopyWebpackPlugin(['layout/*', 'error/*']),
// ランディングページ(認証外す)
new HtmlWebpackPlugin({
template: `!!raw-loader!${workDir}/index.ejs`,
filename: 'landing.ejs',
chunks: [...common, 'vElements']
}),
// アプリケーション
new HtmlWebpackPlugin({
template: `!!raw-loader!${workDir}/signin/index.ejs`,
filename: 'signin.ejs',
chunks: [...common, 'firebase', 'vElements']
}),
// シェア用ページ(認証を外す)
new HtmlWebpackPlugin({
template: `!!raw-loader!${workDir}/campaign/show.ejs`,
filename: 'campaign_show.ejs',
chunks: [...common]
})
]
};
コツとしてはディレクトリ構造を規約化することで、杓子定規にentryやHtmlWebpackPlugin作成できるようにします。
こちらの記事が参考になります。
EJSもcustomeElementもindex
の名前にしてディレクト階層を同レベルに保つことで以下のようなスクリプトでentryやHtmlWebpackPluginを自動生成するような感じです。ただ、開発が続いてくるとどうしても例外的なことが出てきて、なんだかんだ手作りの調整になっています。
for(const [ targetName, srcName ] of Object.entries(getEntriesList())) {
app.plugins.push(new HtmlWebpackPlugin({
template : srcName,
filename : targetName,
chunks : FullType
}));
}
おわりに
よかったこと
- npmパッケージの依存に悩まされることはほぼ無くなり、未知の領域の学習を進めることとプロダクトのスクラップ&ビルドに注力できました。
- フルスタックでやっているためサーバ側も開発しているが、昔ながらのwebアプリケーション作りと現代のフロントエンド開発のいいとこ取りができました。(自己評価)
- SEO/OGPのためにSSRサーバやBFFを入れずに済み、メモリが少ないGAE/nodeにもレールに沿ってサクッとexpressアプリケーションを乗せられています。
苦労したこと
- UIライブラリで使えるものがなかなか無いです。webComponentsベースで作られているものが少なく、通常のCSSフレームワークだとshadowDOMに苦労します。
- paper-elements系は、老舗ということで最初に飛び付いたが内部でpolymer依存しており単独で使えなかったため断念しました。
- onsen-uiは、初めはこれだ感があったがlit-htmlと相性が合わなかったため断念しました。2
- material-components-web-componentsは、期待しているがまだnpmパブリッシュされているものは一部です。
- ルーターが今の課題になっています。なんだかんだcustomElementでページ書き換えしていく形になり始めていてバックやリロードにhistory.push()だけで対処できていないです。