React では、React に最適化された UI コンポーネント ライブラリが出回っています。 Material-UI (MUI) や React Bootstrap などです。
では Mithril に最適化された UI コンポーネントライブラリはあるのか? 残念ながら たいして無い です。
そこで自分は Mithril で Bootstrap を使っています。
厄介な仮想DOM
Bootstrap に含まれる JavaScript のコードは仮想DOM を考慮していません。
しかし、Mithril は 仮想DOM を使います。
この違いによって Mithril で Bootstrap のコンポーネントを動かそうとしても動かないものがでてきてしまうのです。特に Modal(モーダル), Offcanvas(オフキャンバス)、Toast(トースト)... このあたりは普通には動作しません。
よりよい方法は Bootstrap に含まれるJavaScript コードを Mithril 向けの仮想DOMを想定した設計に全部書き換えれば良いのです。でも、大変ですよね。
そこまでしなくても、少し工夫すれば Bootstrap のコンポーネントが ある程度 は使えますよ。
今回は Bootstrap Modal (モーダル) を Mithril で動かしてみます。
Bootstrap Modal の仕組みと Mithril で動かす やり方 概要
通常の Bootstrap JavaScript API を用いた場合、次のようなコードになります。
const modal = new bootstrap.Modal(document.getElementById('myModal'), options)
modal.show();
Bootstrap JavaScript API の内部では、引数で渡した document.getElementById('myModal')
の内容を <body> (document.body) 直下へ直接DOM を書き換えることでモーダル コンテンツを描画します。
でも、これは Mithril では困った問題が発生します。
- Mithril では <body> (document.body) や、その直下にあるDOMコンテンツを 直接 書き換えるのはできません。仮想DOMだからです。
- Mithril 環境下で Bootstrap JavaScript API で渡す
document.getElementById('myModal')
についても、DOM オブジェクトです。 Mithril のコンポーネント型(m.Component) や Vnode型(m.Vnode) ではないので、使い勝手が良くありません。
こういう問題を回避するには <body> (document.body) 直下に Mithril の管理下 (仮想DOM) で処理できるような Portal(ポータル) (とここでは呼びます) という領域を自作して用意する方法がよさそうです。
ここで出てきた、Portal(ポータル) 領域は、次のような感じになります。(雰囲気をつかんでもらうためのイメージです)
class PageComponent {
public view(vnode: m.Vnode<any>){
return (
[
<div class="L-main">
{ /* メイン コンテンツ */ }
</div>
,
<div class="L-portal">
{ /* Portal(ポータル) */ }
</div>
]
);
}
}
m.mount(document.body, PageComponent)
そして、Bootstrap JavaScript API と Mithril と 準備した Portal の3つをつなぎ合わせるようなプログラムコードを書けば Modal は動くでしょう。
実装編
実装してみましょう
最初に読み込まれる部分 index.html main.tsx
JavaScript が最初に実行される 入口となる index.html と index.tsx を作成します。
ここは Mithril を使用する上での よくある 基本的やり方です。詳しい解説は省略します。
index.html
<html>
<head>
<meta charset="utf-8" />
<title>Hello World</title>
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body data-no-jquery="1">
<script type="module" src="/src/main.tsx"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
main.tsx
import m from 'mithril';
import Layout from './layout.js';
import PageModal from './page-modal.js';
m.route(document.body, '/', {
'/' : { render: ()=>{ return <Layout><PageModal /></Layout>; } } ,
});
Portal を含んだ レイアウト layout.tsx
次に Portal を配置した layout.tsx を準備します。
ここでは window.layout.state.portal
というグローバルでアクセス可能な配列を用意しています。
layout.tsx
import m from 'mithril';
export default class implements m.Component<any> {
private portal : Array<any> = [];
oninit(vnode: m.Vnode<any>){
window.layout = vnode;
}
public view(vnode: m.Vnode<any>){
return (
[
// メイン コンテンツ
<div class="L-main">
{ vnode.children }
</div>
,
// Portal (ポータル)
<div class="L-portal">
{
this.portal.map(
(component)=>{
return (
<div
oncreate={
(vnode)=>{
m.mount(vnode.dom, component);
}
}
onremove={
(vnode)=>{
m.mount(vnode.dom, null)
}
}
>
</div>
)
}
)
}
</div>
]
);
}
}
window.layout.state.portal.push( <描画したいMithrilコンポーネント> )
のようにすれば Portal
部分へ描画できる状況になりました。
解説 m.mount() による再描画分離
このソースコードを見て分かる通り、m.mount(vnode.dom, component)
のようにコンポーネントを描画しています。これは自動再描画を メインコンテンツ と Portalのコンテンツを分離する措置を行っています。
もし、ここを m.mount(vnode.dom, component)
ではなく m(component)
のようにしてコンポーネントの描画をしてしまったらどうなるでしょうか?
結論としては、モーダル内コンテンツ (つまり Portal コンテンツ) で発生したイベントから再描画処理を行うのが適切に行われなくなるでしょう。
Bootstrap Modal Wapper - modal-wapper.tsx
Bootstrap JavaScript API を Mithril で扱いやすく、作業性やコードの再利用性を考えて Wapper(ラッパー) コンポーネントを作ります。
modal-wapper.tsx
import m from 'mithril';
import classnames from 'classnames';
import * as bootstrap from 'bootstrap'
/**
* Modal Wapper
*
*
*/
export default class SELF implements m.Component<any> {
/**
* モーダルの背景をつけるかを設定します
*/
public backdrop: boolean|'static' = true;
/**
* ESC(エスケープキー) によってモーダルを閉じるかを設定します
*/
public keyboard: boolean = true;
/**
* モーダルが起動したときに, フォーカスをモーダルに移すかを設定します
*/
public focus: boolean = true;
/**
* モーダルが起動したときに表示するかを指定します
*/
public isShown: boolean = true;
/**
* show インスタンスメソッドが呼ばれてすぐに発生します。
*/
public onShow = ( (sender:any)=>{} );
/**
* modal がユーザに見えるようになったときに発生します。
*/
public onShown = ( (sender:any)=>{} );
/**
* hide インスタンスメソッドが呼ばれてすぐに発生します。
*/
public onHide = ( (sender:any)=>{ /*dummy*/ } );
/**
* modal がユーザから見えなくなった直後に発生します。
*/
public onHidden = ( (sender:any)=>{ /*dummy*/ } );
/**
* bootstrap modal object
*/
private modal : bootstrap.Modal;
public oncreate(vnode: m.Vnode){
const options = {
'backdrop': this.backdrop,
'keyboard': this.keyboard,
'show': this.isShown
};
vnode.dom.addEventListener('show.bs.modal', this.onShow);
vnode.dom.addEventListener('shown.bs.modal', this.onShown);
vnode.dom.addEventListener('hide.bs.modal', this.onHide);
vnode.dom.addEventListener('hidden.bs.modal', this.onHidden);
this.modal = new bootstrap.Modal(vnode.dom, options)
if(this.isShown){
this.modal.show();
}
}
public onupdate(vnode: m.Vnode){
}
public onremove(vnode: m.Vnode){
this.dispose();
}
/**
* 表示する
*/
public show(){
if(! this.modal ){
this.isShown = true;
return;
}
this.modal.show();
}
/**
* 非表示にする
*/
public hide(){
this.modal?.hide();
}
/**
* 表示してる場合は非表示に、非表示の場合は表示する
*/
public toggle(){
this.modal?.toggle();
}
/**
* モーダルの位置を再調整します
*/
public handleUpdate(){
this.modal?.handleUpdate();
}
/**
* Modal を廃棄します
*/
public dispose(){
this.modal?.dispose();
this.modal = undefined;
}
public view(vnode: m.Vnode){
const {
class: className,
...htmlAttrs
} = vnode.attrs;
const classes = classnames(
'modal',
className
);
return (
<div class={ classes } role="dialog" { ...htmlAttrs } >
{ vnode.children }
</div>
);
}
}
ここで作ったラッパーを window.layout.state.portal.push( <ModalWapper> )
のようにすれば Modal は描画はひとまず出来るはずですが、もう少し実用的にします。
表示するためのサンプルページ - page-modal.tsx
表示するためのページを作成します。
<button>showModal</button>
のボタンを押したら モーダルが表示される。さらに、モーダルを閉じた時に 戻り値を表示するようにしました。
import m from 'mithril';
import Modal from './modal-wapper.js';
export default class SELF {
private modal_return: any;
public async showModal(): Promise<boolean>{
return new Promise(
(resolve)=>{
const modal = {
view: ()=>{
let self: any;
return self = (
<Modal
class="modal fade"
tabindex="-1"
role="dialog"
backdrop={ true }
keyboard={ true }
onHidde={
(sender:any)=>{
resolve(false);
}
}
onHidden={
// Modal を閉じた時に Bootstrap JavaScript API では スタイルシートで非表示にしているだけである。放置すると portal に残ったままゴミがたまることとなる。
// そうならないように portal から削除しておきましょう。
(sender:any)=>{
window.layout.state.portal.splice( window.layout.state.portal.indexOf( modal ), 1);
m.redraw();
}
}
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Hello World!</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>こんにちは</p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
onclick={ (event:Event)=>{ resolve(false); } }
>
Close
</button>
<button
type="button"
class="btn btn-primary"
onclick={ (event:Event)=>{ resolve(true); self.state.hide(); } }
>
OK
</button>
</div>
</div>
</div>
</Modal>
);
}
};
window.layout.state.portal.push( modal );
}
)
}
public view(vnode: m.Vnode){
return (
<main class="container">
<h1>Mithril + Bootstrap Modal</h1>
<button
type="button"
class="btn btn-primary"
onclick={
async ()=>{
this.modal_return = await this.showModal();
m.redraw();
}
}
>
showModal
</button>
<div><pre>modal return: { JSON.stringify( this.modal_return , null, 4 ) }</pre></div>
</main>
);
}
}
実行してみましょう
サンプルコード本体一式
今回 使用したコードです