はじめに
Alpine.jsの紹介をしたいが、Alpine.jsのみでは取っつきにくい気がしたので、同じ画面・機能を作ってReact比較してみよう!となったのが成行です。
(Reactタグも付けられるし。。。)
Alpine.jsとは?
Alpine.jsは、小規模アプリ向けの超軽量Javascriptフレームワークで、HTMLに直接Javascriptを記述することでアプリケーションに動的なUIを実装できます。
Alpine・Reactで同じ画面を作成
今回作成した簡単なTODOアプリは、以下で公開してます。
https://github.com/shiranaiHito999/alpine-vite
Alpine.js, React, 関連モジュールの導入などは割愛します。
アプリ画面は以下で、右上のボタンで両フレームワークを使ったページを切り替えられるようにしています。画面レイアウト・データは共通です。
今回実装した機能
- TODOアプリとしての基本CRUD操作
- インライン編集機能
- フィルタリング機能
- 完了済み一括削除
- LocalStorageによるデータ共有
DB,APIなどは用意せず、ローカルストレージを利用してAlpine.js/React間のデータ共有をしています。
ファイル構成
| Alpine.js | React | |
|---|---|---|
| HTML | index.html | react.html |
| UI | index.html | FrontPage.tsx, TodoRow.tsx |
| 初期化 | main.js | main.jsx |
| 状態管理 | alpine-store.js | TodosContext.tsx |
| ストレージ連携 | alpine-store.js | useLocalStorage.ts |
| 型定義 | - | todo.ts |
| スタイル | styles.css | styles.css |
各フレームワークの利点を活かせるよう、ファイル構成で意識したこと
- Alpine.js: 軽量フレームワークらしくシンプルな構成
- React: 型定義・コンポーネント分割を意識した型安全な構成
(React側もjavascript onlyで書くほうが比較として良いのでは?とは思いました。。。)
コード比較(HTML・UI)
Alpine.js
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/alpinejs-logo-icon.svg" />
<link rel="stylesheet" href="/common/styles.css" />
<title>Alpine TODO</title>
</head>
<body>
<h1>Alpine.js TODO</h1>
<a class="switch-link" href="/react.html">React version</a>
<!-- フィルタ状態はこの root のローカル state に持たせる -->
<div
x-data="{
filter: 'all',
filters: [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Completed', value: 'completed' }
]
}"
>
<div
class="input-row"
x-data="{
draft: '',
addTodo() {
if(this.draft && this.draft.trim()){
$store.todos.add(this.draft.trim());
this.draft = ''
}
}
}"
>
<input
type="text"
placeholder="新しいTODOを入力して Enter"
x-model="draft"
@keyup.enter="addTodo()"
/>
<button class="input-button" @click="addTodo()">追加</button>
</div>
<div class="controls">
<div class="small">
表示:
<template x-for="filterItem in filters" :key="filterItem.value">
<button
:class="{ 'font-weight-bold': filter === filterItem.value }"
@click="filter = filterItem.value"
x-text="filterItem.label"
></button>
</template>
</div>
<div style="margin-left: auto">
<button
class="clear-completed"
@click="$store.todos.clearCompleted()"
>
Clear completed
</button>
</div>
</div>
<div style="margin-top: 1rem">
<template
x-for="todo in $store.todos.getFilteredList(filter)"
:key="todo.id"
>
<div class="todo" x-data="todoItem(todo)">
<input
type="checkbox"
:checked="todo.completed"
@change="$store.todos.setCompleted(todo.id, $event.target.checked)"
/>
<div class="text">
<template x-if="!editing">
<div>
<span
x-text="todo.text"
:class="{ 'completed': todo.completed }"
></span>
</div>
</template>
<template x-if="editing">
<div style="display: flex; gap: 0.5rem">
<input
x-ref="input"
type="text"
x-model="tempText"
@keyup.enter="save()"
@keyup.escape="cancel()"
/>
<button @click="save()">Save</button>
<button @click="cancel()">Cancel</button>
</div>
</template>
</div>
<button @click="startEdit()">Edit</button>
<button @click="$store.todos.remove(todo.id)">Delete</button>
</div>
</template>
<div
x-show="$store.todos.list.length === 0"
class="small"
style="margin-top: 0.5rem"
>
TODO は空です
</div>
</div>
</div>
<script type="module" src="/alpine/main.js"></script>
</body>
</html>
React
メインはAlpine.jsなので気になれば、という感じです。
react.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/react-2.svg" />
<title>Todos — React</title>
</head>
<body>
<!-- 切替リンク:ReactページからAlpineへ移動する -->
<a class="switch-link" href="/">Alpine version</a>
<div id="root"></div>
<script type="module" src="/react/main.tsx"></script>
</body>
</html>
FrontPage.tsx
import { useState, useRef, useMemo, useEffect } from "react"
import type { Filter } from "../types/todo"
import TodoRow from "./components/TodoRow"
import { useTodos } from "./context/TodosContext"
const FILTERS: { label: string; value: Filter }[] = [
{ label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Completed", value: "completed" },
]
export default function FrontPage() {
const { todos, add, update, remove, setCompleted, clearCompleted } =
useTodos()
const [draft, setDraft] = useState("")
const [filter, setFilter] = useState<Filter>("all")
const inputRef = useRef<HTMLInputElement | null>(null)
function handleAdd() {
const t = draft.trim()
if (!t) return
add(t)
setDraft("")
inputRef.current?.focus()
}
const visible = useMemo(() => {
return todos.filter((t) => {
if (filter === "all") return true
if (filter === "active") return !t.completed
return t.completed
})
}, [todos, filter])
return (
<div>
<h1>React TODO</h1>
<div className="input-row">
<input
ref={inputRef}
type="text"
placeholder="新しいTODOを入力して Enter"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd()
}}
/>
<button onClick={handleAdd} className="input-button">
追加
</button>
</div>
<div className="controls">
<div className="small">
表示:
{FILTERS.map(({ label, value }) => (
<button
key={value}
onClick={() => setFilter(value)}
style={{ fontWeight: filter === value ? 600 : 400 }}
>
{label}
</button>
))}
</div>
<div style={{ marginLeft: "auto" }}>
<button className="clear-completed" onClick={clearCompleted}>
Clear completed
</button>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
{visible.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onToggle={(v) => setCompleted(todo.id, v)}
onRemove={() => remove(todo.id)}
onSave={(newText) => update(todo.id, newText)}
/>
))}
{visible.length === 0 && (
<div className="small" style={{ marginTop: "0.5rem" }}>
{todos.length === 0 ? "TODO は空です" : "該当するTODOがありません"}
</div>
)}
</div>
</div>
)
}
TodoRow.tsx
import { useEffect, useRef, useState } from "react"
import { Todo } from "../../types/todo"
export default function TodoRow({
todo,
onToggle,
onRemove,
onSave,
}: {
todo: Todo
onToggle: (v: boolean) => void
onRemove: () => void
onSave: (t: string) => void
}) {
const [editing, setEditing] = useState(false)
const [tempText, setTempText] = useState(todo.text)
const ref = useRef<HTMLInputElement | null>(null)
useEffect(() => {
setTempText(todo.text)
}, [todo.text])
useEffect(() => {
if (editing) ref.current?.focus()
}, [editing])
function cancelEdit() {
setEditing(false)
setTempText(todo.text)
}
function save() {
const t = tempText.trim()
if (!t) {
cancelEdit()
return
}
onSave(t)
setEditing(false)
}
return (
<div className="todo">
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => onToggle(e.target.checked)}
/>
<div className="text">
{!editing ? (
<span className={todo.completed ? "completed" : ""}>{todo.text}</span>
) : (
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
ref={ref}
type="text"
value={tempText}
onChange={(e) => setTempText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save()
if (e.key === "Escape") cancelEdit()
}}
/>
<button onClick={save}>Save</button>
<button onClick={cancelEdit}>Cancel</button>
</div>
)}
</div>
<button onClick={() => setEditing(true)}>Edit</button>
<button onClick={onRemove}>Delete</button>
</div>
)
}
Alpine.jsは、htmlに直接javascriptを記述できるため、値を利用するタグの近くで定義できます。
また、今回はTODOの状態管理を分かりやすく行うために分けていますが、基本的にhtmlファイルだけで完結するのがいい感じでした。
コードとしては以下のように、x-dataとして値・関数を定義して、子要素で利用できます。
<div
x-data="{
filter: 'all',
filters: [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Completed', value: 'completed' }
]
}"
>
<div
class="input-row"
x-data="{
draft: '',
addTodo() {
if(this.draft && this.draft.trim()){
$store.todos.add(this.draft.trim());
this.draft = ''
}
}
}"
>
React側では、FrontPage.tsxに同等の定義がありますが、利用のためにFrontPage→main.tsx→react.htmlと3ファイル利用して読み込んでフロントに表示しています。
React
const FILTERS: { label: string; value: Filter }[] = [
{ label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Completed", value: "completed" },
]
function handleAdd() {
const t = draft.trim()
if (!t) return
add(t)
setDraft("")
inputRef.current?.focus()
}
x-dataがAlpine.jsの思想として一番分かりやすいですが、その他にも、x-model,x-text,x-for,x-if(x-show),x-refなどを利用しています。
それぞれ簡単に説明すると、以下です。
- x-model: 入力要素の値を定義したデータにバインドさせる
- x-text: 定義したデータをテキストとして返す
- x-for: リストを反復処理する
- x-if(x-show): 条件で要素の表示・非表示制御
- x-ref: DOM要素にアクセスする
(まあ大体V-〇〇と一緒です)
コード比較(状態管理・ストレージ連携)
Alpine.js
alpine-store.js
import Alpine from "alpinejs"
import persist from "@alpinejs/persist"
import { v4 as uuidv4 } from "uuid"
export function createAlpineStore() {
return {
// $persistを使用してローカルストレージと自動同期
// 既存のReact実装と同じキーを使用して互換性を保つ
list: Alpine.$persist([]).as("alpine_todos_v1"),
add(text) {
const trimmedText = text.trim()
if (!trimmedText) return
this.list.push({
id: uuidv4(),
text: trimmedText,
completed: false,
})
},
update(id, newText) {
const item = this.list.find((todo) => todo.id === id)
if (item) {
item.text = newText
}
},
remove(id) {
const index = this.list.findIndex((todo) => todo.id === id)
if (index >= 0) {
this.list.splice(index, 1)
}
},
setCompleted(id, value) {
const item = this.list.find((todo) => todo.id === id)
if (item) {
item.completed = !!value
}
},
clearCompleted() {
this.list = this.list.filter((todo) => !todo.completed)
},
get active() {
return this.list.filter((todo) => !todo.completed)
},
get completed() {
return this.list.filter((todo) => todo.completed)
},
// フィルタ状態に応じたリストを返すヘルパー
getFilteredList(filter) {
switch (filter) {
case "active":
return this.active
case "completed":
return this.completed
default:
return this.list
}
},
}
}
// Re-usable data
Alpine.data("todoItem", (todo) => {
return {
editing: false,
tempText: todo.text,
startEdit() {
this.editing = true
this.tempText = todo.text
this.$nextTick(() => {
if (this.$refs && this.$refs.input) {
this.$refs.input.focus()
this.$refs.input.select()
}
})
},
save() {
const text = this.tempText.trim()
if (!text) {
this.cancel()
return
}
this.$store.todos.update(todo.id, text)
this.editing = false
},
cancel() {
this.editing = false
this.tempText = todo.text
},
}
})
// Alpine.jsのストアを初期化
export default function initAlpineStore() {
Alpine.plugin(persist)
Alpine.store("todos", createAlpineStore())
}
React
useLocalStorage.tsx
import { useState, Dispatch, SetStateAction } from "react"
/**
* localStorageと同期するカスタムフック
* @param key - localStorageのキー
* @param initialValue - 初期値
* @returns [value, setValue] - 状態と更新関数のタプル
*/
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, Dispatch<SetStateAction<T>>] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : initialValue
} catch {
return initialValue
}
})
const setValue: Dispatch<SetStateAction<T>> = (value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
return [storedValue, setValue]
}
TodoContext.tsx
import { createContext, useContext, useCallback, ReactNode } from "react"
import { useLocalStorage } from "../hooks/useLocalStorage"
import type { Todo } from "../../types/todo"
import { v4 as uuidv4 } from "uuid"
// Alpine.js側と同じローカルストレージキーを使用
const STORAGE_KEY = "alpine_todos_v1"
/**
* Todosコンテキストの型定義
*/
interface TodosContextValue {
todos: Todo[]
add: (text: string) => void
update: (id: string, newText: string) => void
remove: (id: string) => void
setCompleted: (id: string, completed: boolean) => void
clearCompleted: () => void
}
const TodosContext = createContext<TodosContextValue | undefined>(undefined)
/**
* Todosプロバイダーコンポーネント
* アプリケーション全体でTODOの状態を管理します
*/
export function TodosProvider({ children }: { children: ReactNode }) {
const [todos, setTodos] = useLocalStorage<Todo[]>(STORAGE_KEY, [])
/**
* 新しいTODOを追加
*/
const add = useCallback(
(text: string) => {
if (!text || !text.trim()) return
setTodos((prev) => [
...prev,
{
id: uuidv4(),
text: text.trim(),
completed: false,
},
])
},
[setTodos]
)
/**
* TODOのテキストを更新
*/
const update = useCallback(
(id: string, newText: string) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, text: newText } : todo))
)
},
[setTodos]
)
/**
* TODOを削除
*/
const remove = useCallback(
(id: string) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id))
},
[setTodos]
)
/**
* TODOの完了状態を設定
*/
const setCompleted = useCallback(
(id: string, completed: boolean) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed } : todo))
)
},
[setTodos]
)
/**
* 完了済みのTODOをすべて削除
*/
const clearCompleted = useCallback(() => {
setTodos((prev) => prev.filter((todo) => !todo.completed))
}, [setTodos])
const value: TodosContextValue = {
todos,
add,
update,
remove,
setCompleted,
clearCompleted,
}
return <TodosContext.Provider value={value}>{children}</TodosContext.Provider>
}
/**
* Todosコンテキストを使用するカスタムフック
* @throws {Error} TodosProvider外で使用された場合
* @returns TodosContextValue - TODOの状態と操作関数
*/
export function useTodos(): TodosContextValue {
const context = useContext(TodosContext)
if (context === undefined) {
throw new Error("useTodos must be used within a TodosProvider")
}
return context
}
状態管理(alpine-store.js)では、Alpine.data,Alpine.storeを利用しています。
それぞれグローバルで利用できるもので、動作は以下になります。
-
Alpine.data: 呼び出しごとに新しいインスタンスを生成する
→再利用するコンポーネントロジック、コンポーネントごとのローカル状態などに利用するとよい -
Alpine.store: アプリケーション全体で単一の状態を持つ
→アプリケーション全体で共有したいデータ、複数コンポーネントから参照・変更するデータに利用するとよい
Alpine.dataは、以下のようにTodoの編集ロジックを一つ一つのTodoに実装するのに利用しています。
Alpine.data("todoItem", (todo) => {
return {
editing: false,
tempText: todo.text,
startEdit() {
this.editing = true
this.tempText = todo.text
this.$nextTick(() => {
if (this.$refs && this.$refs.input) {
this.$refs.input.focus()
this.$refs.input.select()
}
})
},
save() {
const text = this.tempText.trim()
if (!text) {
this.cancel()
return
}
this.$store.todos.update(todo.id, text)
this.editing = false
},
cancel() {
this.editing = false
this.tempText = todo.text
},
}
})
呼び出し側
<div class="todo" x-data="todoItem(todo)">
<input
type="checkbox"
:checked="todo.completed"
@change="$store.todos.setCompleted(todo.id, $event.target.checked)"
/>
<div class="text">
<template x-if="!editing">
<div>
<span
x-text="todo.text"
:class="{ 'completed': todo.completed }"
></span>
</div>
</template>
<template x-if="editing">
<div style="display: flex; gap: 0.5rem">
<input
x-ref="input"
type="text"
x-model="tempText"
@keyup.enter="save()"
@keyup.escape="cancel()"
/>
<button @click="save()">Save</button>
<button @click="cancel()">Cancel</button>
</div>
</template>
</div>
<button @click="startEdit()">Edit</button>
<button @click="$store.todos.remove(todo.id)">Delete</button>
</div>
React側では、TodoRow.tsxがコンポーネントロジックとUIを実装しているので、Alpine.data+呼び出しに対応しています。
Alpine.storeは、以下のような記述でアプリケーション全体にTodolistの状態・CRUD操作を共有しています。
export function createAlpineStore(){
// Todolistの状態
// CRUD操作
}
Alpine.store("todos", createAlpineStore())
React側では、TodosContext.tsxで似た動作を実装していますが、こちらはグローバルに登録するのではなく、main.tsxでFrontPage.tsxをラップして、ラップした範囲内での状態管理を行う実装(Context)になっています。
最後にローカルスタックとの連携ですが、Alpine.jsではプラグインが用意されており、$persistを利用することで簡単にローカルスタックへ値を連携出来ます。
list: Alpine.$persist([]).as("alpine_todos_v1")
※React側は、JavaScript標準機能(window.localStorage)を利用しているだけなので割愛します。
所感
業務ではVueをメインで触っているおかげか、Alpine.jsはかなり取っつきやすかったです。Vueのv-○○がx-○○になっているだけで動作は同じ、という機能が多かったです。
今回は、Reactとの比較をしたいのもあり、viteのようなビルドツールと組み合わせましたが、Alpine.js単体でシンプルな構造にするなら、CDN+serve起動にすればインストールすら必要なくて、超軽量感が出そうだなと思いました。
諸々踏まえると、alpine.jsは複雑なロジックを含めないフロントアプリデモなど、既存htmlに簡単なデータ・表示制御を行って画面に動きを付けたい場合には向いているかなと思いました。
まだまだ反転途上のフレームワークですが、ドキュメントの充実や日本語化などが進めば、もう少し盛り上がるかも?と思いました。
(個人的にはビルドツール・UIライブラリとの連携などもしてほしいですが、そしたらもうVueで良くねとなってしまいますね。。。)
参考にした記事
→記事の構成、そもそもの同じ画面作る的な発想などを参考にさせて頂きました。
