この記事は ユニークビジョン株式会社 Advent Calendar 2019 の 16 日目の記事です。
##Svelte?
Svelte
Svelte is a radical new approach to building user interfaces. Whereas traditional frameworks like React and Vue do the bulk of their work in the browser, Svelte shifts that work into a compile step that happens when you build your app.
Instead of using techniques like virtual DOM diffing, Svelte writes code that surgically updates the DOM when the state of your app changes.
ユニークビジョン社ではフロントエンドは主にVue.jsで開発をすることが多いので私も普段はVue.jsを使用しています。
Svelteは書き方はVueに非常に似ているのですが、フレームワークというよりもコンパイラに近い感じで、ComponentファイルにscriptやCSSを記述してコンパイルするとプレーンなjavascriptとcssが吐き出される感じです。
動作が高速でコードの記述量が少ないとの評判ですので実際にさわってみました。
##作るもの
こんな感じでgitlabのIssueを取得して、MilestoneごとにBoard風に表示するアプリを作ります。
さもドラッグ&ドロップでMilestoneを変更できそうな雰囲気ですが表示するだけです。
##とりあえず「Hello world」
公式が用意しているテンプレートを使用します。
npx degit sveltejs/template my-svelte-project
cd my-svelte-project
npm install
npm run dev
完了したらhttp://localhost:5000/
にアクセスします。
これで準備OKですね。
##Componentを作る
今回は以下のようなComponent構成にします。
- App
- board(Milestoneごと)
- card(Issueごと)
- card
- card ...
- board ...
- board(Milestoneごと)
では1番子供にあたるcard
から順に書いていきます。
###card Component ~親からプロパティを受け取る~
<script>
export let title
export let assignee
export let labels
</script>
<div class="card user-can-drag">
<div class="title">{title}</div>
<p>{ `担当者:${assignee ? assignee.name : '未定'}`}</p>
<p>{ `ラベル:${labels.join(',')}`}</p>
</div>
<style>
.card {
background: #fff;
border: 1px solid #dfdfdf;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
line-height: 16px;
list-style: none;
position: relative;
margin-bottom: 8px;
padding: 1rem;
}
.title{
font-weight: bold
}
.user-can-drag {
cursor: grab;
}
</style>
このComponentは Issue 1つ分の情報を親Component(board
)から受け取って表示します。
<script>
export let title
export let assignee
export let labels
...略
export
はプロパティにする時に使用します。上記のコードはtitle
、assignee
、labels
を受け取ることを意味します。
...略
<div class="card user-can-drag">
<div class="title">{title}</div>
<p>{ `担当者:${assignee ? assignee.name : '未定'}`}</p>
<p>{ `ラベル:${labels.join(',')}`}</p>
</div>
...略
<div class="title">{title}</div>
のように、{}
内にjavascriptの式を記述することができ、<script></script>
に記述したデータやプロパティ、関数にアクセスできます。
###board Component ~子供にプロパティを渡す~
このComponentは Milestone 1つ分のIssueの一覧を表示します。
<script>
import MyCard from './card.svelte'
export let items = []
export let title
</script>
<div class="boards-list">
<div class="board" >
<div class="board-header">
<div class="board-title">{title}</div>
</div>
<div class="board-body">
{#each items as item}
<MyCard {...item}></MyCard>
{/each}
</div>
</div>
</div>
<style>
.boards-list{
width: 400px;
display: inline-block;
height: 100%;
padding-right: 8px;
padding-left: 8px;
white-space: normal;
vertical-align: top;
}
.board {
position: relative;
height: 100%;
font-size: 14px;
background: #fafafa;
border: 1px solid #e5e5e5;
border-radius: 4px;
}
.board-header {
position: relative;
border-top: 3px solid;
border-color: red;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.board-body {
height: 90%;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
.board-title {
padding: 12px 16px;
font-size: 1.2em;
font-weight: bold;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
}
</style>
親ComponentからIssueのリストを受け取り、Issue1つにつき1つのcard
Componentを作成します。
<script>
import MyCard from './card.svelte'
export let items = []
...中略
<div class="board-body">
{#each items as item}
<MyCard {...item}></MyCard>
{/each}
</div>
...略
まず、card
Componentをimport
しています。
次に、items
をプロパティとしてexport
していますが、このようにデフォルト値を指定することもできます。
#each
はforeach
文に相当するものです。Issue一覧(items
)の要素の数だけcard
Componentを作成しています。制御構文の書き方は結構独特なので、vscodeを使用している方はSvelte 3 Snippetsなどを使用すると良いと思います。
子Component(card
)にプロパティを渡す部分は丁寧に書くと以下のようになります。子Componentのプロパティ名と、オブジェクトのキーが一致している場合に...
を使用できます。
...中略
<div class="board-body">
{#each items as item}
<MyCard title="item.title" assignee="item.assignee" labels="item.labels" ></MyCard>
{/each}
</div>
...略
##Custom store ~flex風にgitlab APIを叩く~
board
Componentは親(App
)からmilestoneの情報や、子供に渡すIssue一覧を受け取るのですが、そのためにはこれらをgitlabから取得する処理が必要です。
普段Vue.jsで開発する際にはvues.jsを使用しており、画面からActionを叩いてActionがAPIを叩き、取得した値をmutationを介してstoreにcommitする流れです。svelteにはstoreがデフォルトで組み込まれており、
- writable(読み取り、更新可能)
- readable(読み取り専用)
- derived(他のstoreの値が変わると変わる。vuexのgetterみたいな感じ)
- custom (上記の組み合わせなどでいろいろできるやつ)
があります。
今回はCustom storeを使用しました。
import { writable } from 'svelte/store'
import { fetchMilestones } from '../gitlab_api'
function createMilestoneList() {
const { subscribe, set } = writable([])
return {
subscribe,
fetch: async () => {
const response = await fetchMilestones()
set(response.filter(item => {
return item.state !== "closed"
}))
},
}
}
export const milestoneList = createMilestoneList()
createMilestoneList()
が独自のCustom storeオブジェクトを作成するコードです。
subscribe
をもたせておくことで、画面から値の変化をReactiveに検知できます。
fetch
のような好きな名前で関数を登録しておき、Componentから呼び出すことができます。上記コードではfetch
でgitlabのAPIを叩いてmilestoneの一覧を取得する処理を実行し、最後にset
を実行しています。これはmilestoneList
というstateにAPIから受け取った値をcommitする処理に相当します。
同じ要領でissueのstoreも作成します。
import { writable } from 'svelte/store'
import { fetchIssues } from '../gitlab_api'
function createIssueList() {
const { subscribe, set } = writable([])
return {
subscribe,
fetch: async () => {
const response = await fetchIssues()
set(response.filter(item => {
return item.state !== "closed"
}))
},
}
}
export const issueList = createIssueList()
App component ~storeの値を参照する~
milestoreやissueの情報を表示する子Component、それらを取得・保持するstoreの準備ができたので、最後にその橋渡しをする基底のComponentを作成します。
<script>
import MyBoard from './components/board.svelte'
import { onMount } from 'svelte'
import { milestoneList } from './store/milestones'
import { issueList } from './store/issues'
onMount(
() => {
milestoneList.fetch()
issueList.fetch()
}
)
function filterByMilestone(milestoneId, list) {
if (!milestoneId) {
return list.filter(item => {
return !item.milestone
})
}
return list.filter(item => {
return item.milestone && item.milestone.id === milestoneId
})
}
</script>
<div class="boards-list-wrapper">
<MyBoard title={'未設定'} items={filterByMilestone(null, $issueList)} ></MyBoard>
{#each $milestoneList as milestone}
<MyBoard title={milestone.title} items={filterByMilestone(milestone.id, $issueList)} ></MyBoard>
{/each}
</div>
<style lang="scss" scoped>
.boards-list-wrapper {
height: 100%;
min-height: 475px;
width: 100%;
padding-top: 25px;
padding-bottom: 25px;
padding-right: 8px;
padding-left: 8px;
overflow-x: scroll;
white-space: nowrap;
}
</style>
Componentからstoreにアクセスし、Componentが生成された時に各storeのfetchメソッドが実行されるようにします。
<script>
import MyBoard from './components/board.svelte'
import { onMount } from 'svelte'
import { milestoneList } from './store/milestones'
import { issueList } from './store/issues'
onMount(
() => {
milestoneList.fetch()
issueList.fetch()
}
)
...中略
{#each $milestoneList as milestone}
<MyBoard title={milestone.title} items={filterByMilestone(milestone.id, $issueList)} ></MyBoard>
{/each}
...略
milestones.js
、issues.js
でexportしたそれぞれのstoreをimportしています。
onMount
はComponentが生成された際に実行されるsvelteのLifecycleメソッドです。ここでfetch
を実行することで、Component生成時にAPIを叩いてそれぞれのListが作成されます。
あとはeach
部分でstoreの配列をの要素の分だけ子Component(board
)を作成して、IssueListを渡しています。
##感想
###メリット
- 確かにコード量が少なくて、Componentはかなりスッキリする
- Vue.jsに似ているのでVue.jsを書ける人は書きやすい
###デメリット
-
$
をつけ忘れる
慣れるまではdataやprop、storeをあれこれ使っていると、あれ?これは$
いるんだっけ?となる。例えばApp.svelte
の27行目の$
を忘れると子コンポーネントがひっそりと表示されなくなる。 -
デバッグが辛い
静的片付け言語のコンパイラと違い、sveltのコンパイルはそういったところはかなりザルなのでランタイムエラーが頻発する。しかしランタイムエラーの発生場所や理由がエラーログからはわからないことが多い。
##その他
蛇足ですがcustom storeから実行するgitlabのAPIを叩く処理は以下のとおりです。
const HOST = "https://gitlab.com/"
const TOKEN = "{your token}"
const PROJECT_ID = "{your project id}"
function buildPath(endPoint) {
return `${HOST}api/v4/projects/${PROJECT_ID}/${endPoint}/?private_token=${TOKEN}&per_page=100`
}
export async function fetchIssues() {
return fetchItems('issues')
}
export async function fetchMilestones() {
return fetchItems('milestones')
}
async function fetchItems(endpoint) {
const path = buildPath(endpoint)
const responseJson = await fetch(path).then((response) => response.json())
return responseJson
}