なになれ

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

Chrome拡張機能を作る過程で学んだReactのこと

以前にChrome拡張機能を作りました。UI部分はReactで実装しました。

hi1280.hatenablog.com

今回はこのChrome拡張機能を作る際にどのようにReactを使ったのかを紹介します。

Option Page

Chrome拡張機能の設定画面です。

f:id:hi1280:20190228220929p:plain

Material-UI

今回は以下のReact用のモジュールを使っています。

material-ui.com

Material-UIを使えば非Webデザイナーでもある程度見た目が良いWebページを作れるので利用します。

今回は、Material-UIのCSS-in-JS機能を使ってReactコンポーネントCSSを適用しています。
個人的にはこの部分を理解するのに苦労しましたので自分の整理も兼ねて説明します。

Material-UIのCSS-in-JS

CSS-in-JS機能を使うのにwithStylesという関数を使ってCSSのstyleを適用します。

コードの全体像は以下の通りです。

option.tsx

// ...省略

const styles = (theme: Theme) =>
  createStyles({
    // ...省略
  });

interface IProps extends WithStyles<typeof styles> {}

const Option = withStyles(styles)(
  class extends React.Component<IProps> {
    // ...省略
    public render() {
      const { classes } = this.props;
      return (
            // ...省略
            <main className={classes.layout}>
              <Paper className={classes.paper}>
                <Grid container={true} spacing={24}>
                  <Grid item={true} xs={12}>
                    <this.apiKeyMessage />
                  </Grid>
                </Grid>
              </Paper>
            </main>
            // ...省略
      );
    }
    // ...省略
  }
);

ReactDOM.render(<Option />, document.getElementById('option'));

このようなコードにおいて、CSSを適用する流れは以下の通りです。

  • CSSのstyleを定義する
  • propsにclassesプロパティが生成される
  • classesプロパティをコンポーネントのclass名にセットする
TypeScriptで扱う

このwithStylesを使う場合にTypeScriptでは型を扱うためちょっとした対象法を実施する必要があります。

createStyles関数を使って、withStylesの引数に与えるstyleを定義します。

option.tsx

const styles = (theme: Theme) =>
  createStyles({
    layout: {
      marginLeft: theme.spacing.unit * 2,
      marginRight: theme.spacing.unit * 2,
      width: 'auto',
      [theme.breakpoints.up(600 + theme.spacing.unit * 2 * 2)]: {
        marginLeft: 'auto',
        marginRight: 'auto',
        width: 600,
      },
    }
    // ...省略
  });

stylesの定義にしたがって、propsの型を定義します。

interface IProps extends WithStyles<typeof styles> {}

これにより、propsに型がある状態で、classesプロパティを扱うことができます。

下記がTypeScriptを使う場合の詳しい解説になります。

material-ui.com

Content Script

ここでは、Chromeで表示しているWebページにDOMを追加する処理をしていて、そのDOMがCanvasになっています。
Webページのブラウザ上で見える部分全体をCanvasとするために見える部分全体のサイズを計測する必要があります。

Reactにおけるコンポーネントのサイズの取得について説明します。

サイズの計測には下記のモジュールを利用しました。

github.com

サイズを計測するコンポーネントを配置する

Measureコンポーネントを配置します。

content-script.tsx

<Measure
  offset={true}
  onResize={contentRect => {
    this.setState({
      wrapper: contentRect.offset,
    });
  }}
>
   // サイズを計測するコンポーネントなどのコンポーネント群
</Measure>

上記ではoffsetを計測するようにしています。そのほかにもDOMのサイズに関するプロパティを計測することができます。
onResizeイベントでサイズが変更される度にstateにサイズがセットされるように定義しています。

計測の設定

content-script.tsx

  // サイズを計測するコンポーネント群
  {({ measureRef }) => (
    <Wrapper ref={measureRef}>
      <Canvas width={this.state.wrapper.width} height={this.state.wrapper.height} />
    </Wrapper>
  )}

measureRefがサイズを計測する対象のコンポーネントを指定する変数になります。
ここではCanvasがWrapperコンポーネントのサイズをstateを介して取得して、サイズをプロパティにセットしています。

この内容に関してはこちらを参考にしました。

qiita.com

共通

拡張機能のUI全般で国際化対応を行いました。

国際化対応には下記のモジュールを利用しました。

github.com

React Intlを使った国際化対応について説明します。

言語ファイルを作成する

対応する言語ごとにファイルを作成します。

ja_JP.ts

const ja_JP = {
  apikey: 'APIキー',
  config: '設定',
  popup_main: 'オプション画面でAPIキーを設定してください',
  popup_sub: 'APIキーの説明',
};
export default ja_JP;

言語ファイルの設定をコンポーネントに適用する

addLocaleData関数を使用してReact Intlで用意されているLocaleデータを読み込みます。

index.tsx

import { addLocaleData, FormattedMessage, IntlProvider } from 'react-intl';
import * as en from 'react-intl/locale-data/en';
import * as ja from 'react-intl/locale-data/ja';

// ...省略
  constructor(props: object) {
    super(props);
    addLocaleData([...en, ...ja]);
  }
// ...省略

IntlProviderコンポーネントを配置します。
このコンポーネントの範囲が国際化の対応範囲になります。

index.tsx

class Popup extends React.Component {
  private locale = navigator.language.split('_')[0];

  // ...省略

  <IntlProvider locale={this.locale} messages={Util.chooseLocale(this.locale)}>
    // 国際化の対応範囲
    <FormattedMessage id="popup_main" />
  </IntlProvider>
}

navigator.languageで利用言語を特定しています。
言語ファイルの定義に存在するキーをid属性に指定することで各言語の内容を表示することができます。

util.ts

export class Util {
  public static chooseLocale(locale: string) {
    switch (locale) {
      case 'en':
        return en_US;
      case 'ja':
        return ja_JP;
      default:
        return ja_JP;
    }
  }
}

この内容に関してはこちらを参考にしました。
lizefieldwp.azurewebsites.net

まとめ

TypeScriptでReactを使う場合、実装方法に工夫が必要になる場合があります。
なぜそのような実装なのかに気づくためにもただコードをコピペするのではなく、TypeScriptとReactをもっと理解することが必要だと感じています。
まだ色々分かっていない感じです。

参考

Chrome拡張機能における各項目がどういった役割なのかはこちらを見ると理解できると思います。

qiita.com

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テストやビジュアルテストといったテストがやりやすくなり、開発体験の向上に寄与するものだと思います。