Vue CLI v3 を利用して、あらゆるサイトに埋め込める Web Component ベースの Qiita スライドビューアーを開発した話

Vue.js Advent Calendar 2018 の管理担当、言い出しっぺだから全部参加をポリシーで進めている potato4d です。

今年は以下の2つを公開しており、ことしは守りのコードを中心に紹介しました。

- [Vuexによる状態管理を含む最高に快適な Vue.js + TypeScript の開発環境を目指す話](https://qiita.com/potato4d/items/c9c0c8e674f20c85948a)
- [JavaScript ベースの Vue.js プロジェクトのデータ構造を JSON Schema を使って守る](https://qiita.com/potato4d/items/3e40ae9f69fb9e012769)

ですが、ここは #1 アドベントカレンダー。せっかくなので、もう少し夢のある話をしたいと思います。

## Kamishibai Viewer

[![Image from Gyazo](https://i.gyazo.com/447d26f36fd0d346464be0901ee51ddc.gif)](https://gyazo.com/447d26f36fd0d346464be0901ee51ddc)

https://github.com/potato4d/kamishibai-viewer

というわけで早速タイトルの成果物の紹介からします。
今回は、 Qiita スライドモードを Web サイトに埋め込むための Web Component ライブラリ「Kamishibai Viewer」を開発しました。まだまだ開発中なので、 Star もらえたりバグ報告があったりすると励みになります (⸝⸝╹ﾜ╹)

webcomponents.org に登録する予定なので URL が変わるかもしれませんが、今は GitHub Pages から JavaScript を落としてきて利用できます。

こんな感じでバンドルを読み込んで

```html
<script src="https://unpkg.com/vue"></script>
<script src="https://potato4d.github.io/kamishibai/dist/0.1.0/kamishibai.min.js"></script>
```

タグを指定するだけで自動で Qiita API から取ってきた本文を気合でパースして表示してくれます :sparkles: 
Ruby 製なので移植が不可能な Qiita Markdown Parser を完全に無視して作っているので、再現度はベストエフォートです。

```html
<kamishibai-viewer data-item-id="4ff5873776992f0511d6"></kamishibai-viewer>
```

CodePen で実際に表示すると、こんな感じ。

<p data-height="265" data-theme-id="0" data-slug-hash="jXmBPd" data-default-tab="html,result" data-user="potato4d" data-pen-title="jXmBPd" class="codepen">See the Pen <a href="https://codepen.io/potato4d/pen/jXmBPd/">jXmBPd</a> by Potato4d (<a href="https://codepen.io/potato4d">@potato4d</a>) on <a href="https://codepen.io">CodePen</a>.</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

CodePen 連携、いいですよね (๑•̀ㅂ•́)و✧

こんな感じで雑に使えるので、使ってみてください！Starください！

## Kamishibai Viewer の開発経緯

さて、ここからは突っ込んだ話をしていきます。

技術の紹介の前に、 Kamishibai Viewer の開発の経緯からご紹介します。実は、 Qiita のスライドモードのコンポーネントは、 Qiita 運営の Increments のネームスペース上で、オープンソースで公開されています。

<img width="998" alt="Screen Shot 2018-12-23 at 22.15.25.png" src="https://qiita-image-store.s3.amazonaws.com/0/75633/44bd2ed6-1502-3ac8-5b05-b97108e7a41b.png">

https://github.com/increments/qiita-slide-mode

ある程度自由に使えるライセンスでの提供となっており、利用にあたっての制限はほとんどない状態です。しかし、 qiita-slide-mode には、一つ問題がありました。

それは、 Qiita のために作られたライブラリであるため、 UI 描画に HyperApp を利用しており、再利用性に欠けているというものです。

HyperApp はミニマルな JavaScript の UI ライブラリであり、薄さゆえの汎用性を魅力としてあげています。しかし、 HyperApp はそれゆえに他のエコシステムから独立しており、かつ、未成熟です。Web Component や VanillaJS のみによる中立的な実装は勿論、 React / Vue コンポーネントでもないために、実際再利用できるかは疑問が残っていた状態と言えます。

私個人としては、ライブラリ化するのであれば、再利用性によってより多くの人に使われてこそ意義を満たすことができると思っています。

幸いにも、スタイルシートのコードもオープンソースであったこと、私自身が qiita-slide-mode ライブラリの開発に関わっており、構造をざっと把握していることから、 Vue.js によって書き換え、 Web Component で吐き出す世界を生み出すことに決めました。

これまであれば、 Web Component ベースで何かを作ることは大きな労力が必要でしたが、 Vue.js や Angular であれば、 Vue CLI v3 の Web Component ビルドや、 Angular Elements によって簡単に吐けるような時代となりました。真に特定のなにかに依存しない部品化を目指すのであれば、これらに乗っかっていきたいところです。

以前から構想自体はありながらも、着手できたのは実は昨日の夜からです。

※ 補足として、HyperApp 時代の技術的問題というより、エコシステムの発展が遅れている現状及び HyperApp 自体がコアとしてエコシステムによる再利用との親和性が高くない状態の問題だと認識しています

## Kamishibai Viewer の技術構成

作るなら技術構成を考える必要があります。
今回、 必要な要件は以下でした。

- Web Components で吐き出すことができ、自分が現実的にメンテナンスできる技術
  - そのため、Polymer だけで頑張るみたいなことはしない
- 本家のスタイルシートが scss なので、 scss が import できて inline-style で読み込める
  - webpack の利用が必須

Web Components で吐き出せて、 loader のメンテナンスが辛くなく、かつ多くの人が触りやすい技術。となると、自然と Vue CLI v3 で開発するのが妥当という結論となりました。最終的には以下のようなつくりとなりました。

- Vue CLI v3
  - w/ TypeScript
  - w/ node-sass
  - w/ @vue/web-component-wrapper(これを使うと Vue.js で Web Component が吐き出せる！)

その他、開発補助のために以下を入れています。

- prettier
  - semi:false / singleQuote: true
- jest
  - まだテスト書いてないので使ってない
- axios
  - Qiita API からデータを取るため

## 開発の流れ

実際の細かなコードは今回おいておくとして、 Vue CLI v3 で Web Component を吐き出すライブラリをどう作っていくかという話をします。

### Web Component の制約について

Web Component モジュールを開発する場合ちょっとめんどくさいことがあります。具体的には、以下の環境で開発する必要があります。

1. Shadow Root が存在することによって CSS の取り扱いが違う
  - 具体的には愚直に書こうとすると開発時か本番時かどっちかでスタイルが消える
2. props の取扱がめんどくさい
  - ルートに渡すやつがつらい感じになる現象を今回はゴリ押してしまった
3. @vue/web-component-wrapper あたりが TypeScript に対応していない
  - 俺の any を受けてみろ

という環境なので、ちょっとつらい部分があります。実際に解消しつつ作業を進めていった流れをご紹介します。

### 開発時と本番時のビルドを切り替える

そもそも開発時は Web Component である必要がないので、ずっとただの Vue Component として開発をしていました。

そして完成時に Web Component に吐き出してみると、 Shadow DOM の仕様上このままじゃスタイルがあたらないという状況が判明します。また、そのあたりを対処するなら、そもそも Vue コンポーネントの import 方法を変える必要があります。

具体的には、以下のような変更が必要となります。

```diff
- import Kamishibai from './Kamishibai.vue'
+ import Kamishibai from './Kamishibai.vue?shadow'
```

そして、これを行った場合、 Web Component では無事スタイルが表示されるのですが、残念ながらローカルでの Vue コンポーネントではスタイルが効きません。勿論、 Vue Component には Shadow Root なんてないからです。

<img width="239" alt="Screen Shot 2018-12-23 at 22.45.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/75633/c0610b1a-a673-a20d-8086-61271c8d47e2.png">

これを対処する場合、 webpack の設定を気合で書き換えるなどの手法があるのですが、このあたりの仕様のために webpack.config.js 職人をやりだすと後でメンテナンスしたくなくなるのは明白です。

そのため、今回はいっそコードを分けてしまうことにしました。具体的には、以下のような形です。

```src/development.ts
import Vue from 'vue'
import DevApp from './DevApp.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(DevApp)
}).$mount('#app')
```

と

```src/main.ts
import Vue from 'vue'
const { default: wrap } = require('@vue/web-component-wrapper')
const { default: Kamishibai } = require('./Kamishibai.vue?shadow')

window.customElements.define('kamishibai-viewer', wrap(Vue, Kamishibai))
```

といった形です。どうやっても `@vue/web-component-wrapper` には型がつかないし、 `?shadow` なんて記法を tsc は許してくれないため、愚直に require しています。`@vue/web-component-wrapper` を使っている人がいなさすぎてここの any については妥協しましたが、気が向いたら改善したいと思います。

逃げの解決方法に見えますが、 DevApp.vue をテストデータの流し込み兼公式サイトに流用できるので、私は意外とこういった事情がなくてもこの構成にすることはよくあります。

二つに分けたら、 NPM scripts も更新しておきます。こんな感じ。

```json:package.json
{
"scripts": {
    "serve": "vue-cli-service serve src/development.ts",
    "serve:prod": "vue-cli-service serve",
    "build": "cross-env BUILD_TYPE=lib VUE_CLI_CSS_SHADOW_MODE=true vue-cli-service build --dest dist/dist/0.1.0/ --target wc --name kamishibai-viewer ./src/main.ts",
    "build:web": "cross-env BUILD_TYPE=app vue-cli-service build --dest dist ./src/development.ts",
    "test:unit": "vue-cli-service test:unit",
    "format": "prettier './src/**/*.{ts,vue}' --write"
  }
}
```

build で --target wc な Web Component を吐き出して、 build:web で Web サイト(DevApp)を吐き出します。

これで平和になりました。

### props の取扱がめんどくさい

「これでビルドも完了したので後は公開するだけ！」というわけでもありません。 Web Component にした上で、いつもの感覚で `<kamishibai-viewer itemid="abc"></kamishibai-viewer>` としても、 invalid な属性なのでうまく動いてくれません。

本来、 Custom Element には Custom Attribute をアタッチできるはずなのですが、どうやらデフォルトでは対応していないみたいです。

どうにかして対応したいところでしたが、今回は「鉄は熱いうちに打て」を優先して、 `data-item-id` の形式で、 data 属性で解決しました。

data 属性であれば、適切に受け渡すことが可能です。

この点については、今後調査した上で、解決方法がわかれば追記します。

### 開発完了

このあたりの面倒な諸問題を解決すると Web Components で吐き出せるようになります。誰もやってないのでライブラリのレポジトリと Vue CLI v3 のドキュメントを読みつつ、以下の記事を参考にして Web Components 向けの設定諸々はようやく解決しました。

- Create and Publish Web Components With Vue CLI 3
  - https://vuejsdevelopers.com/2018/05/21/vue-js-web-component/

## Vue.js + Web Component について

一応 Hello, World くらいは触っていましたが、実際に実用的な範囲のものを作ろうとすると割とよくわからないハマりが多くてつらいというのが率直な感想でした。

が、一度やってしまえばあとは Vue.js の上で開発できて Web Component の恩恵を受けることができてウレシイ！というところなので、これからも引き続き調査していきたいと思います。

### Kamishibai Viewer について

私自身が利用するため、サンプルとして作ったのがはじまりではありますが、メンテナンスを継続したいと思います。

そもそも突貫工事で Web Component として再利用するための提案の一つでしかない状態なので、今できてない機能をこの記事の公開後にひとまず　Issue 化して潰していきます。

もし「イベントで Qiita のスライドモードを使っている人がいたけど開催後記録に埋め込めなくてつらい！」とお嘆きのかたがいらっしゃいました、ぜひぜひ使ってみてください。

ひとまず、直近での実装予定項目は以下となります。

- クリック領域の改善
  - 左半分で前のスライドに
  - 右半分で次のスライドに
  - けど文字は選択できるように
- 最後のページまで行ったときにオリジナルの記事への導線の追加
  - Kamishibai Viewer の責務かと言われると怪しいがやる
- webcomponents.org への登録
  - やってみたい
- スライドのプログレスバークリックでの該当位置への移動
  - ひとまず埋め込むことができる体験だけ優先したけど普通にほしい

最後に、最近技術情報の発信や OSS 活動にもっと注力できるようにするために Patreon を開設しました。もし支援しても良いよ！って人は支援してもらえると喜びます。

https://www.patreon.com/potato4d