なになれ

IT系のことを記録していきます。

React+TypeScriptでChrome拡張機能を作った

作ったもの

WebページをOCRしてコピペできるChrome拡張機能を作りました。

github.com

作る過程での気づきなどを記載します。

Chrome拡張機能を開発するのに辛い点

コールバック前提

JavaScriptにおける非同期処理にはPromiseやasync,awaitを使うことが標準だと思うのですが、ChromeAPIはコールバック前提になってしまっています。辛い。。。

Promiseでラップするモジュールを見つけたので使うことにしました。
TypeScriptで開発していたのでTypeScriptの型定義があるのもいい感じです。

github.com

DOMの操作

Chrome拡張機能はHTMLとCSSJavascriptで作ることになります。
DOMを用いてUIを作ることになるため、素でDOMを扱うとなるとなかなか辛い感じになります。
例えば、DOM操作とアプリの処理が混ざってコードの見通しが悪くなります。

開発環境整備

Chrome拡張機能開発における色々な辛い点を少しでも解消するために環境を整備しました。

React+TypeScript

DOMの操作に対して、TypeScriptとReactでコードの見通しを良くします。
ReactでDOMの操作を分かりやすくしつつ、TypeScriptでのClass構文によってコードの見通しを良くする狙いです。

この狙いに適したものがあったので、これを元にして環境を作っていきました。
github.com

コードフォーマッターは必要なのでPrettierを入れました。Prettierについては過去記事を見てください。

hi1280.hatenablog.com

JestやEnzymeを入れて、テスト環境を整えていますが、テストはほぼ書いていません。。。
Reactのテストはどうするのが良いのか理解できていません。

reactjs.org

React(Webフロントエンド向けライブラリ)の良い点

今回はReactを使いました。Reactに限らず、似たようなライブラリを使うとコードの見通しが良くなると思いました。
以下は当初作成したライブラリなしのコードとReactを使ったコードの比較です。
完全に同一のことをやっているわけではないので雰囲気で見てください。

ライブラリなし版

// 一部抜粋

class Main {
  content: HTMLDivElement;
  canvas: HTMLCanvasElement;
  canvasRectangle: Rectangle;
  drag = false;
  mousePosition: {
    x: number;
    y: number;
  };

  constructor() {
    this.canvasRectangle = {
      height: 0,
      width: 0,
      x: 0,
      y: 0,
    };
    this.mousePosition = {
      x: 0,
      y: 0,
    };
    this.content = document.createElement('div');
    this.canvas = document.createElement('canvas');
    this.content.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      height: 100%;
      width: 100%;
    `;
    this.canvas.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      cursor: crosshair;
    `;
    document.body.appendChild(this.content);
    this.content.appendChild(this.canvas);
    this.canvas.setAttribute('height', this.content.offsetHeight.toString());
    this.canvas.setAttribute('width', this.content.offsetWidth.toString());
    this.canvas.addEventListener(
      'mousedown',
      e => {
        this.canvasRectangle.x = e.clientX;
        this.canvasRectangle.y = e.clientY;
        this.drag = true;
      },
      false,
    );
    this.canvas.addEventListener(
      'mouseup',
      () => {
        const ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
        this.drag = false;
        ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        chrome.runtime.sendMessage(this.canvasRectangle);
      },
      false,
    );
    this.canvas.addEventListener(
      'mousemove',
      e => {
        if (this.drag) {
          const ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
          this.canvasRectangle.width = e.clientX - this.canvasRectangle.x;
          this.canvasRectangle.height = e.clientY - this.canvasRectangle.y;
          ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
          this.draw();
        }
      },
      false,
    );
    document.body.addEventListener(
      'mousemove',
      e => {
        this.mousePosition.x = e.clientX;
        this.mousePosition.y = e.clientY;
      },
      false,
    );

    const listener = (
      request: { rect: Rectangle; base64Img: string },
      _: any,
      sendResponse: (response: any) => void,
    ) => {
      const rectangle = request.rect;
      const base64Img = request.base64Img;
      const canvas = document.createElement('canvas') as HTMLCanvasElement;
      document.body.appendChild(canvas);
      canvas.width = rectangle.width;
      canvas.height = rectangle.height;
      const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
      const img = new Image();
      img.src = base64Img;
      img.addEventListener('load', async () => {
        rectangle.x = rectangle.x;
        rectangle.y = rectangle.y;
        rectangle.width = rectangle.width;
        rectangle.height = rectangle.height;
        ctx.drawImage(
          img,
          rectangle.x,
          rectangle.y,
          rectangle.width,
          rectangle.height,
          0,
          0,
          rectangle.width,
          rectangle.height,
        );
        const res = await fetch(`https://vision.googleapis.com/v1/images:annotate?key=${ENV.API_KEY}`, {
          body: JSON.stringify({
            requests: [
              {
                features: [
                  {
                    type: 'TEXT_DETECTION',
                  },
                ],
                image: {
                  content: canvas.toDataURL('image/png').split(',')[1],
                },
              },
            ],
          }),
          method: 'POST',
        });
        document.body.removeChild(canvas);
        const json = await res.json();
        if (json && json.responses && json.responses[0].textAnnotations) {
          const textarea = document.createElement('textarea');
          textarea.value = json.responses[0].textAnnotations[0].description;
          this.content.appendChild(textarea);
          textarea.style.cssText = `
            position: absolute;
            top: ${this.mousePosition.y}px;
            left: ${this.mousePosition.x}px;
          `;
          textarea.select();
          textarea.addEventListener(
            'click',
            () => {
              this.content.removeChild(textarea);
              document.body.removeChild(this.content);
            },
            false,
          );
        }
      });
      chrome.runtime.onMessage.removeListener(listener);
      sendResponse(true);
      return true;
    };
    chrome.runtime.onMessage.addListener(listener);
  }
}

new Main();

React版

// 一部抜粋

const Wrapper = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  height: 100%;
  width: 100%;
`;

const Canvas = styled.canvas`
  position: fixed;
  top: 0;
  left: 0;
  cursor: crosshair;
`;

const TextArea = styled.div`
  position: absolute;
  background: #fff;
  border: 1px solid #000;
`;

const RightAlignDiv = styled.div`
  text-align: right;
`;

export class Main extends React.Component {
  state = {
    apiKey: '',
    drag: false,
    mouse: {
      x: 0,
      y: 0,
    },
    rectangle: {
      height: 0,
      left: 0,
      rgb: { red: '255', green: '0', blue: '0' },
      top: 0,
      width: 0,
    },
    textarea: {
      style: {
        display: 'none',
        left: 0,
        top: 0,
      },
      value: '',
    },
    wrapper: {
      height: 0,
      left: 0,
      top: 0,
      width: 0,
    },
  };
  imageCanvasRef: React.RefObject<HTMLCanvasElement>;
  textareaRef: React.RefObject<HTMLTextAreaElement>;

  constructor(props: object) {
    super(props);
    this.imageCanvasRef = React.createRef();
    this.textareaRef = React.createRef();
    chrome.storage.sync.get('apiKey', v => {
      this.setState({ apiKey: v.apiKey });
    });
  }

  render() {
    return (
      <React.Fragment>
        <Measure
          offset={true}
          onResize={contentRect => {
            this.setState({
              wrapper: contentRect.offset,
            });
          }}
        >
          {({ measureRef }) => (
            <Wrapper ref={measureRef}>
              <Canvas
                width={this.state.wrapper.width}
                height={this.state.wrapper.height}
                onMouseDown={e => this.onMouseDown(e.clientX, e.clientY)}
                onMouseUp={e => this.onMouseUp(e.target as HTMLCanvasElement)}
                onMouseMove={e => {
                  if (this.state.drag) {
                    this.onMouseMove(e.target as HTMLCanvasElement, e.clientX, e.clientY);
                  }
                }}
              />
              <TextArea style={this.state.textarea.style} onClick={() => this.exit()}>
                <RightAlignDiv>
                  <Close style={{ fontSize: 18 }} />
                </RightAlignDiv>
                <textarea
                  ref={this.textareaRef}
                  value={this.state.textarea.value}
                  readOnly={true}
                  onClick={e => e.stopPropagation()}
                />
              </TextArea>
            </Wrapper>
          )}
        </Measure>
        <canvas ref={this.imageCanvasRef} width={this.state.rectangle.width} height={this.state.rectangle.height} />
      </React.Fragment>
    );
  }

  onMouseDown(x: number, y: number) {
    const rectangle = this.state.rectangle;
    rectangle.left = x;
    rectangle.top = y;
    this.setState({
      drag: true,
      rectangle,
    });
  }

  onMouseUp(canvasEl: HTMLCanvasElement) {
    const ctx = canvasEl.getContext('2d') as CanvasRenderingContext2D;
    this.setState({
      drag: false,
    });
    ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
    if (this.state.rectangle.width > 0 && this.state.rectangle.height > 0) {
      chrome.runtime.sendMessage({});
      this.addMessageListener();
    }
  }

  addMessageListener() {
    const listener = (request: { base64Img: string }, _: any, sendResponse: (response: any) => void) => {
      const base64Img = request.base64Img;
      const canvas = this.imageCanvasRef.current as HTMLCanvasElement;
      const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
      const img = new Image();
      img.src = base64Img;
      img.addEventListener('load', async () => {
        ctx.drawImage(
          img,
          this.state.rectangle.left,
          this.state.rectangle.top,
          this.state.rectangle.width,
          this.state.rectangle.height,
          0,
          0,
          this.state.rectangle.width,
          this.state.rectangle.height,
        );
        const res = await fetch(`https://vision.googleapis.com/v1/images:annotate?key=${this.state.apiKey}`, {
          body: JSON.stringify({
            requests: [
              {
                features: [
                  {
                    type: 'TEXT_DETECTION',
                  },
                ],
                image: {
                  content: canvas.toDataURL('image/png').split(',')[1],
                },
              },
            ],
          }),
          method: 'POST',
        });
        const json = await res.json();
        if (json && json.responses && json.responses[0].textAnnotations) {
          const textarea = this.textareaRef.current as HTMLTextAreaElement;
          this.setState({
            textarea: {
              style: {
                display: 'block',
                left: `${this.state.mouse.x}px`,
                top: `${this.state.mouse.y}px`,
              },
              value: json.responses[0].textAnnotations[0].description,
            },
          });
          textarea.select();
        }
      });
      chrome.runtime.onMessage.removeListener(listener);
      sendResponse(true);
      return true;
    };
    chrome.runtime.onMessage.addListener(listener);
  }

  onMouseMove(canvasEl: HTMLCanvasElement, x: number, y: number) {
    const ctx = canvasEl.getContext('2d') as CanvasRenderingContext2D;
    const rectangle = this.state.rectangle;
    rectangle.width = x - this.state.rectangle.left;
    rectangle.height = y - this.state.rectangle.top;
    this.setState({
      mouse: {
        x,
        y,
      },
      rectangle,
    });
    ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
    ctx.strokeStyle = `rgb(${this.state.rectangle.rgb.red}, ${this.state.rectangle.rgb.green}, ${
      this.state.rectangle.rgb.blue
    })`;
    ctx.strokeRect(
      this.state.rectangle.left,
      this.state.rectangle.top,
      this.state.rectangle.width,
      this.state.rectangle.height,
    );
  }
}

const app = document.createElement('div');
document.body.appendChild(app);
ReactDOM.render(<Main />, app);

元がCanvasの操作などで複雑なことをやっているので分かりにくいかもですが、
ReactによってDOM部分はJSXで表現されていて、イベントの処理はメソッド化されるのでコードの見通しが良くなっているのではと感じます。DOMの変更もstateを介して行われているので、DOM部分のコードは分かりやすくなっていると思います。
ただし、Reactの方がコード量は多くなっています。

Webpack

今回の拡張機能ではAPIキーを扱っています。 開発時には常にAPIキーがセットされている状態にして、簡単に動作確認ができるようにしています。
これにはWebpackのDefinePluginを使ってます。

webpack.js.org

開発環境や本番環境といった環境の違いで動作を変更することができます。

デモ

f:id:hi1280:20190216141548g:plain

まとめ

Chrome拡張機能の開発環境を充実させつつ、目的のものを作ることができました。
開発するにあたってはWebフロントエンドの技術だけでなく、Chrome拡張機能の構成についての独自要素を理解しておくが大事でした。
こちらを理解しておくことをオススメします。
qiita.com

また、Reactを使うのにあまり慣れていなかったため、DOMの変更はどうすれば?といった点など色々苦労しました。
そのあたりのことを別でまとめたいです。

参考

StorybookをAngularで試した

f:id:hi1280:20190129212132p:plain

Storybookが気になったので、Angularを使って試しました。
以下の内容をそのまま試していきましたが、Angularの場合、うまく動作しないところがありました。
修正しながら一通り試したので、その内容を残しておきます。
ページに掲載されているコードに記載ミスがあり、それなりにハマりました。

www.learnstorybook.com

ソースはこちらです。

github.com

以下、Learn Storybookのコンテンツ名に沿って補足します。

Get started

learnstorybookではLESSを使って、CSSを定義していますが、そのままCSSを使いました。
styles.cssに全量のCSSを定義しています。
また、assetsに関してもstorybookから参照するように以下のオプションをつけて起動しています。

package.json

...
    "storybook": "start-storybook -p 6006 -s ./src/assets",
...

Build a simple component

TaskComponentをStorybook Serverで動作させるのはすんなりできました。

Storyshotsの動作は修正が必要でした。

ほぼこちらを参考にしました。

github.com

ポイントとしては以下の通りです。

環境変数NODE_ENVにtestを設定する

package.json

...
    "test": "cross-env NODE_ENV=test ng test",
...

古いパッケージを参照することによって、testが失敗する可能性があるため、jestのcacheを使用しないようにする

jest.config.js

...
  cache: false,
...

Assemble a composite component

コンポーネントを組み合わせてリスト化するのはすんなりできました。

Unit tests with Jest

ユニットテストの部分はDOMの作り方がフロントエンドFWによって違うため、修正が必要です。
以下のように修正しました。

task-list.component.spec.ts

...
  it('renders pinned tasks at the start of the list', () => {
    fixture = TestBed.createComponent(TaskListComponent);
    component = fixture.componentInstance;
    component.tasks = withPinnedTasks;

    fixture.detectChanges();
    const lastTaskInput = fixture.debugElement.query(
      By.css('.list-item:nth-child(1) input[type="text"]'),
    );

    // We expect the task titled "Task 6 (pinned)" to be rendered first, not at the end
    expect(lastTaskInput.nativeElement.value).toBe('Task 6 (pinned)');
  });
...

まとめ

今回の内容に注意すれば、Learn Storybookの内容通りに進めることができます。
Storybookはスタイルガイドとしての役割がメインだと思っていましたが、それだけでなく、コンポーネント駆動開発を実現する開発基盤であることが分かりました。
Storybookをベースに開発していくことで、Snapshotテストやビジュアルテストといったテストがやりやすくなり、開発体験の向上に寄与するものだと思います。

基本から理解したくてAWSを学習した

今まで雰囲気でAWSを使ってきました。
基本的なことの理解が危ういと感じたので、基本からの理解のために以下のUdemyレクチャーを実施しました。

手を動かしながら2週間で学ぶ AWS 基本から応用まで

感想を書きます。

良かったこと

VPCの説明が充実しています。VPC、サブネットのIPアドレス範囲の設定、セキュリティグループの設定といったネットワーク部分の説明が網羅されている印象です。
NATGWはそういうものだったのかというのが個人的な気づきです。
作者さんの実践経験に基づいた配慮が感じられるコンテンツ内容です。

IAMに関してはAWSを利用しているよく出てくるものだと思います。
IAMロールといった要素に関して、要素ごとの説明があって分かりやすかったです。
各要素がどういった役割なのかといった理解が曖昧だったのでありがたかったです。

個人としてAWSを使う場合には料金が気になります。料金をどうやって抑えるかといった説明がされていて、細かな配慮が感じられました。

注意点

Day9までのレクチャーは1レクチャーで1つのサービスを説明しています。それ以降は1レクチャーで2つのサービスを説明するようになっており、内容が薄い印象です。
レクチャーを一通り実施した後は約5.5$ほどのAWSでの料金がかかりました。前提としてAWSの無料枠は既にない状態で実施しています。

まとめ

AWS初心者はもちろん、全体的に基本的なことが学べるのでAWSを触ってはいるけど、雰囲気で使っている人にもオススメです。