技術的負債を返済するブリッジとしてWebComponentsを使うという手法の提案と、具体例として、AngularJSの中にWebComponentsとしてReactを組み込む方法について、この記事で解説します。
祭り化アドベントカレンダーです
この記事は#祭り化 Advent Calendar 2019の12/23日分です。株式会社マツリカ社員たちのエモい記事がいっぱい登場してる中ですが、僕にとっての祭り化状態はやっぱり技術について考えアウトプットしてる時なので、エモとか考えずに、技術系記事でいきます。
ちなみに、最近社内のごく一部の人に流行の兆しを見せているホットサンドメーカーという、肉や魚を焼いたり、なぜかホットサンドも焼ける、神の与えたもうた最強の調理器具の記事を書くことも一瞬考えましたが、今回は技術ネタができあがったので、技術的な記事でいきます。
※ホットサンドメーカーは、肉や魚や肉まんなどを焼ける万能調理器具である。むしろアーティファクト
ホットサンドも焼けます。
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を追加でインストールしています。
サンプルのソースは不要なため、一度消します。
{
"semi": false,
"tabWidth": 2,
"printWidth": 76,
"singleQuote": true
}
個人的な好みによりこれを追加しておきます。
AngularJS側
<!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というカスタムエレメントに置き換えています。
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(/"/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のコードを書く
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で書き直しただけです。
唯一特殊な点としては、input
のonChange={() => {}}
です。checked
を指定している場合にはセットでonChange
が必須であるためダミーを指定しています。Event.preventDefault
をしておらず、change
イベントがそのまま飛ぶため、AngualarJS側でイベントを ngOn
ディレクティブでキャッチしています。
場合によっては、イベントハンドラを真面目に書いて、カスタムイベントを飛ばすといったことも必要になるかもしれません。
カスタムエレメントを作成する
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.parseInt
か Number.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するだけです。
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円未満で買えるので、是非買いましょ?