blog.euxn.me

Angular v9 で Angular Elements(WebComponents 出力)を使う

2019-12-16 Mon.

この記事は Angular アドベントカレンダー 2019 の 11 日目の記事です。体調不良につき遅くなり申し訳ありません……。

はじめに

Angular の WebComponents 出力である Angular Elements が搭載されたのが Angular v6 でのことなので、1 年半になります。

今年は Setncil 1.0 がリリースされ、 lit-html での実装に比べ抽象度を高く保ちつつも、 pure WebComponents としての出力することができることから注目を浴びているように感じます。 Stencil での実装としても、 Angular の良い点である ShadowDOM + Sass を継承しており、 Component 単位で見ると Angular に通じる開発体験がある箇所もあります。 しかし Angular の出番が奪われたわけでは全くなく、やはりフレームワークとしての堅さ、簡潔な Template、高度なビルドシステムを持ちつつ、部分的に WebComponents として出力できることは他にない特徴かと思います。

この記事では Angular Elements と Stencil で同様の簡単なサンプルを実装し、 Angular を触ったことがない人でも、意外ととっつきやすく、意外とバンドルサイズが大きくなく、 WebComponents としても十分実用的である、という点を紹介することを主な目的としています。 ですので、 Angular と Stencil のどちらが優れている、という点について言及するものではありません。

なお、開発環境は執筆時点での LTS である node.js 12.13.x と、最新の rc である Angular CLI v9.0.0-rc.5 を前提とします。 (正式リリース後に可能な限り本記事を対応しますが、 rc であるため変更が入った場合はご容赦ください)

本文中の実装のサンプルリポジトリは以下です。

https://github.com/euxn23/angular-elements-counter

Angular でのカウンターコンポーネントの実装

Angular のプロジェクトを作るところからはじめていきます。

1$ ng new angular-elements-counter
2$ cd angular-elements-counter

デフォルトで AppComponent が生成されているのでこちらを編集しても良いのですが、今回はわかりやすく MyCounterComponent を生成します。

1$ ng generate component my-counter

以下のように編集します。

src/app/my-counter/my-counter.components.ts
1import { Component, EventEmitter, Output } from '@angular/core';
2
3@Component({
4 selector: 'app-my-counter',
5 templateUrl: './my-counter.component.html',
6 styleUrls: ['./my-counter.component.scss']
7})
8export class MyCounterComponent {
9 count = 0;
10
11 @Output()
12 valueChanged = new EventEmitter();
13
14 increment() {
15 this.count++;
16 this.event.emit(this.count);
17 }
18}
src/app/my-counter/my-counter.components.html
1<button (click)="increment()">{{ count }}</button>

次に、ビルドのルートから辿れるよう、 AppModule を書き換えます。 AppComponent は使用しないため削除し、 MyContainerComponent を追加します。 AppComponent 内で Bootstrap を行う参考情報もいくつかありますが、 AppComponent が使われているのか使われていないのか混乱の元となるかと思い、今回は Module 内でブートストラップを行います。

なお、 @Module() 内での entryComponents の定義は Ivy 以降不要になったとのことです。

src/app/app.module.ts
1import { BrowserModule } from '@angular/platform-browser';
2import { Injector, NgModule } from '@angular/core';
3import { createCustomElement } from '@angular/elements';
4import { MyCounterComponent } from './my-counter/my-counter.component';
5
6@NgModule({
7 declarations: [MyCounterComponent],
8 imports: [BrowserModule]
9})
10export class AppModule {
11 constructor(injector: Injector) {
12 const MyCounterElement = createCustomElement(MyCounterComponent, {
13 injector
14 });
15 customElements.define('app-my-counter', MyCounterElement);
16 }
17 ngDoBootstrap() {}
18}

通常の Angular アプリケーションの一部として使用する場合、 Angular Elements 用に別のビルド設定を用意し、 Module を分けるのが良いかと思います。

ビルドと html からの読み込み

production ビルドを行い、生成物を html から読み込みます。

1$ yarn build --prod --output-hashing none

ビルドすると、 dist ディレクトリに以下のファイルが生成されます。

chunk {0} runtime-es2015.js (runtime) 1.45 kB [entry] [rendered]
chunk {0} runtime-es5.js (runtime) 1.45 kB [entry] [rendered]
chunk {2} polyfills-es2015.js (polyfills) 36 kB [initial] [rendered]
chunk {3} polyfills-es5.js (polyfills-es5) 125 kB [initial] [rendered]
chunk {1} main-es2015.js (main) 109 kB [initial] [rendered]
chunk {1} main-es5.js (main) 131 kB [initial] [rendered]
chunk {4} styles.css (styles) 0 bytes [initial] [rendered]
chunk {scripts} scripts.js (scripts) 27 kB [entry] [rendered]

runtime、 polyfills、 main は Differential Loading 用に分けて吐き出されています。 scripts は angular.json の scripts に定義したものを共通です。 今回は Chrome で動かすことを想定し es2015 のもので html ファイルを生成します。

static/index.html
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <title>Hello Angular Elements</title>
6 </head>
7 <body>
8 <app-my-counter></app-my-counter>
9 <script src="/dist/angular-elements-counter/main-es2015.js"></script>
10 <script src="/dist/angular-elements-counter/polyfills-es2015.js"></script>
11 <script src="/dist/angular-elements-counter/runtime-es2015.js"></script>
12 <script src="/dist/angular-elements-counter/scripts.js"></script>
13 <script>
14 const counterElement = document.querySelector('app-my-counter');
15 counterElement.addEventListener('valueChanged', ev => {
16 console.log(`count: ${ev.detail}`);
17 });
18 </script>
19 </body>
20</html>

実際に動かすために、 http-server を起動します。

1$ yarn add -D http-server
2$ yarn http-server .

http://localhost:8080/static にアクセスすると、ボタンが存在し、クリックするとカウントが取得できます。 また、 console を見ると、イベントをキャプチャできていることが分かります。

参考: Stencil での実装とバンドルサイズの比較

同様の WebComponent を Stencil で以下のように実装し、バンドルサイズを計測します。

src/components/my-component.tsx
1import { Component, Event, EventEmitter, State, h } from '@stencil/core';
2
3@Component({
4 tag: 'app-my-counter',
5 styleUrl: 'my-component.css',
6 shadow: true
7})
8export class MyComponent {
9 @State() count = 0;
10
11 @Event() valueChanged: EventEmitter;
12
13 private increment() {
14 this.count++;
15 this.valueChanged.emit(this.count);
16 }
17
18 render() {
19 return <button onClick={() => this.increment()}>{this.count}</button>;
20 }
21}
stencil.config.ts
1import { Config } from '@stencil/core';
2
3export const config: Config = {
4 namespace: 'app-my-counter',
5 outputTargets: [
6 {
7 type: 'dist'
8 }
9 ]
10};

実装が 1 つにまとめられているファイルは dist/app-my-counter/app-my-counter.js になるのでこちらを読み込みます。

src/index.html
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <title>Hello Stencil</title>
6 </head>
7 <body>
8 <app-my-counter></app-my-counter>
9 <script src="/dist/app-my-counter/app-my-counter.js"></script>
10 <script>
11 const counterElement = document.querySelector('app-my-counter');
12 counterElement.addEventListener('valueChanged', ev => {
13 console.log(`count: ${ev.detail}`);
14 });
15 </script>
16 </body>
17</html>

同様に http-server を起動し、 localhost:8080/src にアクセスすると、 Component が動作していることが分かります。

この時、 app-my-counter.js のバンドルサイズは 135KB でした。 上記 Angular Elements での実装は、合計 173KB です。

Angular で WebComponents 作ったら重そう、という感覚がある方もいるかと思いますが、結果としては 2 割ほど Angular の方が大きいものの、致命的なまでに大きいわけではないことが分かります。

今回は 1 Component での例でしたが、 Component が増えていった場合にはまた差が出るか、もしくは逆に最適化や bootstrap のサイズの影響で縮まるか、どちらもあり得ると思います。 (今回はそこまで検証しきれず申し訳ないのですが、もし試した方がいましたら教えてください。)

終わりに

この記事では Angular Elements が意外とバンドルサイズが大きくなく、 WebComponents としても十分実用的である、という点を紹介しました。 フレームワークとして機能しつつ、一部は WebComponent として再利用可能なパーツとして吐き出せるのは他にはない Angular の特徴かと思います。

Angular Elements に関しては情報が少なかったり、 Angular から切り離す部分の知見が少ないなどあるため、みなさんも触って知見を増やしていけるといいなと思っています。