こんにちは。エンジニアの二串です。普段はサーバサイドの開発をしていますが、今日は最近仕事で取り組んだフロントよりの話になっています。
iframe を使って複数の管理画面サイトをあたかも1つの統一されたサイトとして見せるパターンをまとめます。
iframe を使って別サイトをはめ込むことは簡単ですが、実際複数ページあるサイトをはめ込もうとすると、ただ iframe を使うだけでは力不足です。例えばiframe内でのページ遷移、高さ調整、パーマリンク等で問題を感じます。
今回紹介する tips のいくつかはインターネット上で見つけられますが、今回の複数管理画面を1つにマージするという視点でまとまった記事は見つけられなかったので、まとめたら便利なのではとおもい記事にまとめました。
コードスニペットを以下で掲載していますが、より詳しくはデモアプリも併せて見ていただければとおもいます。
背景
移行イメージ
ラクスルに存在する最もレガシーなシステムは admin と呼ばれるいわゆる管理画面です。PHPで作られています。そのレガシー管理画面を素早く新システム(Ops Rails製)へ移行する必要がありました。
当時の状況としては
- 新管理画面はまだ機能が少ないが、今後の新規開発は新管理画面でしたい
- 旧管理画面には多くの機能がまだあり、理想的には新管理画面に移植したいものの、工数的にさくっと移行できない
- オペレーションの中心は旧管理画面から新管理画面に移行してしまいたい
というような状況でしたので、iframe を使って新管理画面の1機能として、旧管理画面の各ページを埋め込んでしまって、ユーザー目線での移行については少ない工数で完了させることにしました。
iframe埋め込みによる問題点
単に iframe の src 属性に URL を指定すると、以下の点が課題になります。
- 旧管理画面のページ数は多いので、ページごとに iframe を設置するのは大変
- iframe の高さ動的に変えれない(初期ページや子側でページ遷移した場合)。結果的にページが切れる
- ブラウザアドレスバー問題
- 子側でページ遷移を何回かした後に F5 を押すと元に戻る。またURL を他の人にシェアできない。
- 子側ページ内のリンクが 旧管理画面URL 問題
- リンク先 URL を Slack 等でシェアできない(できるけど iframe が外れる)
- 別タブで開くと iframe が外れる
- そもそも、デザインの統一性がない(子側のヘッダやサイドバーメニューが邪魔)
さらに補足すると、今回の2画面はドメインが異なるため、iframeの親・子間で window をまたいで javascript や css で操作しようにもクロスドメイン(CORS)のため DOM 操作できません。
仕組み全体像
以上の問題を踏まえ、以下の指針を立てました
- 新管理画面側(Rails)では `GET /proxy?path=path/to/page` というエンドポイントを作り <iframe src="https://旧管理画面/path/to/page"> をレンダリングする。つまりどんなページもはめ込むことができる。
- 親・子それぞれに今回のための javascript を設置し(parent.js, child.js)、window間通信は window.postMessage() API を使う。これによりクロスドメインであってもwindow間で通信可能
- この通信路を使って子の page height などを 親に伝える。高さ以外の情報もあるので、メッセージタイプ(type)、メッセージデータ(data)を含めた object をデータペイロードに入れて送ることにする。
- iframe内のリンクの諸々の問題は、a タグの href の値を力技で新管理画面のproxy URL に書き換える。
- サポートブラウザは Google Chrome のみとする。社内利用のツールなので工数削減も踏まえ割り切れる点と、そもそも社内で Google Chrome が標準で使われているので。
以下、長くなりますが、1つずつ見ていきます。
GET /proxy?path= エンドポイント
# app/controllers/proxies_controller.rb class ProxiesController < ApplicationController def show path = params[:path] || '' @src = 'https://旧管理画面FQDN/' + path end end # app/views/proxies/show.html.erb <iframe id="proxy-iframe" src="<%= @src %>" height="1024" style="overflow: hidden; border: none;" scrolling="no" frameborder="no"></iframe> # config/routes.rb get '/proxy', to: 'proxies#show'
- GETパラメタの path から旧管理画面の URL を構築し iframe の src 属性へセット
- height="1024" は初期値として。後述の方法で別途ページ毎に動的に変える。
- その他、iframeのボーダー枠線。スクロールバーを消す。これはiframeっぽさを無くすための工夫
- id属性 `proxy-iframe` は後述の javascript の処理で利用
これで旧管理画面の任意のページを新管理画面で扱えるようになりました。
なお、旧管理画面側が X-Frame-Options を返却していたので iframe 内で表示させるため nginx で消しこみました。
親・子window間通信
新・旧管理画面それぞれに以下の javascript を置きます。今回のケースでは、子から親に一方通行でメッセージを伝え親側でアクションさせればよかったので、子と親の通信はいわゆるクライアントサーバモデルになっています。
子側
const postMessage = (type, data) => { const rawData = JSON.stringify({ type: type, data: data }) window.parent.postMessage(rawData, '*') } // iframe内でレンダリングされた場合にのみ発火させる if (window !== window.parent) { window.addEventListener('load', () => { // 子の高さを親に伝え、親の iframe の heigit を変更 postMessage('PageHeight', {height: document.body.scrollHeight}) }, false) }
- iframe 内でレンダリングされた場合にのみ実行している
- on load 時に高さを送る。メッセージには type と data がありJSON シリアライズして送る。
iframe 内でレンダリングされた場合にのみこの仕組が発動するようにすることで、移行をやりやすくしました。旧管理画面直アクセスでは発動しないので、旧管理画面直アクセスへのパスを残しつつ、緩やかに移行を進めれます。
親側
const messageHandlers = { PageHeight: (data) => { // iframe の height を調整 let elm = window.document.getElementById('proxy-iframe') elm.style.height = `${data.height}px` } } window.addEventListener('message', (event) => { console.log('[ops] message received', event) // On development environment webpack-dev-server sends message, so skip message from it. if (/^https?:\/\/(localhost|127.0.0.1)/.test(event.origin)) return // Verify received message from for security manner of postMessage() API, const originRe = /旧管理画面URL/ if (event.origin.search(originRe) === -1) { console.log('Blocked the message from ' + event.origin) return } const data = JSON.parse(event.data) if (data.type === null) throw new Error('[ops] Message received, but no type specified') if (!messageHandlers.hasOwnProperty(data.type)) { throw new Error(`[ops] Message received, but type "${data.type}" is invalid`) } messageHandlers[data.type](data.data) })
細かいエラーハンドリングも載せましたが、中心は以下のとおりです。
- window.postMessage() で送信されたデータは 'message' イベントで受け取れる
- type に対応するメッセージハンドラ関数へディスパッチする
- PageHeight ハンドラでは iframe の height 属性を変更
iframe高さの動的調整
1つ前のセクションのとおりです。
ブラウザアドレスバー問題
iframe内でページ遷移を繰り返してもブラウザアドレスバーのURLは最初のURLのまま問題。実際のカスタマーサポートなどの現場では、「あ、この注文、サポートが必要そうだから Slack で注文詳細のURLをシェアしておいて...」 といったことありますので、iframe内で遷移したあとの URL も気軽に貼り付けれる必要がありました。
そこで、子の a タグのクリックイベントを prevent default で止めて、PageMove メッセージを送り、親側windowでページ遷移させてしまうことにしました。詳しくはサンプルアプリのコードを参照ください。
なお、a タグ以外に POST 等の後のサーバサイドでのリダイレクト処理される場合にも同様の課題はあります。管理画面の特性を検討した結果、リダイレクトについては特に何も対策せずとも運用上なんとかなると判断してます。
子側ページ内のリンクが 旧管理画面URL 問題
リンク先をシェアされたり、別タブで開こうとすると iframe が外れてしまう。
これに対しては、子側の DOMContentLoaded で hrefの属性値を 新管理画面の proxy URL に挿げ替えてしまうことにしました。
const fakeAnchors = function () { document.querySelectorAll('a').forEach(function (elm) { // getProxyUrlFromAdminUrl() は https://旧管理画面FQDN/foo/bar を // https://新管理画面FQDN/proxy?path=foo/bar に変換 elm.href = getProxyUrlFromAdminUrl(elm.href) } } // iframe内でレンダリングされた場合にのみ発火させる if (window !== window.parent) { // ... window.addEventListener('DOMContentLoaded', function () { // ... fakeAnchors() }, false) }
なお、javascript を使わず、旧管理画面の view の各aタグを手で全て書き換える方が素直ですが、そうしなかった理由は、数が多いのと、暫くの間旧管理画面にダイレクトにアクセスできるパスは残しておきたかったためです。管理画面だから許される力技...です。
ページ内リンク問題
<a href="#foo">foo</a> のようなリンクです。iframe のオプションで scrolling="no" している、また href をすげ替えた都合でスクロールしてくれないので、このようなリンクがクリックされた場合は、子から親に要素のポジションを計算して親側でスクロールさせるようにしました。詳細はサンプルコードを...といいたいところですがこの部分サンプル書けてないです :pray:
なお、production のコードではこの部分も window.postMessage() で 親にスクロール位置を通知させました。
ヘッダ・サイドバー消し
新管理画面の吸収されてしまった旧管理画面。もう旧管理画面のヘッダやサイドバーメニューは不要なので消します。旧管理画面側で DOMContentLoaded のタイミングで 追加の css ファイルを入れて、消し込むことにしました。
子側
const applyOpsCss = function () { const style = document.createElement('link') style.rel = 'stylesheet' style.href = '/css/additional.css' document.getElementsByTagName('head')[0].appendChild(style) } // iframe経由でのみ発火させる if (window !== window.parent) { applyOpsCss() window.addEventListener('DOMContentLoaded', function () { postMessage('PageDOMContentLoaded', {}) } }
- iframe内でレンダリングされた場合に限り additional.css を適用する。additional.css 内では消したい要素に display: none; を適用している
- DOMが読み込まれたら親に通知(次を参照)
親側
const messageHandlers = { // ... PageDOMContentLoaded: (_data) => { window.document.getElementById('proxy-iframe').style.visibility = 'visible' }, // ... }
- スタイルを当てるまでの非常に短い間はヘッダ等が見えてしまうので画面がチラつく。これを防ぐために ngCloak 的なアプローチ、すなわち、iframe に `visibility : hidden;` で予め非表示にしておいて、追加のスタイルが適用されたら
PageDOMContentLoaded
メッセージを送信しiframe のdisplay visibility を `visible` に変更する
CORS でなければ親側だけで iframe 内の load を検知できそうな気はするのですが、CORS だったのでこのようにしました。
メニューのマージ
このタイミングでメニューバーを整理しました。新・旧それぞれのメニューバーにあった項目を整理し、新管理画面のメニューバーにマージしました。格段に見やすくなってこちらは評判です。
まとめと所感
iframe を使って複数の管理画面サイトをあたかも1つの統一されたサイトとして見せるパターンをまとめました。特に CORS 環境おいては window.postMessage() API を利用することで様々な課題を解決することができました。
この方式で本番運用して4ヶ月ほど立ちますが、大きなトラブルなく稼働できています。管理画面という特性上そのあたりは柔軟に対応できているのと、対応ブラウザを Chrome 1本に割り切ったので、導入後のメンテ工数は今のところほぼなく上手く動いてます。
本番の切り替えについては、旧画面と内容は変わらないので、導入調整コストがほとんどかからず、また利用者=オペレーターさん達の心理的負荷や学習コストも低く抑えることができました。これは本アプローチの最大のメリットではないかとおもいます。
ちなみに、このアイデアを思いついた翌週が運良く Hack It Day だったので、1日つかってプロトタイプを作って、どのあたりに技術課題があるか、工数感などつかむことができたのが良かったとおもいます。 * Hack-It Dayは月に一度、ラクスルのエンジニアが自由に開発することができる日)
さて、長くなりましたが最後までお読み頂きありがとうございます!!
ラクスルでは、絶賛エンジニア募集中です。
「仕組みを変えれば世界はもっとよくなる」
世界が変わっていく瞬間を一緒に体験したいエンジニアの方、お待ちしています☆