先日書いた 巷で話題のnue.jsを試してみた の続きです。
create-nueのソースコードを読んで、前回の記事で完成できなかった複数コンポーネントのレンダリングができるようになったので後悔、もとい公開しようと思います。
TodoリストとTodo入力を作ってみる
実装例としてよくあるTodoリストを作ってみます。
Todoデータ管理コンポーネント
Sharing code between componentsの章に記載されているように、コンポーネント間でコードの共有を使った方法を試してみます。
以下のようなTodoデータ管理コンポーネントを作成します。
class TodoItem {
constructor(title) {
this.title = title
this.isDone = false
}
}
export const todoItems = [new TodoItem('sample todo')]
export function addTodoItem(title) {
todoItems.push(new TodoItem(title))
}
Todoの中身を保持するtodoItems
という変数と、Todoを追加するaddTodoItem
関数を外部公開するコンポーネントです。TodoItemクラスは内部でしか使用しません。
この実装はチュートリアルのカート実装に内容を合わせています。
Todoコンポーネント
上記データコンポーネントを共有するようにした、レンダリング用のコンポーネントを作成します。
<script>
import { todoItems, addTodoItem } from "./todo.js";
</script>
<div @name="todo-list">
<p :for="item in items">
<span>{ item.title }</span>
<spna>{ item.isDone ? '完了' : '進行中' }</spna>
</p>
<script>
constructor(){
this.items = todoItems
console.debug('constractor todo-list')
}
</script>
</div>
<div @name="input-todo">
<input type="text" name="title" ref="inputTodo" />
<button @click="onClickInputTodo">Add Todo</button>
<script>
constructor(){
console.debug('constractor input-todo')
}
onClickInputTodo() {
addTodoItem(this.$refs.inputTodo.value);
}
</script>
</div>
ファイルの先頭にscriptタグを書き、コード共有したいjsファイルをimportします。
続いて2つのコンポーネント todo-list
とinput-todo
を定義しています。
コンポーネント名はv0.1.1現在ではケバブケースまたはスネークケースでしか記載できないようです。キャメルケース、パスカルケースでは記載できません。なぜなら後段のレンダリングでタグ名は全部lowercase変換されてしまうからです。前回の記事ではそこでハマっていました。
コード共有でimportした変数は、一旦コンポーネントのthis
にバインドしないと:for
ディレクティブでアクセスできないようです。要注意点。
Todo入力コンポーネントで新しいTodoを追加する処理では、$refs
を利用してinput要素の中身を取得しています。refの使い方はReactよりも易しそうです。
ページの作成
コンポーネントを配置するページを作成します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TodoApp</title>
</head>
<body>
<main @name="app-todo">
<h1>Todo</h1>
<h2>Input Todo</h2>
<input-todo />
<h2>List</h2>
<todo-list />
<script type="module" src="./app_todo.js"></script>
</main>
</body>
</html>
Todoコンポーネントファイルにて、@name
で定義したinput-todo
とtodo-list
をタグにして単純に配置しているだけです。
また、app_todo.js
というjsファイルはクライアントレンダリング用のスクリプトです。
中身はあとで紹介します。
render.jsの作成
サーバーサイドでページファイルをレンダリングするためのrender.js
を作成します。
中身はcreate-nue
を丸ごとパクって固有名詞だけ変更しています。
import { parse, render } from 'nuejs-core'
import { promises as fs } from 'node:fs'
const read = async (name, dir = 'templates') => await fs.readFile(dir + '/' + name, 'utf-8')
// read dependencies (server-side components)
const page = await read('AppTodo.nue.html')
const html = render(page)
// write index.html
await fs.writeFile('./www/todo.html', html)
console.log('wrote', 'www/todo.html')
AppTodo.nue.html
をレンダリングしてwww/todo.html
に書き出しています。
renderFile
使ってないなぁ。
レンダリング対象が増えてきたらフォルダをglabして出力するrender.jsを書いた方が良さそう。
なおrender.js
はcreate-nue
ではnpmのコマンドに組み込まれています。
コンポーネントのコンパイル
Todoコンポーネントのコンパイルを行うcompile.js
を用意します。
import { compileFile } from 'nuejs-core'
await compileFile('templates/TodoComponents.nue.html','www/lib/TodoComponents.js')
compile.js
もcreate-nue
ではnpmのコマンドに組み込まれていました。npmから実行可能です。
クライアントレンダリング用スクリプトの作成
ページファイルを作成したときに最後に出てきたapp_todo.js
を作成します。
import createApp from './nue.js'
import { lib } from './lib/TodoComponents.js'
// Mount <nue-island/> nodes as a Web Component
class NueIsland extends HTMLElement {
connectedCallback() {
const App = lib.find(el => el.name == this.getAttribute('island'))
if (App) createApp(App, {}, lib).mount(this)
}
}
customElements.define('nue-island', NueIsland)
create-nue
のsetup.js
からほぼそのまま失敬してきました。
コンパイルで生成する./lib/TodoComponents.js
をimportし、nue-island
というカスタム要素を登録するスクリプトみたいです。これはHybrid componentsで紹介されている内容と同等のものと思われます。
レンダリングとコンパイル
いよいよレンダリングとコンパイルを実行します。
haruyan@haruyan-mac nue-demo % node render.js && node compile.js
wrote www/todo.html
エラーなく完了しました。
レンダリングしたtodo.html
はこんな感じに変換されています。
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TodoApp</title>
</head>
<body>
<main>
<h1>Todo</h1>
<h2>Input Todo</h2>
<nue-island island="input-todo"></nue-island>
<h2>List</h2>
<nue-island island="todo-list"></nue-island>
<script type="module" src="./app_todo.js"></script>
</main>
</body>
</html>
<input-todo/>
や<todo-list/>
だった箇所が<nue-island>
タグに変換されています。このカスタム要素と中に書いてあるisland
属性を利用してコンポーネントのレンダリング対象を判断するものと推測されます。
これで表示に必要なファイルは全て作成完了です。
実際にブラウザで表示してみる
実際の表示結果がこちらです。
Todo入力欄、Todoリスト欄がそれぞれ表示されています。todoItems
初期化時に1件だけサンプルTodoを入れているのでそれがそのまま表示されています。
Todoを追加したらしっかりList側に反映されます。
異なるコンポーネント定義ファイル間で状態の共有ができるかどうか
VueでいうところのVuex
、Reactでいうところのrecoil
で実現されているコンポーネント間の状態共有について試してみます。
現状では狭い範囲で「状態のコンポーネント間共有」はできています。ドキュメント通り同じファイルに別々のコンポーネントを定義し、そのトップレベルにscriptタグで状態を管理するjsファイルをimportすることで共有を実現しています。
では異なるコンポーネント定義ファイルで状態の共有はできるのでしょうか?
既存のコンポーネントとは別にコンポーネントファイルを作成してみます。
<script>
import { todoItems, addTodoItem } from "./todo.js";
</script>
<div @name="rich-todo-list">
<p :for="item in items">
{ item.title } は { item.isDone ? '完了' : '進行中' } です。
</p>
<script>
constructor(){
this.items = todoItems
console.debug('constractor rich-todo-list')
}
</script>
</div>
Todoの表示が文章になったTodoListです。どこがリッチなんだとかツッコミは受け付けません。
ファイル冒頭に先ほど作ったTodoComponents
と同様のscriptタグを設置します。
このコンポーネントをページに追加します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TodoApp</title>
</head>
<body>
<main @name="app-todo">
<h1>Todo</h1>
<h2>Input Todo</h2>
<input-todo />
<h2>List</h2>
<todo-list />
<h2>Rich List</h2>
<rich-todo-list />
<script type="module" src="./app_todo.js"></script>
</main>
</body>
</html>
<rich-todo-list>
を追加しました。
最後に新しく作ったコンポーネントをコンパイルするようにします。
import { compileFile } from 'nuejs-core'
await compileFile('templates/TodoComponents.nue.html', 'www/lib/TodoComponents.js')
await compileFile('templates/RichTodoComponents.nue.html', 'www/lib/RichTodoComponents.js')
ファイルの修正が完了したら npm render.js && npm compile.js
でレンダリングとコンパイルを実行します。
初期表示はこのようになります。
Input Todo から Todoを追加するとList側にもRich List側にも追加が反映されます。
ブラ○ト、やるな!
コンポーネント間のデータ共有はVueやReactよりもかなり簡単にできそうです。簡単にできすぎて心配になるくらいです。チーム開発では闇雲に共有データを壊されないように共有データコンポーネントをしっかり設計する必要がありそうです。それに注意さえすればかなり良いんじゃないでしょうか。
終わりに
とりあえず「複数コンポーネントの描画ができました」の報告でした。
今回学んだことは、
- 作成するコンポーネント名はケバブケースで書かないといけない。Vueみたいにパスカルケースでコンポーネント名を書くとハマるぞ!公式ドキュメントはそんなこと教えてくれないぞ!
- 複数のコンポーネントで状態共有ができる。状態共有コンポーネントをファイルの先頭でimportするだけで済むぞ!
といったところです。
今回学習用に作成したコードをgithubに公開します。よろしければ参考にしてください。
https://github.com/haruyan-hopemucci/nue-demo