LoginSignup
11
12

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-03-17

はじめに

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

しかもしかも、vue-cliの公式ページにも Web components 向けにビルドができることを匂わせるページを発見。
- Build Targets | Vue CLI 3

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

環境構築

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

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

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

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

$ npm i -g @vue/cli

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

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

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

# プロジェクト名は適宜変更して良いです
$ 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を選択した後、概ね以下のようになるように設定をしましょう。

1____D_vue__node_.png

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

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

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

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

$ cd vue-webcomponents-sample
$ yarn serve

vue-webvomponents-sample.png

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

実装

今回はお試しということで、Vue.jsの公式にあるTodoMVCの例に少しアレンジを加えたものを Web Components にしていこうと思います。

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

0.前準備

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

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を使用したいと思います。

$ yarn install shortid

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

types/todo.ts

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

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

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

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

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 でビルド

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

$ 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

生成したコンポーネントの使用

では早速生成したコンポーネントを確認して見ましょう!
プロジェクトのルートディレクトリにsample.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

さいごに

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

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

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

参考資料

11
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
12