Help us understand the problem. What is going on with this article?

Vue製 の WebComponent でレガシーコードに立ち向かいたい

説明に使ったリポジトリ
https://github.com/sterashima78/vue-webcomponent-example

はじめに

レガシーなフロントエンドを相手にすることを考える。
レガシーという言葉自体はいろいろなところで使われている上に、何を指しているかがまちまちだが、
ここでは以下のような特徴を持ったフロントエンドと考えることにする。

  • バックエンドでHTML組み立ててレンダリングされている
  • バックエンドのテンプレートの仕組みを使って部品のコンポーネント化はしていない
  • 動的な操作のために jQuery などを使い、DOMエレメントの差し替え・加工を直接行っている
  • CSSの命名規則を使ったスコープが適切に作られておらず、謎な !important もまれによくある
  • (構造・設計的に)直感的ではない場所の関数に依存していたりする

これのいずれかが当てはまったらレガシーとか言いたいわけではなくて、これらの要素が複合的に絡んでいて、
そのうえに、「あーあきっとこれ作ったとき大変だったんだね」とか思うコードがずっと残っているプロジェクトならレガシーと呼んでもいいかなと思っている。

レガシーと戦う上でのポイント

前述した特徴から以下に注意する必要があると考えている

攻撃は予期しないところから飛んでくる

  • グローバルに効いている強いルールのCSS
  • 気が付いたらDOM構造をいじられている

みたいに、基本的にスコープが広いものが多いのがレガシーコードです。
新しく作ったコードは外に漏らさないのは当然として、外からの影響を受けない仕組みを備えていることが望ましいです。

絡まったコードはすぐにほぐすことはできない

  • 部品のコンポーネント化がされていない
  • よくわからない場所の関数に依存している

みたいに、レガシーなコードは凝集性が低く、結合度が高いです。もしかしたら循環的な依存をしてしまっているかも。
一方でコンポーネントはまさに高凝集・疎結合を体現したようなものです。

したがって、素直にリファクタリングをしていると、ここのコンポーネントを直そうとしていたけど、こっちに依存しているからこれもコンポーネントにしなきゃ、あれ、こっちも書き直さないと動かせない。。。
となって、気が付いたらページ全部を書き直さないとデプロイできなくなっちゃうということもあり得ます。

もちろん、ページ全部を書き直している間対象サービスの成長を止めてひたすら書き換えることができれば問題ないかもしれません。
ただ、大抵はそうじゃないです。だからこんなことになったんでしょ。

脱線しましたが、一時的に汚れ役を作って、そこをクッションにすることで部分的にリリースできるようにしたいところです。

WebComponent

WebComponent 自体はその存在が広まってからしばらくたっているため多くの方がご存じだと思います。
なので多くの説明は必要ないと思います。
https://developer.mozilla.org/ja/docs/Web/Web_Components

Shadow DOM

ここで注目したいのは WebComponent の Shadow DOM という仕組みです。
https://developer.mozilla.org/ja/docs/Web/Web_Components/Using_shadow_DOM

これも改めて書くと今更感がありますが、Shadow DOM の内側の要素は外側から参照できません
また、外側て定義されているCSSのルールから影響をうけません

この特徴で一つ目の留意点に対応できそうです。

WebComponent は ES2015クラス

以下の例を見てわかるように、WebComponent は ES2015クラスで定義します。
https://developers.google.com/web/fundamentals/web-components/customelements

ここで、このクラスに定義された振る舞いは外から実行できます。

document.querySelector("example-elemnt").methodName()

画面全体が特定のフレームワークで構成されている場合は、コンポーネント間の協調などにはシングルトンストアの状態を購読させたり、イベントの仕組みを使うことである部品から別の部品に直接アクセスすることは避けることが多いと思います。

しかし、これはレガシーシステムなので、どうしてもそうしないといけないことがあります。
そういったときに外から直接振る舞いにアクセスできることは助かります。

現状のUI的な役割から定義した部品AとBがあります。
のっぴきならない事情があって、AからどうしてもBにアクセスしないといけません。
どちらもVueなどのフレームワークで作られていれば、Vuex 等の状態管理の仕組みを使って、そこの状態を変更させることで対応したりするでしょう。(下にいつものを置いておきますね。)

いつもの

ここに至るまでは、腐敗防止層として定義したXにAを依存させて、Xの実装からBへアクセスさせるようにします。
順調に進んでAもコンポーネント化されたらXの実装を適切なものにすればいいです。
これで二つ目の留意点も解決できそうです。

Vue CLI の WebComponent ビルド

とはいえ、生産性を考えるとなにかしらのフレームワークを使いたいところです。
Vue CLI のビルドターゲットには WebComponent があります。
https://cli.vuejs.org/guide/build-targets.html#web-component

WebComponentへのラッピング自体は https://github.com/vuejs/vue-web-component-wrapper が利用されています。

Vueでコンポーネントを作りつつリリースをする対象をWebComponentにしていけば徐々にVueベースのアプリケーションにしていけそうです。
たとえば、Atomicデザインでコンポーネントを設計するとします。
はじめは Atom や Moleculeに相当する部品から置き換えていきますが、ある程度置き換えが進んだらそれを丸ごと Organism に置き換えてしまえばよさそうです。

WebComponent ビルドしたコンポーネントを使ってみる

プロジェクトの準備

$ vue create vue-webcomponent-example
# お好みで。 ここではTypescript と Vuex を選択しています

ビルドコマンドを以下に変更

package.json
{
 "scripts": {
    "build": "vue-cli-service build --inline-vue --target wc-async --name v './src/components/*.vue'",
  }
}
  • --target wc-async
    • 動的インポートをするようにWebComponentをビルドします
  • --inline-vue
    • ビルド結果に Vue のランタイムを含みます
    • バージョンの不一致を避けるためにCDNのロードなどはしません
    • 現ビルド環境にwebpackなどバンドラーがあれば、既存スクリプトをバンドルするときに含めればいいですが、レガシーなのでないです
  • --name
    • コンポーネント名です
    • 単一のコンポーネントを指定した場合はここでした名前がコンポーネント名になります
    • 複数のコンポーネントをしたいした場合 (今回はこっち) はコンポーネント名の prefix になります

コンポーネントの作成

Vuex を利用したコンポーネントを作ります。

CountDisplay.vue
<template>
<div>
  <slot name="before"></slot>
  <div>{{ count }}</div>
  <slot></slot>
</div>

</template>
<script lang="ts">
import Vue from "vue";
import store from "@/store/";
export default Vue.extend({
  store,
  computed: {
    count(): number {
      return this.$store.state.count;
    }
  }
});
</script>
<style lang="scss" scoped>
div {
  font-size: 3rem;
  color: red;
}
</style>
components/CountDisplay.vue
<template>
<div>
  <slot name="before"></slot>
  <div>{{ count }}</div>
  <slot></slot>
</div>

</template>
<script lang="ts">
import Vue from "vue";
import store from "@/store/";
export default Vue.extend({
  store,
  computed: {
    count(): number {
      return this.$store.state.count;
    }
  }
});
</script>
<style lang="scss" scoped>
div {
  font-size: 3rem;
  color: red;
}
</style>
components/DecrementBtn.vue
<template>
  <button @click="decrement">減らす</button>
</template>
<script lang="ts">
import Vue from "vue";
import store from "@/store/";
export default Vue.extend({
  methods: {
    decrement() {
      store.commit("decrement");
      this.$emit("decrement");
    }
  }
});
</script>
<style lang="scss" scoped>
button {
  color: blue;
}
</style>
components/IncrementBtn.vue
<template>
  <button @click="increment">増やす</button>
</template>
<script lang="ts">
import Vue from "vue";
import store from "@/store/";
export default Vue.extend({
  methods: {
    increment() {
      store.commit("increment");
      this.$emit("increment");
    }
  }
});
</script>
<style lang="scss" scoped>
button {
  color: green;
}
</style>
store/index.ts
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment: state => state.count++,
    decrement: state => state.count--
  },
  actions: {},
  modules: {}
});

コンポーネントを使う

ソース

初めのほうで考えた懸念に対応できそうかを試しています。

index.html
<meta charset="utf-8">
<title>v demo</title>
<script src="./v.js"></script>
<style>
  /** WebComponent は影響を受けない */
  button {
    background: red;
  }
</style>
<v-count-display></v-count-display>
<v-decrement-btn></v-decrement-btn>
<v-increment-btn></v-increment-btn>
<button id="increment">こっちでもふやせる</button>
<button id="decrement" class="herasu">こっちでもへらせる</button>
<style>
  /** WebComponent は影響を受けない */
  button {
    background: pink !important;
  }
</style>
<script>
const decrementBtn = document.querySelector("v-decrement-btn")
const incrementBtn = document.querySelector("v-increment-btn")

decrementBtn.addEventListener("decrement",()=> alert("へりました")) 
incrementBtn.addEventListener("increment",()=> alert("増えました")) 

/** vueComponent で WebComponent のもととなった Vue のメソッドにアクセスできる */
document.getElementById("increment").addEventListener("click", ()=> incrementBtn.vueComponent.increment())
document.getElementById("decrement").addEventListener("click", ()=> decrementBtn.vueComponent.decrement())

// WebComponent は影響を受けない
document.querySelectorAll("button").forEach(btn => {
  btn.innerHTML = btn.innerHTML + "(これはクリックできる)" 
  btn.addEventListener("click", ({target}) => console.log(btn))
})
</script>

動いている絵

広範囲に影響を及ぼしている箇所(スタイル・querySelector)についてはWebComponentは影響を受けていないことがわかると思います。
また、コンポーネントの振る舞いにアクセスすることもできています。
(本来は適切な層を仲介させるべきです)
また、Vuex もちゃんと機能していることがわかります。これで最終的なアーキテクチャを見据えて改善していくことができそうです。

test.gif

終わりに

レガシープロジェクトに対する問題点を考えて、それへの解決手段としてWebComponetが利用できる可能性を考えた。
また、 Vuejs を利用して WebComponentを作成することが可能であることを試すことができた。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした