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