Vue.jsで Web Components！Todo の作成で学ぶ Web Components の作り方

## はじめに
今まで静的サイトのフロントは jQuery を使ってきていたけど、いい加減開発するのもメンテするのも辛くなってきたし、
可能だったらメンテナンス性を上げるためにも Vue.js を使って開発できないかな〜、と思っていたら、なんと一年も前に公式から Web Components を実現するためのライブラリが発表されていることを確認。

- [vuejs/vue-web-component-wrapper: Wrap a Vue component as a web component / custom element.](https://github.com/vuejs/vue-web-component-wrapper/releases)

しかもしかも、vue-cliの公式ページにも Web components 向けにビルドができることを匂わせるページを発見。
- [Build Targets | Vue CLI 3](https://cli.vuejs.org/guide/build-targets.html#web-component)

これは Vue.js で Web components をやるっきゃない！と思い実装してみることにしました。

## 環境構築
環境構築にはvue-cliの3系を使用します。
また、今回の開発方針として、３系でデフォルトとなる TypeScript の流れを汲みつつ、開発速度を保つために以下の構成を採用したいと思います。

- TypeScript で書けるようにする
- css は Stylus, テンプレートは pug で記述できるようにする
- vue-property-decoratorを使用し、クラススタイルでのコンポーネントを記述できるように

また、本題は Web Components ですが、TypeScript + クラススタイル (+ pug and stylus) のサンプルとしても参考にして頂けるようにコードを作成していこうと思います！

では早速vue-cliをインストールしていきましょう。

```shell
$ npm i -g @vue/cli
```

なお、2系など古いvue-cliが既に入っている方は、既存のものを削除し3系を新たにインストールし直しましょう。
今回は3系で新たに追加された機能をばんばん使って参ります。

```shell:2.xなど古いバージョンのvue-cliが入っていた場合 
# 古いvue-cliを削除し、新しいものを入れ直す
$ npm uninstall -g vue-cli
$ npm install -g @vue/cli
```

そして、適当なディレクトリ内に新たな Vue.js のプロジェクトを作成します。

```shell
# プロジェクト名は適宜変更して良いです
$ vue create vue-webcomponents-sample

# こんな質問を聞かれると思います
Vue CLI v3.4.1
? Please pick a preset: (Use arrow keys)
  default (babel, eslint)
❯ Manually select features
```

ここで注意なのですが、今回は**デフォルトのプリセットを使用せず**マニュアルで指定するようにします。
最初の質問で``Manually select features``を選択した後、概ね以下のようになるように設定をしましょう。

<img width="820" alt="1____D_vue__node_.png" src="https://qiita-image-store.s3.amazonaws.com/0/119731/09fd277e-d609-2d93-8169-4ef95f79a5b3.png">

これで TypeScript + Stylus を使ってクラススタイルでコンポーネントを書けるようになりました！
ここまでの構築をvue-cliだけで完結できちゃうのはすごいですよね。

あとは pug が使えるようにすれば環境構築は完了です。

```shell
$ cd vue-webcomponents-sample
$  yarn add -D pug pug-loader pug-plain-loader
```

では、以下のコマンドを叩いてローカルサーバが立ち上がり、http://localhost:8080/ にアクセスして以下の画面が見られるかどうか確認しましょう！

```shell
$ cd vue-webcomponents-sample
$ yarn serve
```

<img width="533" alt="vue-webvomponents-sample.png" src="https://qiita-image-store.s3.amazonaws.com/0/119731/0b9df57d-98d7-efe4-f959-56f1967c821c.png">

これで環境構築は完了です！:tada:

## 実装
今回はお試しということで、Vue.jsの公式にある[TodoMVCの例](https://jp.vuejs.org/v2/examples/todomvc.html)に少しアレンジを加えたものを Web Components にしていこうと思います。

コンポーネントの作成手順もこれから載せていきますが、全てのソースコードは以下のgithubリポジトリにて公開しております。
本記事中では省いたところも全て閲覧できますので、ぜひ一度ご高覧下さい。
[sugoikondo/vue-webcomponents-sample](https://github.com/sugoikondo/vue-webcomponents-sample)

### 0.前準備
まずsrc ディレクトリ内に以下のような構成でファイルを生成します。

```shell
src/
├── App.vue
├── assets
├── components
│   ├── TodoForm.vue # 新規Todo追加用のフォーム
│   └── TodoItem.vue # Todoリストのアイテム
├── main.ts
├── services
│   └── localStorage.ts # todoの保存用
├── shims-tsx.d.ts
├── shims-vue.d.ts
├── types
│   └── todo.ts # Todoの型定義
└── views
    └── Todo.vue # Todoコンポーネント本体。これが Web Components になるイメージ
```
`views`というディレクトリ名は個人的に納得がいっていませんが、一旦この名称で進めていきます。
いわゆる Web components として使用する単位のものを`views`に、views が使用する部品類を`components`に格納していくイメージです。

### 1.ストレージと型定義の準備
最初にtodoの型定義ファイルの準備と、todoの保存先を作っていきましょう。
無くても問題はありませんが、todo一つ一つにユニークなIDを割り振るために今回は外部ライブラリの[shortid](https://github.com/dylang/shortid)を使用したいと思います。

```shell
$ yarn install shortid
```

そして該当ファイルを以下のようにします。

#### types/todo.ts
```typescript:types/todo.ts
import shortid from 'shortid'

export class Todo {
  id:    string
  title: string  = ''
  done:  boolean = false

  constructor(title: string, done: boolean = false) {
    this.id    = shortid.generate()
    this.title = title
    this.done  = done
  }
}
```

#### components/TodoForm.vue
```typescript:services/localStorage.ts
import { Todo } from '@/types/todo'

const STORAGE_KEY = 'webcomponent-todos-localstorage'

export const todoStorage = {
  fetch: () =>  {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
  },
  save: (todos: Todo[]) => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
  }
}
```

### 2. TodoItemとTodoFormコンポーネントの作成
次にtodo一つ一つを表すTodoItemコンポーネントと、todoを新規追加するTodoFormコンポーネントを作成します。

#### components/TodoItem.vue
```typescript:components/TodoItem.vue
<template lang="pug">
  .todo-item(@mouseover="hovered = true" @mouseleave="hovered = false")
    input.toggle(type="checkbox" v-model="todo.done")
    label.title(v-if="!editorActive" :class="{ done: todo.done }" @dblclick="editorActive = true")
      | {{ todo.title }}
    input(v-if="editorActive" ref="editor" type="text" v-model="todo.title" @blur="editorActive = false")
    button.destroy(:class="{ active: hovered }" @click="removeSelf") ×
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator'
import { Todo } from '@/types/todo'

@Component
export default class TodoItem extends Vue {
  $refs!: { editor: HTMLFormElement }

  @Prop({ required: true }) todo!: Todo
  private hovered:      boolean = false
  private editorActive: boolean = false

  private removeSelf(): void {
    this.$emit('remove', this.todo.id)
  }

  @Watch('editorActive') private setFocusToInput(value: boolean): void {
    if (value) this.$nextTick().then(() => this.$refs.editor.focus())
  }
}
</script>

<style lang="stylus" scoped>
.todo-item
  display       flex
  align-items   center
  font-size     24px
  min-height    2.6em
  border-bottom 1px solid #ededed

  .toggle
    flex       0 0 auto
    text-align center
    width      40px
    height     40px
    margin     0 8px
    border     none
  label, input
    flex        1 0 auto
    box-sizing  border-box
    text-align  left
    word-break  break-all
    display     block
    line-height 1.2em
    transition  color 0.4s
    color       #4d4d4d
    border      none
    &.done
      text-decoration line-through
      color           #d9d9d9

  button.destroy
    flex       0 0 auto
    width      40px
    height     40px
    font-size  30px
    transition color .1s ease-out
    border     none
    appearance none
    color            transparent
    background-color transparent
    &.active
      color #cc9a9a
</style>
```

#### components/TodoForm.vue
```vue:components/TodoForm.vue
<template lang="pug">
  .todo-form
    input(type="text" autofocus v-model="formText" @keyup.enter="submitNewTodo()" placeholder="今日は何をしますか？")
</template>

<script lang="ts">
import { Component, Vue, Emit } from 'vue-property-decorator'
import { Todo } from '@/types/todo'

@Component
export default class TodoForm extends Vue {
  private formText: string = ''

  submitNewTodo(): Todo | void {
    const value = this.formText.length && this.formText.trim()
    if (value) {
      this.formText = ''
      this.$emit('submit', new Todo(value, false))
    }
  }
}
</script>

<style lang="stylus" scoped>
.todo-form
  input
    padding     16px 16px 16px 54px
    border      none
    background  rgba(0, 0, 0, 0.003)
    box-shadow  inset 0 -2px 1px rgba(0,0,0,0.03)
    position    relative
    margin      0
    width       100%
    font-size   24px
    font-family inherit
    font-weight inherit
    line-height 1.4em
    box-sizing  border-box
    -webkit-font-smoothing antialiased
</style>
```

### 3.コンポーネントの結合
では最後に、Todo.vueで今まで作成したコンポーネントをつなぎこんでいきましょう。

#### views/Todo.vue
```vue:views/Todo.vue
<template lang="pug">
  .todo
    TodoForm(@submit="appendNewTodo($event)")
    .todo-list
      TodoItem(v-for="todo in todos", :todo="todo", :key="todo.id", @remove="removeTodo($event)")
</template>

<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator'
import { Todo as TodoType } from '@/types/todo'
import { todoStorage } from '@/services/localStorage'
import TodoForm from '@/components/TodoForm.vue'
import TodoItem from '@/components/TodoItem.vue'

@Component({
  components: { TodoForm, TodoItem }
})
export default class Todo extends Vue {
  todos: TodoType[] = []

  created(): void {
   this.todos = todoStorage.fetch()
  }

  @Watch('todos') autosaveTodosOnChange() {
    todoStorage.save(this.todos)
  }

  appendNewTodo(todo: TodoType): void {
    this.todos = this.todos.concat(todo)
  }

  removeTodo(id: string) : void {
    this.todos = this.todos.filter(todo => todo.id !== id)
  }
}
</script>

<style lang="stylus" scoped>
.todo
  background #fff
  margin     130px 0 40px 0
  position   relative
  box-shadow 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1)
</style>

```

## Web Components の生成
長くなりましたが、**ここからが本題です。**早速作成したTodoコンポーネントを Web Components として利用できるようにしましょう。

### vue-cli でビルド
プロジェクト内のルートディレクトリにて、以下のコマンドを叩きます。

```shell
$ yarn build --target wc-async --name wc-todo 'src/views/Todo.vue'
```
各オプションについて軽くまとめると、

`--target wc-async`
ビルド対象を指定します。
`wc`でも良いですが、`wc-async`を指定することによって動的インポートが使えるようになるので、基本的にこちらで問題ないと思います。
今回のようにビルドするファイルが一つの場合は、`wc`を指定した時と同じ出力が得られます。

`--name wc-todo`
コンポーネントにつける名前を指定します。
これがHTML内で使用する際の名前です。`<wc-todo>`のように使うことが可能になります。

注意として、今回のように生成するコンポーネントが一つの場合は、**必ずハイフンを含めなくてはいけません**。
また、生成するコンポーネントが複数の場合には、`--name`で指定した名前が**コンポーネントの名前のプレフィックスとして**つくようになります。

```
# 複数コンポーネントが存在する状態で叩くと、
$ yarn build --target wc-async --name prefix 'src/views/*.vue'

# HTML内でこのように呼べるようになります
<prefix-todo>
```

`'src/views/Todo.vue'`
Web Components として生成するファイルを指定します。
もし複数のコンポーネントを生成する場合には、`'src/views/*.vue'`のようにglobで指定します。

ビルドが成功すると、`dist`配下にjsファイルが生成されます。
![1____D_v_vue-webcomponents-sample__fish_.png](https://qiita-image-store.s3.amazonaws.com/0/119731/129358ec-ad3e-7204-027a-7e18665dd447.png)

### 生成したコンポーネントの使用
では早速生成したコンポーネントを確認して見ましょう！
プロジェクトのルートディレクトリに`sample.html`のようなhtmlファイルを作成し、以下のようにします。

```html:sample.html
<html lang="ja">
  <head>
    <meta charset="utf-8"/>
  </head>
  <body>
    <script src="https://unpkg.com/vue"></script>
    <script src="dist/wc-todo.min.js"></script>

    <div style="width: 80vw; margin: 0 auto;">
      <!-- この wc-todo が Web Components です -->
      <wc-todo></wc-todo>
    </div>
  </body>
</html>
```

そしてブラウザにて表示させ、以下のように表示されていればOKです！

![sample_html.png](https://qiita-image-store.s3.amazonaws.com/0/119731/01176c6d-eb08-4c81-e4fa-0cec720a1451.png)


## さいごに
再度の掲載になりますが、今回作成したソースコードは以下のリポジトリに格納してありますので、よろしければご高覧ください。
Web Components だけでなく、Vue.js のソースコードとしても皆様の参考になるようなコードを目指したので、少しでも参考にして頂けると幸いです。
[sugoikondo/vue-webcomponents-sample](https://github.com/sugoikondo/vue-webcomponents-sample)

また、もし記事内で何か良くないところや問題点などございましたら、遠慮なくお申し付け下さい。
すぐに修正させて頂きます。

最後になりますが、ここまでお読み頂き本当にありがとうございました！

## 参考資料
- [vuejs/vue-web-component-wrapper: Wrap a Vue component as a web component / custom element.](https://github.com/vuejs/vue-web-component-wrapper/releases)
- [Build Targets | Vue CLI](https://cli.vuejs.org/guide/build-targets.html)
- [kaorun343/vue-property-decorator: Vue.js and Property Decorator](https://github.com/kaorun343/vue-property-decorator)
- [TodoMVC — Vue.js](https://vuejs.org/v2/examples/todomvc.html)