はじめに
今まで静的サイトのフロントは 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系で新たに追加された機能をばんばん使って参ります。
# 古い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
を選択した後、概ね以下のようになるように設定をしましょう。
これで 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.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
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
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
<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
<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
<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ファイルが生成されます。
生成したコンポーネントの使用
では早速生成したコンポーネントを確認して見ましょう!
プロジェクトのルートディレクトリにsample.html
のような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です!
さいごに
再度の掲載になりますが、今回作成したソースコードは以下のリポジトリに格納してありますので、よろしければご高覧ください。
Web Components だけでなく、Vue.js のソースコードとしても皆様の参考になるようなコードを目指したので、少しでも参考にして頂けると幸いです。
sugoikondo/vue-webcomponents-sample
また、もし記事内で何か良くないところや問題点などございましたら、遠慮なくお申し付け下さい。
すぐに修正させて頂きます。
最後になりますが、ここまでお読み頂き本当にありがとうございました!