LoginSignup
18
9

More than 3 years have passed since last update.

技術的負債を段階的に返済するためにReactをWebComponentsとして他のフレームワークに組み込む方法

Last updated at Posted at 2019-12-23

技術的負債を返済するブリッジとしてWebComponentsを使うという手法の提案と、具体例として、AngularJSの中にWebComponentsとしてReactを組み込む方法について、この記事で解説します。

祭り化アドベントカレンダーです

この記事は#祭り化 Advent Calendar 2019の12/23日分です。株式会社マツリカ社員たちのエモい記事がいっぱい登場してる中ですが、僕にとっての祭り化状態はやっぱり技術について考えアウトプットしてる時なので、エモとか考えずに、技術系記事でいきます。

ちなみに、最近社内のごく一部の人に流行の兆しを見せているホットサンドメーカーという、肉や魚を焼いたり、なぜかホットサンドも焼ける、神の与えたもうた最強の調理器具の記事を書くことも一瞬考えましたが、今回は技術ネタができあがったので、技術的な記事でいきます。

※ホットサンドメーカーは、肉や魚や肉まんなどを焼ける万能調理器具である。むしろアーティファクト

EMIjl98UcAAt2Py-orig.jpg

ホットサンドも焼けます。

EL4sT77U8AAXna--orig.jpg

Amazonで2000円未満で買えるので、是非買って、様々なものを焼きましょう。

技術的負債を段階的に返済しよう

ウェブ開発をする貴方のお手元には、jQueryやAngularJS(つまりAngularの1.x系)など、石器時代のごとき古代の遺産が残っていませんか?傾向として、フルスタックフレームワークや、逆に詳細に踏み込みすぎるライブラリは、技術的負債になりやすいものです。

技術的負債はビジネス価値を生み出す開発の脚を引っ張るため、価値を生み出す為にも技術的負債の返済が大切になりますが、技術的負債を返している過程自体はビジネス価値を持ちません。このため技術的負債を一気に返すというのは難しいものです。

そこでビジネス価値を生み出しつつ技術的負債を解消し、さらなるビジネス価値を生み出すという、段階的な工程が望ましいということになります。

たとえば、AngularJSのもたらす圧倒的な苦痛と闘っているとします。既にある膨大な資産を読み解こうにも、VSCodeのIntelliSenseの恩恵にもあずかれず、E2Eテストもなく、当然ユニットテストもなく、ドキュメントもなく、型定義もない、そんな状況は、おそらく色々なところにあるでしょう。

限られた人員で、全部をReact(Vueかもしれません)に置き換えるというのは、誠に残念ながら、先ほど述べた通り非現実的です。そのため次善策としては、一部を置き換えるということになります。

一部を置き換える為の方法としてAngularJSとReactの別々のアプリケーションとして作ったうえで、URLで振り分けるというやり方もありますが、段階的な技術的負債の返済としてはもう少し粒度を下げたいものです。

ブリッジ技術としてWebComponentsを使おう

WebComponentsは疎結合にできる仕組みだといえます。フレームワークやライブラリという詳細からは独立しているためです。

ヒントはWeb Componentsを利用した段階的AngularJS脱出作戦 - builderscon tokyo 2019と、デザインシステムにおけるフロントエンド - LINE DEVELOPER DAY 2019の2つのセッションです。

前者はAngularJSからAngularへの移行手順としてWebComponentsを使うもので、後者はReact, Vueの間で共通のUIパーツを使う為にWebComponents/LitElementを使うものです。

共通することは、WebComponentsというスタンダードな技術を使って、異なるフレームワークを繋ぐブリッジとしている点です。

たとえば最新のReact HooksやNuxt.jsでイケてるシステム・アプリケーションを作成したとして、将来的に別の何かに置き換えるべきタイミングがきたときも、同じやり方が使えます。

WebComponentsをブリッジ技術として使い、少しずつ置き換えて、換骨奪胎が完遂すれば、古い資産及びWebComponentsは役目を終え、消滅することになります。

WebComponents

ここでいうWebComponentsは、具体的には、カスタムエレメントとシャドウDOMです。(ただし、この記事ではシャドウDOMに関してはあまり踏み込みません)

一時期はWebComponentsといえばPolymerでしたが、既にPolymerは開発を終え、LitElement及びlit-htmlが使われることが増えました。

しかし、WebComponentsの構成技術は既にほとんどのウェブブラウザに組み込まれている標準であるため、LitElementやlit-htmlを使っても、使わなくてもかまいません。今回の目的にはlit-htmlはそぐわないため使いません。

AngularJSにReactを組み込む

お待たせしました。ここからが本番です。

AngularJSのコードは https://angularjs.org/ 公式にあるTODOアプリをサンプルとして使います。このTODOの各アイテムをReact化します。(粒度が小さすぎるのはサンプルなので勘弁してください。)

ただし、色々なセットアップで楽をするために、一度 create-react-app を使って React のセットアップをします。

$ yarn create react-app react-webcomponents-example --template typescript
$ cd react-webcomponents-example
$ yarn add angular@^1.7.9
$ yarn add styled-components @types/styled-components
$ rm -rf src/*

AngularJS@^1.7.9 と styled-componentsを追加でインストールしています。

サンプルのソースは不要なため、一度消します。

.prettierrc
{
  "semi": false,
  "tabWidth": 2,
  "printWidth": 76,
  "singleQuote": true
}

個人的な好みによりこれを追加しておきます。

AngularJS側

public/index.html
<!DOCTYPE html>
<html ng-app="ReactAngularJSApp">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <h2>Todo</h2>
    <div ng-controller="TodoListController as todoList">
      <span
        >{{todoList.remaining()}} of {{todoList.todos.length}}
        remaining</span
      >
      [ <a href="" ng-click="todoList.archive()">archive</a> ]
      <ul class="unstyled">
        <todo-item
          ng-repeat="todo in todoList.todos"
          label="{{todo.text}}"
          done="{{todo.done}}"
          ng-on-change="todoList.change(event, $index)"
        >
        </todo-item>
      </ul>
      <form ng-submit="todoList.addTodo()">
        <input
          type="text"
          ng-model="todoList.todoText"
          size="30"
          placeholder="add new todo here"
        />
        <input class="btn-primary" type="submit" value="add" />
      </form>
    </div>
  </body>
</html>

AngularJSのテンプレートを使ったHTMLです。AngularJS公式のサンプルと大きく違うのは、<todo-item>というタグです。

オリジナルコード
        <li ng-repeat="todo in todoList.todos">
          <label class="checkbox">
            <input type="checkbox" ng-model="todo.done">
            <span class="done-{{todo.done}}">{{todo.text}}</span>
          </label>
        </li>

オリジナルはこのようなコードでしたが、todo-itemというカスタムエレメントに置き換えています。

src/index.ts
import './web-components'

const angular = require('angular')

angular.module('ReactAngularJSApp', []).controller('TodoListController', [
  function() {
    // @ts-ignore
    var todoList = this
    todoList.todos = [
      { text: 'learn AngularJS', done: true },
      { text: 'build an AngularJS app', done: false }
    ]

    todoList.addTodo = function() {
      todoList.todos.push({ text: todoList.todoText, done: false })
      todoList.todoText = ''
    }

    todoList.remaining = function() {
      var count = 0
      angular.forEach(todoList.todos, function(todo: any) {
        count += todo.done ? 0 : 1
      })
      return count
    }

    todoList.archive = function() {
      var oldTodos = todoList.todos
      todoList.todos = []
      angular.forEach(oldTodos, function(todo: any) {
        if (!todo.done) todoList.todos.push(todo)
      })
    }

    todoList.change = function(ev: Event, index: number) {
      todoList.todos[index].done = !todoList.todos[index].done
    }
  }
])

これが、新たなエントリポイントとなる src/index.ts です。AngularJS公式のJSコードと違うのは、TypeScriptとしてエラーになる部分を潰したことと、import './web-components'というインポート文と、todoList.change です。

さきほどのテンプレートでは、

        <todo-item
          ng-repeat="todo in todoList.todos"
          label="{{todo.text}}"
          done="{{todo.done}}"
          ng-on-change="todoList.change(event, $index)"
        >
        </todo-item>

というように、ng-on-change="todoList.change(event, $index)"という属性を指定しています。ngOnディレクティブは、イベントハンドラを登録するためのものです。

このコードであれば、changeというイベントによりtodoList.changeが呼び出されるため todoList.todos の中身を更新できるようになります。

ただし ngOn は AngularJS 1.7.x のディレクティブであるため、それより前のバージョンでは、自前でディレクティブを作成する必要があります。

  .directive('ngOn', [
    function() {
      return {
        restrict: 'A',
        compile: function(elements: any, attrs: any) {
          const s = attrs.ngOn.replace(/&quot;/g, '\\"')
          var ngOn = JSON.parse(s)

          return function(scope: any, element: any) {
            Object.keys(ngOn).forEach(eventName => {
              element.on(eventName, function(event: Event) {
                scope.$evalAsync(ngOn[eventName], { event })
              })
            })
          }
        }
      }
    }
  ])

たとえばこのようなディレクティブです。ng-on='{"change": "todoList.change(event, $index)"}'のようにして利用します。https://www.npmjs.com/package/ng-on を参考にしていますが、仕様が気にくわなかったので本来のngOnディレクティブの仕様に近い形に作り替えています。

ここまでが、AngularJSのコードです。

Reactのコードを書く

src/components/todo-item/index.tsx
import React from 'react'
import styled from 'styled-components'

export type Props = {
  label: string
  done: boolean
}

const DoneLabel = styled.span`
  text-decoration: line-through;
  color: gray;
`

const TodoItem: React.FC<Props> = ({ label, done }) => {
  return (
    <li>
      <label>
        <input type="checkbox" checked={done} onChange={() => {}} />
        {done ? <DoneLabel>{label}</DoneLabel> : <span>{label}</span>}
      </label>
    </li>
  )
}

export default TodoItem

あまり変哲もないコードです。本来の公式サンプルのテンプレートで削った部分をReactで書き直しただけです。

唯一特殊な点としては、inputonChange={() => {}}です。checkedを指定している場合にはセットでonChangeが必須であるためダミーを指定しています。Event.preventDefaultをしておらず、changeイベントがそのまま飛ぶため、AngualarJS側でイベントを ngOn ディレクティブでキャッチしています。

場合によっては、イベントハンドラを真面目に書いて、カスタムイベントを飛ばすといったことも必要になるかもしれません。

カスタムエレメントを作成する

src/components/todo-item/web-components.ts
import React from 'react'
import ReactDOM from 'react-dom'

import TodoItem, { Props } from './index'

class TodoItemWC extends HTMLElement {
  _props: Props = {
    label: '',
    done: false
  }

  static _conv = {
    label: (v: string) => v,
    done: (v: string) => v === 'true'
  }

  static get observedAttributes() {
    return Object.keys(this._conv).filter(key => !key.startsWith('on'))
  }

  attributeChangedCallback(name: string, prev: any, next: string) {
    // @ts-ignore
    this._props[name] = TodoItemWC._conv[name](next)
    this.render()
  }

  render() {
    ReactDOM.render(React.createElement(TodoItem, this._props), this)
  }
}

customElements.define('todo-item', TodoItemWC)

カスタムエレメントは、HTMLElementを継承し、特定のメソッドを実装したクラスです。

  • static get observedAttributes で自分の属性の変化を検知したいという宣言をする
  • attributeChangeCallbackメソッドで、属性の変更を元にReactコンポーネントに渡すプロパティを更新する
  • connectedCallbackおよびdisconnectedCallbackメソッドで、Reactのライフサイクルでいうマウント・アンマウントの処理を行う。
  • ReactDOM.render で Reactコンポーネントをレンダリングする

ちなみに今回はシャドウDOMを使っていません。

シャドウDOMは、アプリケーション全体のDOMとは独立した世界になるため、様々なグローバルリソースとかち合わないという大きな利点があるのですが、問題もあります。

たとえば、シャドウDOM内ではそのままではStyledComponentが使えません(グローバルなCSS定義をして参照しようとしてしまうため)

問題が出たときにシャドウDOMを使うようにしておけば大丈夫だと思います。(WebComponents詳しい方のご意見をお待ちしております)

ここから順にコードを解説します。

class TodoItemWC extends HTMLElement {

HTMLElementを継承してカスタムエレメントを作成します。仕様上クラスである必要があるようです。

  _props: Props = {
    label: '',
    done: false
  }

Reactコンポーネントに渡すプロパティの初期値です。

  static _conv = {
    label: (v: string) => v,
    done: (v: string) => v === 'true'
  }

カスタムエレメントの属性は全て文字列であるため、それ以外のものはいい感じにデシリアライズする必要があります。オブジェクトであれば、JSON.parseが使えるでしょう。数値ならNumber.parseIntNumber.parseFloat が必要になります。

  static get observedAttributes() {
    return Object.keys(this._conv).filter(key => !key.startsWith('on'))
  }

onChangeなどハンドラをReactコンポーネントで指定している場合、それをカスタムエレメントとしては使いたくないため、filterで弾いています。先ほどのAngularJSのときに説明したように、生じるイベントを制御する必要があるときには、カスタムエレメントの定義クラスでイベントをpreventしたり、カスタムイベントを発生してthis.dispatchEventすることになるでしょう。

  attributeChangedCallback(name: string, prev: any, next: string) {
    // @ts-ignore
    this._props[name] = TodoItemWC._conv[name](next)
    this.render()
  }

static get observedAttributesで返した配列をキーに持つ属性が指定・変更されたときに呼び出されるコールバックです。最初の呼び出し時は prev には null が入っていますが、それ以外の場合やnextには string が入ります。

this._propsを更新しthis.renderを呼び出しています。

  render() {
    ReactDOM.render(React.createElement(TodoItem, this._props), this)
  }

ReactDOM.renderで自分自身をマウントポイントとしてレンダリングしています。

customElements.define('todo-item', TodoItemWC)

定義したカスタムエレメントを実際に使えるようにしています。

ここまででカスタムエレメントの定義が完了しました。

あとは、
src/index.ts から、このsrc/components/todo-item/web-components.tsをimportするだけです。

src/web-components.ts
import './components/todo-item/web-components'

まとめ

技術的負債を一気に返済するのはたいていの場合しんどいため、段階的に返済する方が望ましいケースが多いでしょう。

WebComponents (カスタムエレメントやシャドウDOM)を使うと、フレームワーク・ライブラリに依存しない疎結合なブリッジが可能となります。

今回の事例では、AngularJSにReactを組み込んでみました。React側ではもちろんReact Hooksを使う事もできます。

  • イベントの扱いはカスタムエレメントやシャドウDOMの都合で少し面倒
  • シャドウDOMを使う場合、styled-componentsのようにCSSに干渉しようとすると頑張る必要がある
  • カスタムエレメントでは属性が全て文字列であるためデシリアライズが必要となる

など、少しだけ面倒も伴います。今回の記事ではシャドウDOMを使っていませんが、外との境界線をより強固にするためにはシャドウDOMも検討する必要があるかもしれません。

今回は、アトミックデザインでいうAtom単位でカスタムエレメントにしていますが、現実的なところでいえばMolecules以上の単位になるでしょう。

まだReactDOM.renderを使ったカスタムエレメントを大量に定義していませんが、もしかしたらメモリや速度で問題が生じる可能性もあるため、検証は必要かもしれません。

ホットサンドメーカーはアーティファクトである

ホットサンドメーカーという、肉や魚を焼いたり、なぜかホットサンドも焼ける、神の与えたもうた最強の調理器具があります。※ホットサンドメーカーは、肉や魚や肉まんなどを焼ける万能調理器具である。むしろアーティファクト

Amazonで2000円未満で買えるので、是非買いましょ?

18
9
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
18
9