Open11

Svelteをプロダクト開発に使って、思ったあれこれ。

qaynamqaynam

環境構築

Reactより大差ない。

  • sveltekit使えば一瞬
  • vite-svelteも可能。
  • eslint
    • そのまま動かないものが結構ある。
  • prettierも動く
  • css関連の拡張機能もひと通り動くきがする。

エコシステム

typescript

  • 型縛りはReactほどではないが、それなりにできると思う、ただtsが使えるのは<script lang="ts">内のみ、template側でtsコードを書けない。 👇のようなエラーがでて、コンパイルが通らない。
    svelte-template-typescript-notworking

  • 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再起動しないとファイルのシンボリックが見つからず、補完が出てくれない。
  • フォーマットがおかしいときがある。👇のように変なところで改行いれてフォーマットするときがある、そういうときは手で治す必要がある。
    formatter-suck

qaynamqaynam

その他

  1. プライベートコンポーネントが定義できない
    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はシンプルであるべきという理由から却下されている。
https://github.com/sveltejs/svelte/issues/2940

でも、svelte5で解決される模様、スニペット構文のドキュメントが公開されている
https://svelte-5-preview.vercel.app/docs/snippets

ちょっと読んでみて現時点で思ったのは、slotとprivate componentの問題を同時に解決しようとしているが、JSから離れた独自の構文が増えると学習コストが増えてしまい、手をつけづらいフレームワークになってしまわないか心配

  1. eventの型が弱い
    イベントはon:event_nameといった感じで定義できるが、これの型が弱い、実際に存在しないイベントを書いてもコンパイルが通ってしまう。
qaynamqaynam

早期returnが書けない

reactの場合👇

const App = () => {
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  if(!mounted) {
    return null;
  }

  return <div>app</div>

}

これがsvelteの場合だと、templateの中が順序に描画されてしまうため、早期returnはできない。

qaynamqaynam

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>

toggle-false
toggle-true

qaynamqaynam

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で👇
jsdoc-in-react

これが、Svelteの場合は書き方がちょっと違っていて、まず構文上、一箇所にまとめることができない
一応公式には例が書かれている

https://svelte.dev/docs/faq#how-do-i-document-my-components

先ほどの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>

svelte-jsdoc

見ての通りReactのような型情報がが表示されないのと、テンプレートの方にJSのドキュメントを記述するのはなんか違う気がする、HTMLのコメントに見えてしまうような。。。
一応他の@deprecated,@exampleといったJSDocのコメントは問題なく書くことができる。

qaynamqaynam

v4でモヤモヤする構文

まだeachするときしかあまり使い道を見出してないが、slotにpropsを渡してそれをループ内で使える構文が一回slotを挟むせいで頭が混乱する、、、、コード書くと👇のようになる。

App.svelte
<script>
    import Each from "./Each.svelte"
    let items = ["a", "b", "c"]
</script>

<Each {items} let:item let:index>
	<div>{item} {index}</div>
</Each>
Each.svelte
<script>
	export let items
</script>

{#each items as item, index}
  <slot {item} {index} />
{/each}

引数をslotに渡しているのに、Eachでlet定義するのもなんか違う気がするような、、、、

qaynamqaynam

テスト

結論から言うと、とてもイケてないと思った、というよりまだまだエコシステムの成長しきってない感じだった(当たり前なのかもしれないが)

reactの場合はhookが存在するので、ロジック部分とviewの部分をファイルわけし、hooksのロジックだけjestとreact-hooks-testing-libiraryでテストを行うことでとても快適にテストライフを過ごすことができる。
https://github.com/testing-library/react-hooks-testing-library

svelteにも一応@testing-libirary/svelteがあるが、これが、esmしか対応していなくて、jestとセットアップするだけに、たくさんの準備が必要。
どちらかというと、@testing-libirary/svelteがesmしか対応してないのが、cjs使うJestやsvelte場合物事を複雑にしてしまっているきがする。

https://testing-library.com/docs/svelte-testing-library/setup#jest

そもそも、jestはまだESMに完全対応しておらず、不具合が出た場合にどうしようもない気がする。
じゃvitest使うかというと、まだそこまで熟成してないライブラリーの気がするので、無難にjest使っておきたい気持ちがあるもあるのと、svelte-kit使ったフルスタックで開発している場合は、サーバーコードのテストとクライアントコードのテストをjestとvitestで行うのもまた色々とやることが増えてしまう。

qaynamqaynam

jestにsvelteのものが混ざるとcjsで怒られるので、複雑なロジックがある画面をreduxを使って書くことにした
reduxにすることで得られメリット

  • 今後別のフレームワークに移行したくなったときにもやりやすい
  • 補修性が良くなる

デメリット

  • 記述量が多い
  • svelteで動かすには色々と書かないといけない

実際のざっくりしたコード貼っておくと👇のような感じ

Store.redux.ts
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を使い読み込み専用のストアを別ファイルで定義する

Store.ts
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内で使うと👇

Menu.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>

qaynamqaynam

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 />
+ -->

もちろんいいこともあるのだが、気をつけないと、データーフローがグチャグチャになり依存関係がよくわからない状況が生まれる。

qaynamqaynam

オブジェクトにまとめたコンポーネントの補完が出ない

写真みたほうがわかりやすいと思う
👇こういうふうに定義し

import ImagesViewer from './ImagesViewer.svelte';
import ProductPageRoot from './ProductPageRoot.svelte';

const ProductPage = {
   Root: ProductPageRoot,
   ImageViewer: ImagesViewer
} as const;

export default ProductPage;

👇こういうふうにobjectとして扱う時は型補完が出る
works

👇こういうふうにコンポーネントとして書くとでない
not-working

qaynamqaynam

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を使ったほうがコードが少なく済むのと、データーの流れも追いやすいといった印象。