こんにちは。
NAVITIME JAPAN Advent Calendar 2019 の17日目は、taaaka が担当させていただきます。
昨日はデルタさんの機械学習のお話でしたが、本日はうってかわってフロントエンドの話題です。
私が所属する部門では、Webサイトで地図を表示するための JavaScript ライブラリの運用をしています。
今回は、そのライブラリの実装を試すPlayground環境をWebアプリで構築した経験から得られた知見を共有させていただきます!
つくったもの
以下のような、地図のJavaScriptライブラリ(社内では "mapscript" と呼んでいます)の各機能をショーケース的に表示できるものです。
以下の機能を備えています。
- mapscript で実現できる各機能の確認
- コード実装例の表示
- コードを直接編集しその場で実行するPlayground機能
- 編集したコードをHTMLとしてエクスポートする機能
技術スタック
- フレームワーク
- React + Redux
- 言語
- TypeScript
- ライブラリ等
- Bootstrap ( React Bootstrap )
→ UIフレームワークとして - Ace
→ エディタ部分に利用(後述) - FileSaver
→ エクスポート機能に利用(後述)
- Bootstrap ( React Bootstrap )
Playground環境を作る
ここからは、このアプリケーションの主要機能である、Playground環境とエクスポート機能をどのように作っていったかを紹介させていただきます。
(下準備)プロジェクトのベース作成
今回はReactのプロジェクトなので、 create-react-app
を用いてベースを作成しました。
$ npx create-react-app mapscript-demo --typescript
こちらについては、以下に挙げるような公式のページや、他様々な記事で触れられているため詳細は割愛しますが、TypeScriptベースのプロジェクトも簡単に立ち上げられるのはありがたいです。
プロジェクト作成後は、
- eslint + prettier の導入
- デフォルトで入っていた ServiceWorker の削除
- React Bootstrap の導入
を行い、それ以外はとくにプロジェクトの設定変更は行っていません。
エディタ部分
今回のアプリケーションでコアとなるのは、Playground環境を実現するためのコードエディタです。
こちらは、 Ace を利用することで簡単に実現することができました。
Aceは、JavaScriptで開発されているコードエディターで、以下のようなネイティブのエディタにもひけを取らない多くの機能を搭載しています。
- 100言語以上の Syntax Highlight
- 20以上の Editor Theme
- Vim や Emacs などの主要エディタのキーバインディングサポート
- リアルタイム構文チェック
- etc...
Cloud9 IDE のエディタとしても利用されています。
今回は、アプリケーションをReactで実装しているので、react-ace を利用しました。
これにより、Aceをラップしたコンポーネントを簡単に利用できるようになっています。
以下は、JavaScriptのエディターを利用するコンポーネントの例になります。
import React, { useState } from 'react';
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/mode-javascript'; // JavaScript エディターを利用するために必要
import 'ace-builds/src-noconflict/theme-chrome'; // エディタの Theme として "Chrome" を利用
import 'ace-builds/src-noconflict/snippets/javascript'; // JavaScript の基本的なSnippetを利用できるようにする
/** エディタ共通の設定 */
const EditorCommonSettings = { [key: string]: any } = {
theme: 'chrome',
tabSize: 2,
enableBasicAutocompletion: true,
height: '500px',
width: '500px'
};
/** JavaScript のエディターを実現するコンポーネント */
const JSEditor: React.FC = () => {
const [jsValue, setJS] = useState();
return (
<AceEditor
mode="javascript"
name="js-editor"
value={jsValue}
onChange={setJS}
{...EditorCommonSettings}
></AceEditor>
);
};
export default JSEditor;
今回のアプリケーションでは、各機能の実装例を表示するようにしたかったので、
前述のようなコンポーネントに対して <Router>
から実装例の定義の文字列を流し込むような実装を行っています。
実行させる仕組み
画面上の実行ボタンが押されたときに、前述のエディター部分に書かれている文字列を利用して、
- HTML
- コンテンツを表示したい要素の
innerHTML
に書かれた内容を注入
- コンテンツを表示したい要素の
- JavaScript
-
<script>
を生成 - 生成したタグの
innerHTML
に書かれた内容を注入 -
document.body
などにappend
-
することで、書いたコードがその場で実行されるようにしています。
なお、ReactではHTMLの内容をそのまま出力する場合に、 dangerouslySetInnerHTML
が利用できますが、
エディター部分のコンポーネントのState(=入力されている内容)をそのまま流し込む形だと、エディター上の変更がリアクティブに反映されてしまうのであえて避けました。
HTML だけであればまだ良いのですが、JavaScript側はすでにDOMとして追加されたもののinnerHTMLを書き換えても、それが即時動作してくれるわけではありません。
そのため、 <script>
タグを都度生成してDOMに追加するようにしてあげる必要があります。
ただし、この手法はややリスキーな部分があるので、これについては後の「課題に感じた部分」で触れたいと思います。
ファイルのエクスポート
実装したものをHTMLとして出力するのには、 FileSaver を利用しました。
通常、ファイル保存の処理は
- 出力したい内容をBlobに変換
- Blobから
URL.createObjectURL
を用いてURL生成 - ダウンロードリンクのanchor要素を生成し、
href
属性に↑で生成した URL を設定したうえで画面に表示
のような手順を取らなければなりません。
また、生成したURLを利用したあとは、 URL.revokeObjectURL
を利用して開放しないと、メモリリークにつながってしまいます。1
一方、 FileSaverを用いると、これらの処理を簡潔に行うことができます。
内部で行っている処理は前述した内容のようでしたが、I/Fがシンプルな上に ObjectURL の開放まで確実に行ってくれるため安心です。
const content = `
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<h1>Hello!</h1>
</body>
</html>
`
const blob = new Blob([content], {type:'text/html;charset=utf-8'});
// 通常の実装
const url = URL.createObjectURL(blob);
const el = document.createElement('a');
el.href = url;
el.setAttribute('target', '_blank');
el.addEventListener('click', () => URL.revokeObjectURL(url));
el.dispatchEvent(new MouseEvent('click'));
// FileSaver利用
import FileSaver from 'file-saver';
FileSaver.saveAs(blob, 'export.html');
以上が、今回作成したアプリケーションでコアとなった実装内容です。
思っていたより簡単に実現できる…と思いませんか?
課題に感じた部分
さて、ここまでPlayground環境の実現方法についていくつか紹介してきていますが、もちろん課題に感じている部分もあります。
(1) JavaScript 実行時の環境
エディターで書かれたコードを実行する際、script タグを新たに追加して実行する方法を紹介しましたが、このまま実行すると グローバルスコープでの実行になってしまう という問題があります。
たとえば、書いたコードが繰り返し実行されると、前に実行したときに宣言していた const
宣言の変数名が使い回されてしまい、 エラーが発生してしまうような問題が考えられます。
そういった問題を防ぐため、都度実行されるコードは即時関数でラップし、外部への影響を最小限にする対応は行っておくべきです。
const script = `(async () => { ${writtenJsCode} })();`
const tag = document.createElement('script');
tag.innerHTML = script;
document.body.appendChild(tag);
また、グローバルスコープに対しての操作も行えてしまう問題もあります。
アプリケーションが稼働するのに必要な変数が操作されてしまい、後に動作しなくなる…というようなリスクも十分有り得ます。
(※今回は社内ツールだったので深入りしませんでしたが、より多くのユーザーに使われるケースだとしっかり考えなければならない部分です。)
また、今述べた変数のスコープの問題とは別に、 実行したコードでエラーがあったときに catch できない という問題もあります。
このままでは、発生したエラーはDevToolsのConsole上にしか表示されないのですが、Alertのコンポーネントを利用するなどして、エラーの内容が画面上に表示されたほうが、より親切だと言えます。
(2) Scoped CSS にできない
JavaScript と同様、スコープの問題に悩まされるのがCSSです。
エディター上で実装されたHTMLは、画面全体で適用されているCSSの影響をダイレクトに受けます。
今回の例ではあまりクリティカルにならないかもしれませんが、アプリケーション実装で利用した class 属性のバッティングなどにより、実行する HTML の見た目が想定と異なる、といったことが起こりえます。
逆もまた然りで、エディター上で <style>
タグでむちゃくちゃな値を指定されると、アプリケーションのレイアウトは簡単に壊れてしまいます。
/* ↓のようなものを書かれたら即死… */
body {
font-size: 100px !important;
}
Reactの場合、 styled-components を利用することで(ある程度)この問題を回避できますが、とはいえ完全とは言えません。
解決方法はあるか?
ここで述べた2点の問題を解決する方法があるとすれば、以下のようなものになると思います。
- エディターの入力を受けつけ、HTMLに出力し直すサーバーサイドのアプリケーションを用意する
- クライアント側では、エディタの入力を一度サーバーに流し、得られるレスポンスを新規ウィンドウまたは
iframe
で表示する
また、サーバーサイドでコードの静的解析をかけることで、ブラウザ表示時の問題はさらに発生しにくくなると思います。
(Syntax Error があったときは400を返す、などすればアプリケーション上でもユーザーへのエラー通知に活用できますね!)
まとめ
今回は社内向けということで、簡単なものでやってみようかな〜位のノリで試してみたのですが、これくらいのエディタであれば意外と簡単に実現できてしまうんだな、という手応えがあったのが一番の収穫でした。
今後は、先に述べた課題などが解決されていけば、研修での利用や社外向けの展開2も見込めるのではないかと考えています。
いままでブラウザ上で試せるPlayground環境はいくつか触ったことがありましたが、こういった環境の裏側がどうなっているかを知るきっかけになりました。
サーバーサイドアプリケーションを用意すれば、今回のような HTML/JavaScript 以外の言語のPlayground環境も用意できそうだなーともやんわり考えています。
(各言語の実行環境用にDockerコンテナを立てて、コンテナ内で実行→結果を返すAPIを作れればいけそう。もちろん、セキュリティ面での課題等はありますが)
みなさんも、現場で活用できる場面があれば、ぜひお試しください!!
-
https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL ↩
-
NAVITIMEでは、法人向けにも地図スクリプトのAPIを提供しています。 ↩