WP REST API + Riot.js - SPA 構築 -
デザイン戦略室フロントエンドエンジニアの吉井です。DeNA DESIGN BLOG の実装を担当させて頂きましたので、これを機に実装の際に得た知見と、コードをご紹介したいと思います。
- SPA(Single Page Application)について
- Riot.js について
- Without jQuery
- コンポーネントの css
- 採用しなかった Riot way
- Riot.js の大きな落とし穴
なお長文に渡るため 2 部構成とし、後編では以下の内容を綴りたいと思います。
- Flux ライクな RiotControl
- WP REST API と routing
- Pomise とアニメーション
- Mount によるトリック
SPA(Single Page Application)について
DeNA DESIGN BLOG は SPA(Single Page Application)になっており、ページのレンダリングを javascript で行っています。SPA では入力された URL に関係なく、一定の html を表示する様にサーバー設定します。表示された html(javascript)は URL を解析し、URL に応じたコンテンツを表示するものだと考えて貰えれば良いかと思います。ページのリロードなくコンテンツを読み込むため、URL が変わっているのにアニメーションし続けたり、BGM を流し続けたりと、従来の手法ではできなかった UI/UX を提供することが出来ます。
SPA 実装は昨今の WEB サービスのトレンドになってきており、コーポレートサイト、広告の WEB サイトでも普及してきました。SPA 実装のためのフレームワークは著名なものに、Angular・React・Vue などがあり、フロントエンド界隈では、どのフレームワークを選び、どういった手法で実装するべきなのか、というところに多くの注目が集まっています。
バックエンドのブログシステムには WordPress を利用しています。投稿は他の WordPress で作られたブログと相違ありませんが、表示側ではプラグインで WP REST API から投稿を取得し、theme として用意した SPA で表示を担う、これが DeNA DESIGN BLOG のアーキテクチャです。
Riot.jsについて
SPA実装にあたりフレームワークの選定にいくつか候補がありましたが、Riot.jsを採用しました。軽量で、SPA実装のために必要最小限のAPIを提供してくれます。その記法は従来のhtmlの書き方と似ていて、SPA・コンポーネント実装が初めての方にも敷居が低く作られています。
http://riotjs.com/
どのフレームワークでもそうですが、内部実装やコーディングスタイルに賛否両論あり、自身もはっきりとした pros/cons を把握しておきたいと思っていました。実装を通して得た所感としては、DeNA DESIGN BLOG の様なサイト構築には向いているのかな、と思いました。
Riot.js ではカスタムタグと呼ばれる、アプリケーション独自のタグを.tag という拡張子で定義していきます。カスタムタグには、html、css や javascript を含めて書くことが出来ます。このタグをネストしたり、条件に応じて表示する(マウントする)ことで、DOM を構築します。Riot.js に含まれている router が URL を解析し、マウントするタグを決めることで、SPA として機能します。
Riot.js の実装方法など、詳しくは公式ドキュメントや既出の記事を参照ください。本稿ではまだあまり認知されていない Riot.js の実装テクニック、コーディングガイドラインを綴っております。また、文中には自身が感じた Riot.js を導入するにあたり注意しなければならない、改善しなければいけない、と思う点も記載しています。これを通じて OSS として成長を続けている Riot.js を応援できれば幸いです。
Without jQuery
アプリケーション構築のうえで状態管理に問題が発生しやすいこと、古いブラウザを意識しなくても良くなってきたことから、フロントエンド界隈では jQuery は外そう、という空気感が出て来ています。Riot.js で用意されている API と、javascript の nativeAPI を利用すれば問題なさそうだったので、DeNA DESIGN BLOG も jQuery は使わないことに。jQuery.animate より高速なアニメーションライブラリを使ったり、レガシーブラウザの担保のために pollyfill で賄いながら実装しました。
$.ajax の代替
FetchAPI を利用しました。こちらの pollyfillで FetchAPI に対応していないブラウザをサポートをしています。fetch 関数は Promise を return します。
投稿者はユーザ認証を経て、記事の投稿やプレビューを行います。WordPress では CSRF 対策として nonce を利用するため、投稿者は Ajax リクエストヘッダに nonce を追加する必要があります。fetch 関数での追加方法は以下のとおりです。
theme/index.php
<?php
$wpApiSettings = json_encode(array(
nonce => wp_create_nonce('wp_rest')
));
?>
<script type='text/javascript'>
var wpApiSettings = <?=$wpApiSettings?>;
</script>
routes.js
const request = "path/to/api/endpoint";
const params = {
mode: "cors",
credentials: "include",
headers: { "X-WP-Nonce": wpApiSettings.nonce },
};
fetch(request, params)
.then((response) => {
return response.json();
})
.then((json) => {
console.log(json); // 認証済みのユーザに限るレスポンス内容
});
jQuerySelector の代替
jQuerySelector の代替として、querySelector、querySelectorAll がありますが、考慮した利用をしなければ意図しない挙動が発生するので注意が必要です。例えば以下の様な親タグがあるとします。
my-parent.tag
<my-parent>
<div class="name">parent</div>
<script>
this.on("mount", function () {
console.log(this.root.querySelector(".name").innerHTML);
});
</script>
</my-parent>
このタグをマウントすると結果はparent
となりますが、以下の様に子タグが追加されたとします。
my-parent.tag
<my-parent>
<div class="name">parent</div>
<my-child /> // 重複したclass名をもつタグをマウント
<script>
this.on("mount", function () {
console.log(this.root.querySelector(".name").innerHTML);
});
</script>
</my-parent>
my-child.tag
<my-child>
<div class="name">child</div>
</my-child>
このタグをマウントすると結果はchild
となってしまい、意図した挙動を得られなくなります。セレクタ名の工夫で賄おうとしても、いずれ衝突が起きる危険性があるため、この方法は避けなければいけません。この問題を解決するため、Riot.js では、タグのコンテキストに限った一意セレクタとして、name 属性と id 属性から DOM を参照することが出来ます。
my-custom-tag.tag
<my-custom-tag>
<h1 name="title">{ text }</h1>
<script>
this.text = "タイトル文字列";
this.title.style["background-color"] = "#ccc";
</script>
</my-custom-tag>
コンポーネント実装では、定義したタグに将来的に子コンポーネントをマウントしたり、別の親にマウントされることを意識しなければいけません。プロダクトの成長に応じて、新しいコンポーネントが積み重なり、入り混じり、利用されていくからです。
また、id 属性でも同様のことが可能ですが、id 属性はページ内の一意セレクタなので利用しません。先述のとおり、そのコンポーネントが1つしか使われない、ということは将来約束されないため、ページ内に一意なセレクタはコンポーネント実装では避けるべきです。
jQueryPlugin の代替
筆者は既に jQuery が導入されているプロダクトでは、実装のほとんどを自作 plugin の中に閉じる様にしています。Riot.js でも mixin を利用することで、同等の振る舞いをしてくれます。再利用生の高い機能は共通の mixin に切り出し、DRY な実装を心掛けます。
React.Component を extends する(継承する)様な方法が Riot.js のカスタムタグにはありません※が、mixin で代替することが出来ます。※同等のものに riot.Tag.extends があるが現状の安定版では非推奨
my-mixin.tag
var OptsMixin = {
init: function () {
console.log("hellow mixin!");
},
};
<my-mixin>
this.mixin(OptsMixin)
</my-mixin>
DeNA DESIGN BLOG では、babel を用いた実装をしているため、mixin を外部ファイルとして以下の様に定義しています。また、object ではなく、function で定義しているのは、mixin 適用時に引数をとることが出来るからです。
my-custom-tag.tag
import OptsMixin from "./opts-mixin";
<my-custom-tag>
<script>this.mixin( OptsMixin('hellow export mixin!') );</script>
</my-custom-tag>;
opts-mixin.js
export default function (message) {
return {
init() {
// init関数は必ず実行される
console.log(message);
},
};
}
hellow export mixin!
また、mixin は複数適用することが可能です。これを利用してタグ固有の実装は mixin 内に実装する方針に。共通の機能を提供する mixin(jQueryPlugin の代替にあたるもの)では、インターフェース関数を用意しておき、あとはタグ固有の実装箇所でインターフェースを override します。各々の mixin の init 関数が上書きされることがないため、各 mixin で担保しておきたいメンバはここで定義します。
my-custom-tag/view.tag
import AnimalMixin from "./animal-mixin";
import ViewModel from "./view-model";
<my-custom-tag>
<script>this.mixin( ViewModel('cat'), AnimalMixin('dog') );</script>
</my-custom-tag>;
my-custom-tag/animal-mixin.js
export default function (name) {
return {
init() {
this.name = name;
this.whoAmI();
},
whoAmI() {
console.log(this.name);
},
};
}
view-model.js
// タグ固有の実装をタグからmixinに切り出すことで見通しが良くなる
export default function (name) {
return {
init() {
this.name = name;
this.whoAmI();
},
whoAmI() {
console.log(`my name is ${this.name}.`);
},
};
}
dog
my name is cat.
コンポーネントの css
css もコンポーネントの責任範囲で閉じる指定にしておけば、後に定義するスタイルで打ち消す様な記述を削減することができます。以下の指定は、どこが危険か察しがつくでしょうか?
_my-parent.scss
.my-parent {
.name {
margin: 40px;
}
}
この指定も先述の「将来どこにマウントされ、何をマウントするのか約束されない」という観点からすると、ネストする子コンポーネントに影響を及ぼすことが予想されます。これを防ぐことは簡単で、以下の様に直下タグ指定を追記するだけで、コンポーネントのコンテキストに限定した指定をすることが出来ます。
_my-parent.scss
.my-parent {
> .name {
margin: 40px;
}
}
直下タグ指定をすることでもう一つ得られる利点として、安易な名称でも影響を受ける可能性が少なくなるため、冗長なセレクタを命名しなくても良くなります。もしネストが深くなり可読性が悪くなったら、それはコンポーネントの粒度を細かくするサインです。
採用しなかった Riot way
DeNA DESIGN BLOG はレスポンシブデザインの WEB サイトです。css のコーディングガイドを検討した際、Riot.js の scoped css やタグ内に含めるスタイルは選択肢から外しました。Riot.js に限った話ではないですが、css in js はレスポンシブデザインに適していないと考えています。その仕組みは、コンポートがマウントされたタイミングで head の style タグに定義が蓄積されていくものです。
カスタムタグに書かれた style タグからでは、mediaQuery による読み分けが出来ないため、インライン mediaQuery をタグの分だけ読みむ必要があります。これでは無駄なメモリを確保することになり、非力なスマートフォンにはエコではありません。このため、css in js は利用しない方針とし、従来どおり css と javascript は別ファイルで bundle しました。
また、Riot.js の scoped css は、他の MVVM フレームワークでプリコンパイルされたものと比べハッシュによる scope の生成ではなく、タグ名とそれに付随するものが付与されただけのものになっています。
webpack を利用した scoped css のハッシュ生成設定
{
// ...
module: {
loaders: ["stylus-loader?localIdentName_[hash:base64:5]"];
}
}
app.tag
<app>
<style scoped>
:scope {
max-width: 1680px;
}
</style>
</app>
index.html
<!-- 該当タグがマウントされた際にheadタグ内に挿入される -->
<style type="text/css">
app,
[riot-tag="app"],
[data-is="app"] {
max-width: 1680px;
}
</style>
何故これだけで scoped に出来るのでしょうか? それは次に提起している問題点に紐づいています。
Riot.js の大きな落とし穴
Riot.js ではタグ名が重複すると後者に上書きされてしまいます。安易なタグ名をつけてしまうと、気づかないうちに定義済みのタグが上書きされてしまい、アプリケーションを破壊してしまいます。この問題点から、ディレクトリに準じた命名規則でタグ名を決めるガイドラインで実装を進めました。
DeNA DESIGN BLOG のコンポーネントディレクトリ構成
/js/modules/
├── /header/view.tag # <module-header>
├── /footer/view.tag # <module-footer>
└── /aside/view.tag # <module-aside>
筆者はプリコンパイラに webpack とriotjs-loaderを利用していますが、プリコンパイル時のタグ名重複によるエラーハンドリングが出来れば、この問題点は解決されるかもしれません。
逆手にとると、タグ名とクラス名を同じにしてしまえば、css でのコンフリクトが起こらなくなり、ネストの浅いセレクタでスタイルを指定することができる利点もあります。
DeNA DESIGN BLOG のスタイルシートディレクトリ構成
/css/modules/
├── /header.sccs # .module-header
├── /footer.sccs # .module-footer
└── /aside.sccs # .module-aside
実装 TIPS 前編 総括
コーンポーネント実装における留意点、Riot.js における留意点を、導入時から検討出来ているかどうかで後の開発工数に大きく影響することがお伝え出来たかと思います。Riot.js では直感的で自由な実装が可能なだけに、スケールを想定しているプロダクトでは特に初期のガイドライン考慮が重要だと感じました。