Chrome拡張機能を作る過程で学んだReactのこと
以前にChrome拡張機能を作りました。UI部分はReactで実装しました。
今回はこのChrome拡張機能を作る際にどのようにReactを使ったのかを紹介します。
Option Page
Material-UI
今回は以下のReact用のモジュールを使っています。
- Material-UI
- Material Designを実装したReactのコンポーネント
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を適用する流れは以下の通りです。
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を使う場合の詳しい解説になります。
Content Script
ここでは、Chromeで表示しているWebページにDOMを追加する処理をしていて、そのDOMがCanvasになっています。
Webページのブラウザ上で見える部分全体をCanvasとするために見える部分全体のサイズを計測する必要があります。
Reactにおけるコンポーネントのサイズの取得について説明します。
サイズの計測には下記のモジュールを利用しました。
サイズを計測するコンポーネントを配置する
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を介して取得して、サイズをプロパティにセットしています。
この内容に関してはこちらを参考にしました。
共通
拡張機能のUI全般で国際化対応を行いました。
国際化対応には下記のモジュールを利用しました。
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をもっと理解することが必要だと感じています。
まだ色々分かっていない感じです。
参考
React+TypeScriptでChrome拡張機能を作った
作ったもの
WebページをOCRしてコピペできるChrome拡張機能を作りました。
作る過程での気づきなどを記載します。
Chrome拡張機能を開発するのに辛い点
コールバック前提
JavaScriptにおける非同期処理にはPromiseやasync,awaitを使うことが標準だと思うのですが、ChromeのAPIはコールバック前提になってしまっています。辛い。。。
Promiseでラップするモジュールを見つけたので使うことにしました。
TypeScriptで開発していたのでTypeScriptの型定義があるのもいい感じです。
DOMの操作
Chrome拡張機能はHTMLとCSSとJavascriptで作ることになります。
DOMを用いてUIを作ることになるため、素でDOMを扱うとなるとなかなか辛い感じになります。
例えば、DOM操作とアプリの処理が混ざってコードの見通しが悪くなります。
開発環境整備
Chrome拡張機能開発における色々な辛い点を少しでも解消するために環境を整備しました。
React+TypeScript
DOMの操作に対して、TypeScriptとReactでコードの見通しを良くします。
ReactでDOMの操作を分かりやすくしつつ、TypeScriptでのClass構文によってコードの見通しを良くする狙いです。
この狙いに適したものがあったので、これを元にして環境を作っていきました。
github.com
コードフォーマッターは必要なのでPrettierを入れました。Prettierについては過去記事を見てください。
JestやEnzymeを入れて、テスト環境を整えていますが、テストはほぼ書いていません。。。
Reactのテストはどうするのが良いのか理解できていません。
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を使ってます。
開発環境や本番環境といった環境の違いで動作を変更することができます。
デモ
まとめ
Chrome拡張機能の開発環境を充実させつつ、目的のものを作ることができました。
開発するにあたってはWebフロントエンドの技術だけでなく、Chrome拡張機能の構成についての独自要素を理解しておくが大事でした。
こちらを理解しておくことをオススメします。
qiita.com
また、Reactを使うのにあまり慣れていなかったため、DOMの変更はどうすれば?といった点など色々苦労しました。
そのあたりのことを別でまとめたいです。
参考
StorybookをAngularで試した
Storybookが気になったので、Angularを使って試しました。
以下の内容をそのまま試していきましたが、Angularの場合、うまく動作しないところがありました。
修正しながら一通り試したので、その内容を残しておきます。
ページに掲載されているコードに記載ミスがあり、それなりにハマりました。
ソースはこちらです。
以下、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の動作は修正が必要でした。
ほぼこちらを参考にしました。
ポイントとしては以下の通りです。
環境変数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テストやビジュアルテストといったテストがやりやすくなり、開発体験の向上に寄与するものだと思います。