なになれ

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の変更はどうすれば?といった点など色々苦労しました。
そのあたりのことを別でまとめたいです。

参考