React とか SolidJS などのフレームワークって便利ですよね。
コミュニティとエコシステムが成長し、ググれば記事なんて沢山転がってますし。
でも、場面によっては不要な機能が沢山付いてしまいますよね。
そこで、DOM のレンダリングと JSX 構文のエッセンスだけに絞った超軽量なライブラリ Hiroshi JS を作成してみました。
▼ NPM パッケージは以下
▼ GitHub リポジトリは以下
特徴
超軽量
2022/08/12時点で、BundlePhobia での計測値は zgip 圧縮で 657B(未圧縮なら 1.12kB)となっています。
比較するのもおこがましいのは百も承知ではありますが、他のライブラリとも BundlePhobia で比較してみました……
ライブラリ名 | 未圧縮サイズ | zgip 圧縮サイズ | 備考 |
---|---|---|---|
Hiroshi JS | 1.12kB | 0.7kB | ※1 |
React + React DOM | 6.4kB + 130.5kB | 2.5kB + 42kB | ※2 |
Preact | 10.3kB | 4kB | |
Vue.js | 95.3kB | 34.2kB | |
Svelte | 4.5kB | 1.7kB | |
SolidJS | 20.1kB | 7.1kB |
※1 今回作成したライブラリ
※2 ブラウザ上で使用するなら React DOM をセットで使用する必要あり
他のライブラリと比べて、とても軽量であることがわかると思います。
ただ、上記のライブラリ webpack 等のプロジェクトに追加したとして、そのサイズがそのまま JS のファイルサイズとして増えるわけではない(Tree Shaking される)ので、あくまで参考程度に。
JSX 形式で記述ができる
これをメリットと呼べるか微妙なところですが。
例えば、React の <div className='sample'>TEXT</div>
は
React.createElement('div', {className: 'sample'}, 'TEXT')
の
糖衣構文に過ぎないので、これと同じ引数を取る実装を作成した形となります。
そのため、Hiroshi JS は React と非常に近い形で記述ができるので、一度 React を触ってたら実質学習コストが無いと言っても良いのではないかなと考えています。
TypeScript を触ったことあるなら誰でも実装できる範囲内のソースコード
React のソースコードを見て発狂したことがある人は自分だけではないはず。
そして Hiroshi JS は TypeScript で記述しているのですが、圧倒的に記述量が少ないので、大体の人が読むことができると思います。
「なんでか分からないけど React 動いてるのが怖い!」だったり「React に致命的なバグがあったらどうするんだ!」みたいな超慎重派の方でも使用して頂けるのではないかなと思います。
前者はまだわかるけど後者の人ってまだいるのかな……
▼ 2022/08/12時点では処理は97行しか書いていないです。
想定しているユースケース
jQuery 等で HTML の出力を文字列で行っている場合
以下のような JS 実装をしている方を見かけたのがキッカケだったりします。
まだ jQuery を使っているの?というアンチ的な考えは毛頭ないのですが、HTML を文字列で組み立てず、(JSX という糖衣構文を噛ますかはさて置き)関数ベースで HTML を組み立てて出力し、その際に勝手にエスケープしてくれるような仕組みがあれば XSS の脆弱性は未然に防げるなと考えました。
// ※href や text 変数はエスケープされていないものとする
const href = '"><script>alert("アラート!!");</script>';
const text = '<style>* {display: none !important;}</style>';
// これでアラートが発火して、画面が真っ白になってしまう
// 各変数に replace を噛ましてエスケープするでも良いが、ヒューマンエラーも発生しそう……
$('#app').append('<a href="' + href + '">' + text + '</a>');
新しく作成したライブラリを経由すれば、仕組みとしてエスケープを勝手にしてくれます。
// 後述の手段で今回作成したライブラリの createElement メソッドを読み込んだ上で
// createElement は名称が長いので h 変数に突っ込む
const c = createElement;
const href = '"><script>alert("アラート!!");</script>';
const text = '<style>* {display: none !important;}</style>';
// h (createElement) 関数内でエスケープされる仕組みを提供
// ※ 以下の記述は JSX 構文を使用しない記述となります
$('#app').append(c('a', {href: href}, text));
皆が jQuery なんぞ使わず React や Vue.js だったり SolidJS を使っていれば良いのですが、世の Web サイトの8割以上 1 が jQuery を使っているので、この使用例が活きる箇所は沢山あるのではと考えています。
API を元に JS で HTML 出力するけど、それ以降 state の変更がない場合
基本バックエンドで HTML を出力するけど、CDNを使用していてキャッシュヒット率を上げる目的で コンテンツを部分的に API で渡し JS でレンダリングするだけ、みたいなシチュエーションで当ライブラリが活きてくると想定しています。
ユーザーの操作に応じてリアクティブに HTML を書き換える必要があるなら React や Vue.js 等を使用すれば良いと思いますが、単にデータを受け取って HTML として出力するだけなら、それらのライブラリはオーバースペックであり、意図しないのJS ファイルのバンドルサイズの肥大化の要因となりえてしまいます。
(前述の通りいくら webpack 等のバンドラーで Tree Shaking がある程度機能するとしても、限界はあると思っているのですが、実際のところどうなんでしょうか……この辺りの知見も貯えたい所存。)
import * as React from 'react';
import {createRoot} from 'react-dom/client';
const App = props => {
const [apiData, setApiData] = React.useState([]);
// API を叩いて表示する
React.useEffect(() => {
fetch('/api/v1/path/to/api')
.then(res => res.json())
.then(res => setApiData(res));
}, []);
return <>{apiData.map(item => <Card {...item}/>)}</>;
};
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App />);
こういう API 叩いて表示するだけ、みたいなシチュエーションで活きてくると思います。
使い方
CDN(オプション)
▼ ES モジュール使いたくない場合( <script src="xxx">
とか読み込む方)
- https://cdn.jsdelivr.net/npm/hiroshi@latest/dist/umd/hiroshi.js
- https://unpkg.com/hiroshi@latest/dist/umd/hiroshi.js
※上記 js ファイルを読み込んだ上で Hiroshi
オブジェクトでアクセスできます。
▼ ES モジュール形式( import xxx from 'xxx'
とか書く方)
- https://cdn.jsdelivr.net/npm/hiroshi@latest/dist/esm/hiroshi.js
- https://unpkg.com/hiroshi@latest/dist/esm/hiroshi.js
JSX 形式
上記の通り CDN でも使用できますが npm でパッケージを取り込む想定で説明を進めます。
Hiroshi JS を install したうえで、
$ npm install hiroshi
適宜モジュールバンドラーやトランスパイラで JSX のコンパイルオプションを調整してください。
TypeScript の tsconfig.json
の調整例
※ typscript
パッケージインストール済みの上での設定(抜粋)。
{
"compilerOptions": {
"jsx": "preserve",
"jsxFactory": "createElement",
"jsxFragmentFactory": "Fragment",
}
}
Babel での調整例
※ @babel/core
@babel/preset-env
@babel/plugin-transform-react-jsx
パッケージインストール済みの上での設定(抜粋)。
{
"plugins": [
["@babel/plugin-transform-react-jsx", {
"pragma": "createElement",
"pragmaFrag": "Fragment",
}]
]
}
設定が面倒だよという人
Hiroshi JS を React として読み込ませれば動作するようになっています。
※ TypeScript など型に厳密な設定だとエラー出力されるかも、未検証です。
import * as React from 'hiroshi';
const {createElement, Fragment, createRef} = React;
import {createElement, Fragment, createRef} from 'hiroshi';
上2つの記述は同じ処理が走るので、適宜読み替えてください。
実装
React のように仮想 DOM を用いず、直接生のDOM を返却するようにしているため、以下のように記述することができます。
もちろん文字列のエスケープ処理も勝手に行われるので、意図的に危険な処理を挟まない限りは XSS が起きないような実装にしています。
import {createElement, Fragment, createRef} as React from 'hiroshi';
const style = {
marginBottom: "0.5rem",
border: "solid 1px #aaa",
padding: ".5rem",
flexBasis: "12rem"
};
const Card = (props) => (
<div className="card"
style={style}
onClick={e => alert(`Click: ${props.name}`)}>
{props.name}: {props.children}
</div>
);
const UserList = () => {
const ref = React.createRef();
fetch("//jsonplaceholder.typicode.com/users")
.then((response) => response.json())
.then((res) => {
// API が読み込まれたら DOM を生成して ref を用いて置き換える
const {current} = ref;
current.replaceChild(
<>{res.map(({ name, username }) => (
<Card name={name}>{username}</Card>
))}</>,
current.firstElementChild
);
});
// API 読み込み中のメッセージを出しておく
return (
<div
className={"userList"}
style="display: flex; flex-wrap: wrap; gap: .25rem;"
ref={ref}
>
<span>Now loading...</span>
</div>
);
};
document.getElementById("app").appendChild(UserList());
細かい説明は省略します(追って追記するかもです)が、React における以下メソッドの互換を作成しています。
-
React.createElement
-
React.Fragment
-
React.createRef
- React の最上位 API – React #reactcreateref
- Ref と DOM – React のコールバック ref も対応
コールバック ref も対応しているので createRef
を使わずに以下の記述で済ませることもできます。
const App = () => {
const callback = node => {
// ref 属性で指定した node が取得できる
console.log(node);
// do something
};
return <div ref={callback}>Now Loading...</div>
};
JSX 未使用な形式
細かい説明は省きますが JSX 形式を使用せずとも記述が出来ます。
バンドラーやトランスパイラ等を使用していない案件でも使用いただけます。
以下の出力結果は、等価となります。
import {createElement as c, Fragment} from 'hiroshi';
const jsx1 = <div className='sample' dataSample='hoge'>TEXT</div>;
const notJsx1 = c('div', {className: 'sample', dataSample: 'hoge'}, 'TEXT');
const jsx2 = <>
{items.map(item) => <Card {...item}/>}
</>;
const notJsx2 = c(Fragment, null,
...items.map(item) => c(Card, {...item})
);
const jsx3 = <div>
<div>ITEM 1</div>
<div>ITEM 2</div>
</div>;
const notJsx3 = c('div', null, ...[
c('div', null, 'ITEM 1'),
c('div', null, 'ITEM 2'),
]);
今後について
現在バージョンを 0.0.x
としていますが、当記事の評判次第なところはありますが、テストコードや厳密な型定義(TypeScript サポート)ができ次第、バージョンを 1.0.x
とし、ドキュメントも適宜拡充していこうと考えています。
終わりに
もしバグとかあったら issue なりプルリクエストを投げて頂けると幸いです。
この世のサイト、全て某ホームページ爆速表示系人気男性俳優さんのホームページみたく軽くなることを願っています。
このライブラリでサイトの軽量化に一役買えたなら嬉しい限りです。