これは Riot.js Advent Calendar 2020 の 6 日目の記事 🎉 です。
動機
いままで、HTML を書くときは共通部分を一元化したりするために EJS とか Nunjucks などの HTML を生成するテンプレートエンジンを使っていました。
そんな折に、Riot.js の公式 Twitter アカウントの以下のツイートを目にします。
Riot.js from now on can be used also as #javascript server side engine https://t.co/4L2ZxFaYGh pic.twitter.com/gv2epBROP6
— Riot.js Framework (@riotjs_) August 6, 2020
この水辺を楽しげにスキップしているおばあさん(?)が何者なのかわかりませんが、
ツイートのリンク先、GitHub のリリースを見てみると、Riot.js の SSR 用モジュールで、こんなサンプルが書いてありました。
<html>
<head>
<title>{ state.message }</title>
<meta each={ meta in state.meta } {...meta}/>
</head>
<body>
<p>{ state.message }</p>
<script src='path/to/a/script.js'></script>
</body>
<script>
export default {
state: {
message: 'hello',
meta: [{
name: 'description',
content: 'a description'
}]
}
}
</script>
</html>
これをみて最初は「あ、head タグとかも生成できるんだー、へー、まあ SSR にはそりゃ必要だよねー」くらいの感想だったんですが、あとでよくよく考えたら、これって Nunjucks とかでやってることと一緒じゃね?じゃあ Numjucks でやってることも Riot.js でできるんじゃないの?と思いました。
やれるかも
SSR 用のモジュール、デザイナーのぼくにはこれまであんまり馴染みがなかったんですが、
もともと、なるべく Webpack とか Gulp みたいなものに依存しない環境を作りたいという気持ちから Riot.js を使う環境も riot/cli という公式のプリコンパイラーを npm-scripts から使うようにしててて、 node.js で実行するコードも簡単なものは少し書いたりしてたので、自分でもやれるかもとおもいました。
やりたいこと
とりあえず、SSR 用のモジュールは、Node.js が動くサーバーで、リクエストに対して .riot
をコンパイルした結果を返す というものだろうとおもってたので、
その機能を npm-scripts から実行して、リクエストにコンパイル結果を返す代わりにローカルに HTML としてファイルを保存すればいいだろう、というのがやりたかったことです。(これができれば使っていた Nunjucks の代わりになりそうという発想)
やってみた。
ディレクトリ構造
ちょっと省略しますが、だいたいこんな感じです。
dist ← ここに HTML が保存される
node_scripts
└ html.js ← これを実行すると .riot → .html
src
└ html
├ components ← HTML 生成する際の共通パーツを置く
└ pages ← 生成する HTML に対応する .riot を置く
package.json ← 必要な npm-scripts を記述
.riot → .html の処理
SSR モジュールは Node.js から呼び出して使わないとなので、npm-scripts で呼び出すJSファイル node_scripts/html.js
を作って、そこで @riotjs/ssr を使った処理を書いていきました。
const fs = require('fs')// Node.js のファイル管理モジュール
const path = require('path')// Node.js のパス扱うモジュール
const glob = require('glob')// /**/*.js みたいにファイルを複数取得するために必要
const mkdirp = require('mkdirp')// ファイル保存時のディレクトリ作成に使用
const { render } = require('@riotjs/ssr')// Riot.jsのSSRモジュール
const register = require('@riotjs/ssr/register')// Riot.js のおまじないに必要
const srcDirFromRoot = './src/html/pages' // HTML のテンプレートとなる .riot ファイルを置くディレクトリ
const outputDir = './dist' // 生成した HTML を保存するディレクトリ
// Riot コンポーネントを require できるように
register()
// HTML 生成のテンプレートとなる .riot ファイルを読み込んで、HTMLを生成する関数を実行
glob(`${srcDirFromRoot}/**/*.riot`, (err, files) => {
if (err) return err
generateHtml(files)
})
// HTMLを生成する関数
const generateHtml = (files) => {
/*
* 引数で渡された配列から取り出したファイルパスごとに .riot → .html を実行。
* Riot.js の公式のサンプルリポジトリの中の SSR のサンプルを参考にした。
* https://github.com/riot/examples/blob/gh-pages/ssr/index.js
*/
files.forEach((file) => {
const Root = require(`.${file}`).default
const html = render('html', Root)
const dir = path.join(
outputDir,
file.replace(srcDirFromRoot, '').replace(/riot$/, 'html')
)
/*
* 書き出すディレクトリが存在しないとエラーになっちゃうので
* mkdirp でディレクトリ作成してそこに HTML を保存。
*/
mkdirp(path.parse(dir).dir).then(() => {
fs.writeFile(dir, html, (err) => {
if (err) throw err
})
})
})
}
HTML に変換される .riot ファイルの中身
HTML に変換される .riot ファイルは、いくつかの共通パーツに分かれています。
まず全体の雛形を設定した src/html/components/html.riot
はこんな感じです。
<html>
<head>
<title>{ props.title ? props.title : state.title }</title>
<meta if="{ props.meta }" each="{ meta in props.meta }" {...meta} />
<meta if="{ !props.meta }" each="{ meta in state.meta }" {...meta} />
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="{ this.props.toRoot }css/main.css">
<script src="{ this.props.toRoot }js/main.js" defer></script>
</head>
<body>
<h1>{ props.title ? props.title : state.title }</h1>
<static-component></static-component>
<slot name="default"></template>
</body>
<script>
import StaticComponent from './static-component.riot'
export default {
components: {
StaticComponent
},
state: {
title: 'Static',
meta: [
{
name: 'description',
content: 'a description',
},
{
property: 'og:title',
content: 'ogp title',
},
],
},
}
</script>
</html>
riot/ssr を使うことで、head タグとかにも Riot.js の props などが使えるので、それを使って、ページタイトルとか OGP なんかもページごとに設定できるようになっています。
また、あらかじめ import したコンポーネントは展開された状態で HTML ファイルが生成されます。
で、ページに対応した src/html/pages/index.riot
の中身はこんな感じです。
<html>
<template is="html" title="{ state.title }" meta="{ state.meta }">
<static-header></static-header>
<p>page content</p>
</template>
<script>
import Html from '../components/html.riot'
import StaticHeader from '../components/static-header.riot'
export default {
components: {
Html,
StaticHeader
},
state: {
title: 'title from props',
meta: [
{
name: 'description',
content: 'description from props',
},
],
},
}
</script>
</html>
ベースとなる src/html/components/html.riot
を読み込み、head タグなどに入れたい情報は、props として渡します。
HTML の保存先の dist
の中にディレクトリを切って HTML を保存したい場合は、src/html/pages/
の中に任意のディレクトリを切って .riot ファイルを置きます。
src/html/pages/child/index.riot
というファイル作ったとしたら以下のような内容に。
<html>
<template is="html" title="{ state.title }" meta="{ state.meta }" to-root="../">
<static-header></static-header>
<p>page content</p>
</template>
<script>
import Html from '../../components/html.riot'
import StaticHeader from '../../components/static-header.riot'
export default {
components: {
Html,
StaticHeader
},
state: {
title: 'Child page: title from props',
meta: [
{
name: 'description',
content: 'child page: description from props',
},
],
},
}
</script>
</html>
npm-scripts
あとは、最初の node_scripts/html.js
を Node.js で実行する npm-scripts を用意すれば完成。
"script": {
"start": "run-s html watch:html",
"html": "node node_scripts/html.js",
"watch:html": "onchange src/html -- npm run html"
}
とりあえず、これでやりたいことができました。
ちょっと駆け足になりましたが、静的な HTML を生成する書き方と、動的に JS で展開される Riot.js のコンポーネントがどちらも同じ書式でかけるようになって個人的には大満足。
ちょっとまだざっとやりたいことやれるようにしただけなので、なにか問題とかあるかもしれません💦
デザイナーが頑張って作ってみた環境なので、なにかおかしいところとか改善できそうなことがあったらぜひやさしくおしえてください🙇♂️
今回の記事は、個人的な Web 制作用のボイラープレートの中でやったことをかいつまんで書いてみました。
あまりリポジトリとして整理できてなくてちょっとお恥ずかしい感じですが、なにか「こうしたらいいよー」みたいなこととかがあれば issue とかで(やさしく)お知らせいただくのも大歓迎です。
↓
https://github.com/nibushibu/Getup