各種統計(例えばState of JavaScript 2024)を見ると現在Reactは圧倒的に指示されているフロントエンドのフレームワーク(ライブラリ)です。しかし、だからこそReactに不満を持ちながら使っている方もいると思います。
以下のような不満を持っている方にはRippleはどうでしょうか?
- ステート管理が面倒
- コンポーネント内にCSS宣言を書けない
- クラス属性の指定が
className=
なのはイヤだ - 条件で変わるJSXを書くのに条件演算子(三項演算子)を使うの不気味
- JSX内での繰り返しを書くにはmapメソッド使うの不気味
Gemini 2.5 Flash Imageが生成した画像を使っています
Rippleに付いて
Ripple GitHubの What is Ripple? の内容をAIに要約してもらいました。
RippleはフロントエンドWebへのラブレターとして、1週間もかからずに個人的に開発した非常に初期段階のプロジェクトです。著者(@trueadm)は、Inferno、React、Lexical、Svelte 5など著名なフロントエンドフレームワークに携わり、そこで得たさまざまなアイデアを基にRippleを試作しました。RippleはHTMLファーストではなく、JavaScript/TypeScriptファーストな設計で、独自拡張子.rippleを持ち、TypeScriptやJSXと親和性の高い独自スーパーセット言語を用いることで、開発者やLLMにとって良好な開発体験(DX)を目指しています。現時点では多くのバグや未実装部分が残る実験的なα版ですが、アイデアが他のフレームワークへ還元・共有されることを期待しています。Svelte 5との類似点が多いのは、作者がSvelte 5に関わった経験を活かしたためです。
Featuresには以下のように書かれています(AI翻訳です)
- Reactive State Management:
$
接頭辞付き変数によるビルトイン反応性 - Component-Based Architecture: クリーンで再利用可能なプロップと子コンポーネント
- JSX-like Syntax: Ripple 特有の機能強化が施された馴染みのあるテンプレート化
- TypeScript Support: 型チェックを含むTypeScriptの完全な統合
- VS Code Integration: 診断、シンタックスハイライト、インテリセンスによる豊富なエディタサポート
大ざっぱにいうとReact風のSvelteでしょうか。
Ripperのコード
以下の画像のような、いつものジャンケンアプリをRipperで作ってみましょう。プロジェクトの作成はGitHubのTry Rippleの手順で行いました。
- ① ジャンケンに関連する型や関数は、今まで通り
jyanken.ts
に書かれています(ブログ末にコードを置きました) - ② コンポーネントは関数
function
ではなくcomponent
で定義します - ③ $から始まる変数はステートとして扱われます。
useSate
等は不要です、このへんはSvelteに似ていますね - ④ ステートが配列の場合、ステートの更新はReact同様に新たな配列を作成する必要があります(pushメッソドでの追加ではダメです)
- ⑤ クラス属性はReactと違い
class=
で行えます👍 - ⑥ なぜか
<h1>じゃんけん ポン!</h1>
とは書けません😢 - ⑦ ステートをコンポーネントに渡すには名前を
$
から始める必要があります - ⑧ コンポーネント内に
<style>
でCSS定義が書けます - ⑨ RipperのJSX風の文にはif文が使えます
- ⑩ また、繰り返しにはfor…of文が使えます
import { judge, randomHand, Te } from './jyanken'; // ← ①
import type { Score } from './jyanken';
export component App() { // ← ②
let $scores: Score[] = []; // ← ③
const pon = (humanHand: Te) => {
const computerHand = randomHand();
const score: Score = {
human: humanHand,
computer: computerHand,
judgment: judge(humanHand, computerHand),
matchDate: new Date()
};
$scores = [score, ...$scores]; // ← ④
};
<div class="application"> {/* ← ⑤ */}
<h1 class="title">
{'じゃんけん ポン!'} {/* ← ⑥ */}
</h1>
<JyankenBox pon={pon} />
<ScoreList $scores={$scores} /> {/* ← ⑦ */}
</div>
<style> {/* ← ⑧ */}
.application {
margin: 0 2rem;
width: 50%;
}
.title {
margin: 1.5rem 0;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 700;
text-align: center;
}
</style>
}
component JyankenBox({ pon }: { pon: (hand: Te) => void }) {
<div class="button-block">
<button type="button" onClick={() => pon(Te.Guu)}>
{'グー'}
</button>
<button type="button" onClick={() => pon(Te.Choki)} class="center-button">
{'チョキ'}
</button>
<button type="button" onClick={() => pon(Te.Paa)}>
{'パー'}
</button>
</div>
<style>
.button-block {
display: flex;
margin: 0 auto 2.5rem;
width: 230px;
}
button {
padding: 0.5rem;
border-radius: 0.25rem;
width: 4rem;
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: #fff;
background-color: #2563EB;
border: 1px solid transparent;
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px 0 rgba(0,0,0,0.06);
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #1D4ED8;
}
.center-button {
margin: 0 1rem;
}
</style>
}
component ScoreList({ $scores }: { $scores: Score[] }) {
if ($scores.length === 0) { {/* ← ⑨ */}
<p class="no-score">
{'まだ対戦履歴がありません。'}
</p>
} else {
<table class="score-block">
<thead class="score-header">
<tr>
for (const title of ["時間", "人間", "コンピュータ", "結果"]) { {/* ← ⑩ */}
<th scope="col" class="score-header-cell">
{title}
</th>
}
</tr>
</thead>
<tbody class="score-body">
for (const score of $scores) { {/* ← ⑩ */}
<ScoreListItem score={score} />
}
</tbody>
</table>
}
<style>
.no-score {
text-align: center;
color: #9CA3AF;
}
.score-block {
width: 100%;
font-size: 0.875rem;
line-height: 1.25rem;
text-align: left;
color: #6B7280;
border-collapse: collapse;
}
.score-header {
background-color: #f8fafc;
border: 1px solid;
}
.score-header-cell {
padding: 0.75rem 1.5rem;
text-align: center;
}
</style>
}
component ScoreListItem({ score }: { score: Score }) {
const teString = ["グー", "チョキ", "パー"];
const judgmentString = ["引き分け", "勝ち", "負け"];
const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8);
const judgmentColor = ["#000", "#2979ff", "#ff1744"];
const color = `color: ${judgmentColor[score.judgment]}`;
<tr class="score-body-row">
<td class="score-body-cell" style={color}>{dateHHMMSS(score.matchDate)}</td>
<td class="score-body-cell" style={color}>{teString[score.human]}</td>
<td class="score-body-cell" style={color}>{teString[score.computer]}</td>
<td class="score-body-cell" style={color}>{judgmentString[score.judgment]}</td>
</tr>
<style>
.score-body-row {
border: 1px solid;
}
.score-body-cell {
padding: 1rem 1.5rem;
}
</style>
}
まとめ
はじめに書いた以下のReactの欠点ですが、
1. ステート管理が面倒
ReactはJSXはトランスパイラ(トランスコンパイラ)でJSに変換されますが、ステート管理等は実行時のライブラリーが行います。しかしRippleはSvelte同様にステートが変更されるとDOMを変更するコードがトランスパイラによって作られています。過去にSvelteの生成したコードを読んでみたというブログを書いているので興味がある方は読んでみてください。
Svelteではステートも通常の変数に見えますが、Rippleではステート変数名を$
から始めるというのは、扱いに注意が必要なステートを意識できるのは良いルールだと思います。
2. コンポーネント内にCSS宣言を書けない
Vueと同様にCSS定義がコンポーネント内に書けるのは良いですね。ただしTailwind CSSを使えば問題ではないのかな?🤔
3. クラス辱性の指定がclassName=
なのはイヤだ
Ripperではクラス辱性の指定がclass=
ですね。Next.js開発チームは今年の エープリルフール でclassName=
をclass=
にすると発言があり話題になりましたね。😄
4. 条件で変わるJSXを書くのに条件演算子(三項演算子)を使うの不気味 / 5. JSX内での繰り返しを書くにはmapメソッド使うの不気味
Vueのテンプレートのv-if
やv-for
、Svelteのテンプレートには{#if ...}
、{#each ...}
のようなif文、for文的な構文への要望は高いようですね。
感想
今回のサンプルコードを書いてみて感じたRippleの欠点は、htmlタグの子要素が文字列の場合に直接書けずに<h1>{'じゃんけん ポン!'}</h1>
のように書く必要がある事くらいでしょうか。将来的には解決してほしいですね。
また、VS Code用のRipple用プラグインがリリースされているのも良いですね、これによりコーディングがかなり楽になります。
途中でも書きましたが、RippleはReactに近いSvelteですね。Reactが書ける人はすぐに使えると思います。現在はアルファ版以前ですが楽しみな存在ですね❗
参考:React版のコード
- App.tsx
import { useState } from 'react';
import { judge, randomHand, Te } from './jyanken';
import type { Score } from './jyanken';
import './App.css';
function App() {
const [scores, setScores] = useState<Score[]>([]);
const pon = (humanHand: Te) => {
const computerHand = randomHand();
const score: Score = {
human: humanHand,
computer: computerHand,
judgment: judge(humanHand, computerHand),
matchDate: new Date()
};
setScores([score, ...scores]);
};
return (
<div className="application">
<h1 className="title">
じゃんけん ポン!
</h1>
<JyankenBox pon={pon} />
<ScoreList scores={scores} />
</div>
);
}
function JyankenBox ({ pon }: { pon: (hand: Te) => void }) {
return (
<div className="button-block">
<button type="button" onClick={() => pon(Te.Guu)}>
グー
</button>
<button type="button" onClick={() => pon(Te.Choki)} className="center-button">
チョキ
</button>
<button type="button" onClick={() => pon(Te.Paa)}>
パー
</button>
</div>
);
}
function ScoreList ({ scores }: { scores: Score[] }) {
const header = ["時間", "人間", "コンピュータ", "結果"];
return (
scores.length === 0 ?
<p className="no-score">
まだ対戦履歴がありません。
</p>
:
<table className="score-block">
<thead className="score-header">
<tr>
{header.map((title, ix) => (
<th key={ix} scope="col" className="score-header-cell">
{title}
</th>
))}
</tr>
</thead>
<tbody className="score-body">
{scores.map((score, ix) => (
<ScoreListItem key={ix} score={score} />
))}
</tbody>
</table>
);
}
function ScoreListItem({ score }: { score: Score }) {
const teString = ["グー", "チョキ", "パー"];
const judgmentString = ["引き分け", "勝ち", "負け"];
const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8);
const judgmentColor = ["#000", "#2979ff", "#ff1744"];
const color = {color: judgmentColor[score.judgment]};
return (
<tr className="score-body-row">
<td className="score-body-cell" style={color}>{dateHHMMSS(score.matchDate)}</td>
<td className="score-body-cell" style={color}>{teString[score.human]}</td>
<td className="score-body-cell" style={color}>{teString[score.computer]}</td>
<td className="score-body-cell" style={color}>{judgmentString[score.judgment]}</td>
</tr>
);
}
export default App
- Jyanken.ts
export const Te = {
Guu: 0,
Choki: 1,
Paa: 2
} as const;
export type Te = (typeof Te)[keyof typeof Te];
export const Judgment = {
Draw: 0,
Win: 1,
Lose: 2
} as const;
export type Judgment = (typeof Judgment)[keyof typeof Judgment];
export type Score = {
human: Te;
computer: Te;
judgment: Judgment;
matchDate: Date;
};
export type Status = {
draw: number,
win: number,
lose: number
}
export const randomHand = (): Te => {
return Math.floor(Math.random() * 3) as Te;
}
export const judge = (humanHand: Te, computerHand: Te): Judgment => {
return (computerHand - humanHand + 3) % 3 as Judgment;
}
export const calcStatus = (scores: Score[]): Status => {
const jugdeCount = (judge: Judgment) => {
let count = 0;
for (const score of scores) {
if (score.judgment === judge) count++;
}
return count;
}
return {
draw: jugdeCount(Judgment.Draw),
win: jugdeCount(Judgment.Win),
lose: jugdeCount(Judgment.Lose)
};
}
- App.css
body {
background-color: #fff;
}
.application {
margin: 0 2rem;
width: 50%;
}
.title {
margin: 1.5rem 0;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 700;
text-align: center;
}
.button-block {
display: flex;
margin: 0 auto 2.5rem;
width: 230px;
}
button {
padding: 0.5rem;
border-radius: 0.25rem;
width: 4rem;
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: #fff;
background-color: #2563EB;
border: 1px solid transparent;
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px 0 rgba(0,0,0,0.06);
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #1D4ED8;
}
.center-button {
margin: 0 1rem;
}
.score-block {
width: 100%;
font-size: 0.875rem;
line-height: 1.25rem;
text-align: left;
color: #6B7280;
border-collapse: collapse;
}
.no-score {
text-align: center;
color: #9CA3AF;
}
.score-header {
background-color: #f8fafc;
border: 1px solid;
}
.score-body {
background-color: #fff;
border: 1px solid;
}
.score-body-row {
background-color: #fff;
border: 1px solid;
border-bottom: 1px solid;
}
.score-header-cell {
padding: 0.75rem 1.5rem;
}
.score-body-cell {
padding: 0.75rem 1.5rem;
padding-top: 1rem;
padding-bottom: 1rem;
}