Svelteをプロダクト開発に使って、思ったあれこれ。
環境構築
Reactより大差ない。
- sveltekit使えば一瞬
- vite-svelteも可能。
- eslint
- そのまま動かないものが結構ある。
- prettierも動く
- css関連の拡張機能もひと通り動くきがする。
エコシステム
typescript
-
型縛りはReactほどではないが、それなりにできると思う、ただtsが使えるのは
<script lang="ts">
内のみ、template側でtsコードを書けない。 👇のようなエラーがでて、コンパイルが通らない。
-
ReactのようにGenerics Typeを気軽に書けない、svelteの$$Genericみたいなutility typeが用意されている、ここは結構いまいちな気がする。
https://github.com/dummdidumm/rfcs/blob/ts-typedefs-within-svelte-components/text/ts-typing-props-slots-events.md#generics -
それ以外の方法だと、scriptタグにgenerics属性を書くスタイルがあるが、ずっとReact書いていた身からしたら、ちょっと抵抗感があるというか、""の中にTSコードが入るのはちょっと気持ち悪いなと思う。
<script lang="ts" generics="T extends boolean, X">
import {createEventDispatcher} from "svelte";
export let array1: T[];
export let item1: T;
export let array2: X[];
const dispatch = createEventDispatcher<{arrayItemClick: X}>();
</script>
これ以外にも方法がいくつかある、$$Generic
というTSのutility typeのようなものがあるが、どれもスックリしない印象だた。
- sveltekitには
svelte-kit sync
というコマンドラインがあり$types
からインポートできる型をcodegenで生成をしてくれる。 -
svelte-check
というコマンドラインで型チェックを行うことができる。
VSCode
-
公式にSvelte for VS Codeという拡張機能用意されている
- 基本的にVueと同じくこの 拡張機能にかなり頼っている感じがする、language-serverが立ち上がって色々とやってくれみたいだが、自分の環境ではsnippet使ったら、language-server再起動しないとファイルのシンボリックが見つからず、補完が出てくれない。
-
フォーマットがおかしいときがある。👇のように変なところで改行いれてフォーマットするときがある、そういうときは手で治す必要がある。
その他
-
プライベートコンポーネントが定義できない
SFCのコンセプトなので、reactのように一つのファイル内に子コンポーネントを定義して、それをそのファ. イルで使い回すということができない。
👇のようなことができない。
const Component = (props) => {
const iconMarkup = iconType === 'default' && props.icon && (
<Icon size='sm' icon={props.icon} />
);
const titleMarkup = title && (
<TitleTag>
{title}
</TitleTag>
);
const descriptionMarkup = description && (
<p>
{description}
</p>
);
const actionMarkup = primaryActionContent && (
<Button>
{primaryActionContent}
</Button>
);
return (
<div id={id}>
{iconMarkup}
<div>
<div>
{titleMarkup}
{descriptionMarkup}
</div>
{actionMarkup}
</div>
</div>
);
色々と議論されているようだが、Svelteはシンプルであるべきという理由から却下されている。
でも、svelte5で解決される模様、スニペット構文のドキュメントが公開されている
ちょっと読んでみて現時点で思ったのは、slotとprivate componentの問題を同時に解決しようとしているが、JSから離れた独自の構文が増えると学習コストが増えてしまい、手をつけづらいフレームワークになってしまわないか心配
-
eventの型が弱い
イベントはon:event_name
といった感じで定義できるが、これの型が弱い、実際に存在しないイベントを書いてもコンパイルが通ってしまう。
早期returnが書けない
reactの場合👇
const App = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if(!mounted) {
return null;
}
return <div>app</div>
}
これがsvelteの場合だと、templateの中が順序に描画されてしまうため、早期returnはできない。
slotがReactのchildrenほど柔軟じゃない
以下のようなReactコンポーネントをSvelteで完全再現できない。
import { FC, Fragment, PropsWithChildren } from 'react';
const VStack: FC<PropsWithChildren> = ({children}) => {
const childrens = Array.isArray(children) ? children : [children];
return <div style={{display: 'flex', flexDirection: 'column'}}>
{childrens.map((child, i) => <Fragment>
{i !== 0 && <div style={{height: "6px"}} />}
{child}
</Fragment>)}
</div>
}
export const App: FC<{ name: string }> = ({ name }) => {
return (
<VStack>
<span>item1</span>
<span>item2</span>
</VStack>
);
};
SSRを見捨てて、svelteのactionAPIをでゴニョゴニョすれば、できそうな気がして、書いてみたら、条件で表示非表示になるコンポーネントの場合、描画位置がずれる問題がおきた。
<script>
const Div = () => {
const div = document.createElement('div');
div.style.height = "4px";
return div;
}
const vstack = (node) => {
const insertSpacer = () => {
const childNodes = [];
node.childNodes.forEach((childNode) => {
childNodes.push(childNode);
!childNode.isEqualNode(node.lastChild) && childNodes.push(Div());
node.removeChild(childNode);
});
node.append(...childNodes);
};
insertSpacer();
return {
update: () => insertSpacer()
}
}
</script>
<div use:vstack>
<slot />
</div>
そして、👇のように使うと
<script>
import VStack from './lib/VStack.svelte';
let toggle = false;
</script>
<button on:click={() => {toggle = !toggle}} >toggle {toggle}</button>
<VStack>
<p>item1</p>
<p>item2</p>
{#if toggle}
<p>toggle item</p>
{/if}
<p>item3</p>
</VStack>
JSDocの書き方がイマイチ
Reactの場合はコンポーネントはただの関数なので、JSのままドキュメントを書くことができる
/**
* component
* @param {object} props - props
* @param {ReactNode} props.children - children
* @param {string} props.otherProp - other prop
* @returns {JSX.Element}
*/
function Component({children, otherProp}) {
return <div />;
}
VSCodeで👇
これが、Svelteの場合は書き方がちょっと違っていて、まず構文上、一箇所にまとめることができない
一応公式には例が書かれている
先ほどのReactのコンポーネントにsvelteでドキュメントを書くと恐らく👇のようになる
<script lang="ts">
/** other prop */
export let otherProp: string | undefined = undefined;
</script>
<!--
@component Component
@param {string|undefined} otherProp - other prop
-->
<div>{otherProp}</div>
見ての通りReactのような型情報がが表示されないのと、テンプレートの方にJSのドキュメントを記述するのはなんか違う気がする、HTMLのコメントに見えてしまうような。。。
一応他の@deprecated
,@example
といったJSDocのコメントは問題なく書くことができる。
v4でモヤモヤする構文
まだeachするときしかあまり使い道を見出してないが、slotにpropsを渡してそれをループ内で使える構文が一回slotを挟むせいで頭が混乱する、、、、コード書くと👇のようになる。
<script>
import Each from "./Each.svelte"
let items = ["a", "b", "c"]
</script>
<Each {items} let:item let:index>
<div>{item} {index}</div>
</Each>
<script>
export let items
</script>
{#each items as item, index}
<slot {item} {index} />
{/each}
引数をslotに渡しているのに、Eachでlet定義するのもなんか違う気がするような、、、、
テスト
結論から言うと、とてもイケてないと思った、というよりまだまだエコシステムの成長しきってない感じだった(当たり前なのかもしれないが)
reactの場合はhookが存在するので、ロジック部分とviewの部分をファイルわけし、hooksのロジックだけjestとreact-hooks-testing-libirary
でテストを行うことでとても快適にテストライフを過ごすことができる。
svelteにも一応@testing-libirary/svelte
があるが、これが、esmしか対応していなくて、jestとセットアップするだけに、たくさんの準備が必要。
どちらかというと、@testing-libirary/svelte
がesmしか対応してないのが、cjs使うJestやsvelte場合物事を複雑にしてしまっているきがする。
そもそも、jestはまだESMに完全対応しておらず、不具合が出た場合にどうしようもない気がする。
じゃvitest使うかというと、まだそこまで熟成してないライブラリーの気がするので、無難にjest使っておきたい気持ちがあるもあるのと、svelte-kit使ったフルスタックで開発している場合は、サーバーコードのテストとクライアントコードのテストをjestとvitestで行うのもまた色々とやることが増えてしまう。
jestにsvelteのものが混ざるとcjsで怒られるので、複雑なロジックがある画面をreduxを使って書くことにした
reduxにすることで得られメリット
- 今後別のフレームワークに移行したくなったときにもやりやすい
- 補修性が良くなる
デメリット
- 記述量が多い
- svelteで動かすには色々と書かないといけない
実際のざっくりしたコード貼っておくと👇のような感じ
import { configureStore, createSlice } from '@reduxjs/toolkit';
export interface MenuFormStore {
name: string;
}
const initialState: MenuFormStore = {
name: '',
};
const menuFormSlice = createSlice({
name: 'menuForm',
initialState,
reducers: {
setName: (state, action: { payload: string }) => {
state.name = action.payload;
}
}
});
const MenuFormActions = menuFormSlice.actions;
const menuFormStoreRedux = configureStore({
reducer: menuFormSlice.reducer
});
const MenuFormDispatch = menuFormStoreRedux.dispatch;
export const MenuFormReduxStoreModule = {
initialState,
Actions: MenuFormActions,
Store: menuFormStoreRedux,
Dispatch: MenuFormDispatch
} as const;
このファイルはsvelte関係ないので、そのままjestでユニットテストが書ける。
ただ、これだけではsvelte上でreactiveにならないので、👇のようにsvelte/store
からderived
を使い読み込み専用のストアを別ファイルで定義する
import { onMount } from 'svelte';
import { writable, derived } from 'svelte/store';
import { MenuFormReduxStoreModule } from './MenuForm.redux';
const state = writable(MenuFormReduxStoreModule.initialState);
export const getMenuFormStore = () => {
onMount(() =>
MenuFormReduxStoreModule.Store.subscribe(() => {
state.update(prev => ({ ...prev, ...MenuFormReduxStoreModule.Store.getState() }));
})
);
return derived(state, $state => $state);
};
svelte内で使うと👇
<script lang="ts">
import { getMenuFormStore } from './Store';
import { MenuFormReduxStoreModule } from './Store.redux';
$: menuFormStore = getMenuFormStore();
const { Actions, DIspatch } = MenuFormReduxStoreModule;
</script>
<div>
{$menuFormStore.name}
<input on:input={e => DIspatch(Actions.setName(e.currentTarget.value))}/>
</div>
data flowに気をつけないといけない
reactの場合はParent -> Children -> GrandChildren といった流れで上から下にデータがわたっていくことが多い、ほとんどの場合はprop drillingを使いデータを親から受け渡し、そのコンポーネントの中で閉じ込めて使う、子から親の状態を更新したい場合はpropsでわたってきた関数を呼び返して親で更新を行い自身を再レンダリングをしたりする、子から直接親の状態を更新することは基本的にできないし、やったとしても力技になるケースが多い
できない例👇
const Parent = () => {
const [count, setCount] = useState(0);
return <Child count={count} />
}
const Child = ({count}) => {
const increment =() => {
count += count + 1;
}
return (
<>
<p>{count}</p>
<button onClick={increment}>+</button>
</>
)
}
できる例👇
const Parent = () => {
const [count, setCount] = useState(0);
const increment =() => {
count += count + 1;
}
return <Child count={count} increment={increment} />
}
const Child = ({count, increment}) => {
return (
<>
<p>{count}</p>
<button onClick={increment}>+</button>
</>
)
}
ただsvelteの場合は書き方一つで話が変わってくる
<script>
// Parent
let count = 0;
</script>
<Child count={count} />
<!--
以下のように省略して書くことができる
<Child {count} />
-->
<script>
export let count;
const increment = () => {
count = count + 1;
}
</script>
<p>{count}</p>
<button on:click={increment}>+</button>
この場合はreactと同じく親の状態が子からは更新できない。
ただ以下のように書くとそれが可能になってしまう。
<script>
// Parent
let count = 0;
</script>
- <Child count={count} />
- <!--
- 以下のように省略して書くことができる
- <Child {count} />
- -->
+ <Child bind:count={count} />
+ <!--
+ 以下のように省略して書くことができる
+ <Child bind:count />
+ -->
もちろんいいこともあるのだが、気をつけないと、データーフローがグチャグチャになり依存関係がよくわからない状況が生まれる。
オブジェクトにまとめたコンポーネントの補完が出ない
写真みたほうがわかりやすいと思う
👇こういうふうに定義し
import ImagesViewer from './ImagesViewer.svelte';
import ProductPageRoot from './ProductPageRoot.svelte';
const ProductPage = {
Root: ProductPageRoot,
ImageViewer: ImagesViewer
} as const;
export default ProductPage;
👇こういうふうにobjectとして扱う時は型補完が出る
👇こういうふうにコンポーネントとして書くとでない
context がreactiveではない
svelteではglobal stateを持つには以下の2種類の方法がある
contextの場合
contextはユニークキーにデータをセットで持つイメージだが👇
<script lang="ts">
import { createContext } from 'svelte';
createContext('user', {
name: "test-user"
});
</script>
以下の理由から、扱いづらいと感じた
- デフォルトでtype safeではない
- どこでsetされているかが分かりづらい、データの流れを追いづらい
- writableとセットで使わないと、reactiveではない
もちろんこれらの問題は自前のラッパーを書くことで解決できるが、その手間をかけたくない。
writableの場合
そのままcontext同様tsファイルで定義することが可能
import { writable } from 'svelte/store';
export type User = {
name: string;
}
export const user = writable<User>({
name: "test-user"
});
ご覧の通りデフォルトでtypeが非常に書きやすいので、ボイラープレートが少ない。
ただ、弱点はどこからでもアクセスできるてしまうのとデータのスコープがないところ。
readonly
を使うことでreadonlyにできる
import { writable, derive } from 'svelte/store';
export type User = {
name: string;
}
export const user = writable<User>({
name: "test-user"
});
export const readOnlyUser = readonly(user)
結論、reactのContextAPIのような使い方をすべきではないのかもしれない
一応自分なりに考えたベストプラクティスを考えてみた
- UIだけの変化や操作をメインとするコンポーネントを作るときは、contextとwritableをセットで使うと便利な印象、prop drillingを避けられるし、スコープをしっかりとじ込むことができる。
アプリに関わるグローバルステート等は、なるべくwritableを使ったほうがコードが少なく済むのと、データーの流れも追いやすいといった印象。