HTML
JavaScript
Web

完璧にポータブルなWebページを作る方法 - Webサーバーもなしで実現

きっかけ

以下の記事で、RubyとかJavaScript(ES2017)をURLに埋め込んでポータビリティを実現する方法を紹介しました。
極小WebアプリをURLに埋め込んで超ポータブルなWebアプリを作りたい! - Qiita

そもそも、ポータビリティを完全に追求するならData URLがあります!今回はData URLでポータブルなWebページな実現とNipp1との違いを書きました。

Data URLの構造

data:[<mediatype>][;base64],<data>

MDNから引用しています。こちらにもっと詳しくまとまっています:データ URL | MDN

画像をData URLにするのはよく見かけます。画像以外に、<mediatype>text/htmlにすればHTMLが記述できるようになります。つまり、HTML内にCSSを書いたりJavaScriptを書いたりできるので、Webサーバもいらずに完璧にポータブルなWebページが作れます!

具体例

例があったほうが分かりやすいと思うので、具体例を少しあげます。

簡単な時計

簡易的に時計ページです

clock.mp4.opt.gif

(クリックで開かないので、コピペして開きます)
data:text/html,%3Chtml%3E%0A%3Chead%3E%0A%3Ctitle%3EClock%3C/title%3E%0A%3C/head%3E%0A%3Cbody%3E%0A%3Cdiv%20id='clock'%3E%3C/div%3E%0A%3Cscript%3E%0AsetInterval(function()%7B%0A%20%20document.getElementById('clock').innerText%20=%20new%20Date();%0A%7D,%20500);%0A%3C/script%3E%0A%3C/body%3E%0A%3C/html%3E

中のコード
<html>
<head>
<title>Clock</title>
</head>
<body>
<div id='clock'></div>
<script>
setInterval(function(){
  document.getElementById('clock').innerText = new Date();
}, 500);
</script>
</body>
</html>

VueのTodoリスト

vue-todo-list.mp4.opt.gif

(クリックで開かないので、コピペして開きます)
data:text/html,%3Chtml%3E%0A%3Chead%3E%0A%3Ctitle%3EVue%20Todo%20List%3C/title%3E%0A%3Cstyle%3E%0Abody%20%7B%0A%20%20background:%20#20262E;%0A%20%20padding:%2020px;%0A%20%20font-family:%20Helvetica;%0A%7D%0A%0A#app%20%7B%0A%20%20background:%20#fff;%0A%20%20border-radius:%204px;%0A%20%20padding:%2020px;%0A%20%20transition:%20all%200.2s;%0A%7D%0A%0Ali%20%7B%0A%20%20margin:%208px%200;%0A%7D%0A%0Ah2%20%7B%0A%20%20font-weight:%20bold;%0A%20%20margin-bottom:%2015px;%0A%7D%0A%0Adel%20%7B%0A%20%20color:%20rgba(0,%200,%200,%200.3);%0A%7D%0A%3C/style%3E%0A%3Cscript%20src=%22https://cdn.jsdelivr.net/npm/vue%22%3E%3C/script%3E%0A%3C/head%3E%0A%3Cbody%3E%0A%3Cdiv%20id=%22app%22%3E%0A%20%20%3Ch2%3ETodos:%3C/h2%3E%0A%20%20%3Col%3E%0A%20%20%20%20%3Cli%20v-for=%22todo%20in%20todos%22%3E%0A%20%20%20%20%20%20%3Clabel%3E%0A%20%20%20%20%20%20%20%20%3Cinput%20type=%22checkbox%22%0A%20%20%20%20%20%20%20%20%20%20v-on:change=%22toggle(todo)%22%0A%20%20%20%20%20%20%20%20%20%20v-bind:checked=%22todo.done%22%3E%0A%0A%20%20%20%20%20%20%20%20%3Cdel%20v-if=%22todo.done%22%3E%0A%20%20%20%20%20%20%20%20%20%20%7B%7B%20todo.text%20%7D%7D%0A%20%20%20%20%20%20%20%20%3C/del%3E%0A%20%20%20%20%20%20%20%20%3Cspan%20v-else%3E%0A%20%20%20%20%20%20%20%20%20%20%7B%7B%20todo.text%20%7D%7D%0A%20%20%20%20%20%20%20%20%3C/span%3E%0A%20%20%20%20%20%20%3C/label%3E%0A%20%20%20%20%3C/li%3E%0A%20%20%3C/ol%3E%0A%3C/div%3E%0A%3Cscript%3E%0Anew%20Vue(%7B%0A%20%20el:%20%22#app%22,%0A%20%20data:%20%7B%0A%20%20%20%20todos:%20%5B%0A%20%20%20%20%20%20%7B%20text:%20%22Learn%20JavaScript%22,%20done:%20false%20%7D,%0A%20%20%20%20%20%20%7B%20text:%20%22Learn%20Vue%22,%20done:%20false%20%7D,%0A%20%20%20%20%20%20%7B%20text:%20%22Play%20around%20in%20JSFiddle%22,%20done:%20true%20%7D,%0A%20%20%20%20%20%20%7B%20text:%20%22Build%20something%20awesome%22,%20done:%20true%20%7D%0A%20%20%20%20%5D%0A%20%20%7D,%0A%20%20methods:%20%7B%0A%20%20%20%20toggle:%20function(todo)%7B%0A%20%20%20%20%20%20todo.done%20=%20!todo.done%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D)%0A%3C/script%3E%0A%3C/body%3E%0A%3C/html%3E

サマー・ウォーズのワールドクロック

world-clock.mp4.opt.gif

(クリックで開かないので、コピペして開きます)
data:text/html,%3C!--%20(original%20from%3A%20https%3A%2F%2Fshimz.me%2Fblog%2Fd3-js%2F4360)%20--%3E%0A%3Chtml%3E%0A%3Chead%3E%0A%20%20%3Ctitle%3EWorld%20Clock%3C%2Ftitle%3E%0A%20%20%3Cstyle%3E%0A%20%20%20%20html%2C%0A%20%20%20%20body%20%7B%0A%20%20%20%20%20%20margin%3A%200px%3B%0A%20%20%20%20%20%20padding%3A%200px%3B%0A%20%20%20%20%20%20width%3A%20100%25%3B%0A%20%20%20%20%20%20height%3A%20100%25%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20svg%20%7B%0A%20%20%20%20%20%20width%3A%20960px%3B%0A%20%20%20%20%20%20height%3A%20500px%3B%0A%20%20%20%20%7D%0A%20%20%3C%2Fstyle%3E%0A%20%20%3Cscript%20src%3D%22https%3A%2F%2Fcdnjs.cloudflare.com%2Fajax%2Flibs%2Fd3%2F3.5.17%2Fd3.min.js%22%3E%3C%2Fscript%3E%0A%20%20%3Cscript%20src%3D%22https%3A%2F%2Fcdnjs.cloudflare.com%2Fajax%2Flibs%2Fmoment.js%2F2.10.3%2Fmoment-with-locales.min.js%22%3E%3C%2Fscript%3E%0A%3C%2Fhead%3E%0A%0A%3Cbody%3E%0A%20%20%3Csvg%3E%3C%2Fsvg%3E%0A%3C%2Fbody%3E%0A%3Cscript%3E%0A%20%20const%20windowWidht%20%3D%20960%3B%0A%20%20const%20windowHeight%20%3D%20500%3B%0A%0A%20%20const%20svg%20%3D%20d3.select(%22svg%22)%3B%0A%0A%20%20const%20projection90%20%3D%20d3.geo.orthographic()%0A%20%20%20%20.scale(windowWidht%20%2F%204)%0A%20%20%20%20.rotate(%5B0%2C%200%2C%200%5D)%0A%20%20%20%20.translate(%5BwindowWidht%20%2F%202%2C%20windowHeight%20%2F%202%5D)%0A%20%20%20%20.clipAngle(90)%3B%0A%0A%20%20const%20projection180%20%3D%20d3.geo.orthographic()%0A%20%20%20%20.scale(windowWidht%20%2F%204)%0A%20%20%20%20.rotate(%5B0%2C%200%2C%200%5D)%0A%20%20%20%20.translate(%5BwindowWidht%20%2F%202%2C%20windowHeight%20%2F%202%5D)%0A%20%20%20%20.clipAngle(180)%3B%0A%0A%20%20d3.json(%22https%3A%2F%2Fgist.githubusercontent.com%2Fnwtgck%2F3d4efebf41e47fbdd30601ffb393c3a6%2Fraw%2F45ed39348f2b8cbaff60c6bae1d423286d8b77d4%2FLandmasses.geojson%22%2C%20geojson%20%3D%3E%20%7B%0A%0A%20%20%20%20const%20stage%20%3D%20svg.append(%22svg%3Ag%22)%3B%0A%0A%20%20%20%20stage.attr(%22transform%22%2C%20%60rotate(23.4%2C%20%24%7BwindowWidht%20%2F%202%7D%2C%20%20%24%7BwindowHeight%20%2F%202%7D)%60)%3B%0A%0A%20%20%20%20const%20backMap%20%3D%20stage.append(%22svg%3Apath%22)%0A%20%20%20%20%20%20.attr(%7B%0A%20%20%20%20%20%20%20%20%22fill-opacity%22%3A%201%2C%0A%20%20%20%20%20%20%20%20%22fill%22%3A%20%22%23EDE9F1%22%2C%0A%20%20%20%20%20%20%20%20%22stroke%22%3A%20%22none%22%2C%0A%20%20%20%20%20%20%7D)%3B%0A%0A%20%20%20%20const%20frontMap%20%3D%20stage.append(%22svg%3Apath%22)%0A%20%20%20%20%20%20.attr(%7B%0A%20%20%20%20%20%20%20%20%22fill-opacity%22%3A%201%2C%0A%20%20%20%20%20%20%20%20%22fill%22%3A%20%22%23FD81DB%22%2C%0A%20%20%20%20%20%20%20%20%22stroke%22%3A%20%22none%22%2C%0A%20%20%20%20%20%20%7D)%3B%0A%0A%20%20%20%20(()%20%3D%3E%20%7B%0A%20%20%20%20%20%20let%20i%20%3D%200%3B%0A%20%20%20%20%20%20(function%20update()%20%7B%0A%20%20%20%20%20%20%20%20i%20%3D%20i%20%2B%200.2%3B%0A%20%20%20%20%20%20%20%20projection90.rotate(%5Bi%2C%200%2C%200%5D)%3B%0A%20%20%20%20%20%20%20%20projection180.rotate(%5Bi%2C%200%2C%200%5D)%3B%0A%0A%20%20%20%20%20%20%20%20const%20frontPath%20%3D%20d3.geo.path().projection(projection90)%3B%0A%20%20%20%20%20%20%20%20const%20backPath%20%3D%20d3.geo.path().projection(projection180)%3B%0A%0A%20%20%20%20%20%20%20%20backMap.attr(%22d%22%2C%20backPath(geojson))%3B%0A%20%20%20%20%20%20%20%20frontMap.attr(%22d%22%2C%20frontPath(geojson))%3B%0A%0A%20%20%20%20%20%20%20%20setTimeout(update%2C%20100)%3B%0A%20%20%20%20%20%20%7D)()%3B%0A%20%20%20%20%7D)()%3B%0A%0A%20%20%20%20const%20marginLeft%20%3D%20windowWidht%20%2F%207%3B%0A%20%20%20%20const%20marginTop%20%3D%20windowHeight%20%2F%203%20%2B%20windowHeight%20%2F%2012%3B%0A%20%20%20%20const%20textY%20%3D%208%3B%0A%0A%20%20%20%20const%20clockGroup%20%3D%20svg.append(%22g%22)%0A%20%20%20%20%20%20.attr(%22transform%22%2C%20%60translate(%24%7B%5BmarginLeft%2C%20marginTop%5D%7D)%60)%3B%0A%0A%20%20%20%20const%20clockRect%20%3D%20clockGroup.append(%22rect%22)%0A%20%20%20%20%20%20.attr(%7B%0A%20%20%20%20%20%20%20%20%22width%22%3A%20%2270%25%22%2C%0A%20%20%20%20%20%20%20%20%22height%22%3A%20windowWidht%20%2F%2010%2C%0A%20%20%20%20%20%20%20%20%22fill%22%3A%20%22EDE9F1%22%2C%0A%20%20%20%20%20%20%20%20%22fill-opacity%22%3A%200.2%0A%20%20%20%20%20%20%7D)%3B%0A%0A%20%20%20%20const%20clockText%20%3D%20clockGroup.append(%22text%22)%0A%20%20%20%20%20%20.attr(%7B%0A%20%20%20%20%20%20%20%20%22x%22%3A%20%2210%22%2C%0A%20%20%20%20%20%20%20%20%22y%22%3A%20windowWidht%20%2F%2011%2C%0A%20%20%20%20%20%20%20%20%22font-size%22%3A%20110%2C%0A%20%20%20%20%20%20%20%20%22font-weight%22%3A%20%22bold%22%2C%0A%20%20%20%20%20%20%20%20%22font-family%22%3A%20%22arial%22%2C%0A%20%20%20%20%20%20%20%20%22line-height%22%3A%201.5%2C%0A%20%20%20%20%20%20%20%20%22letter-spacing%22%3A%205%2C%0A%20%20%20%20%20%20%20%20%22word-spacing%22%3A%205%0A%20%20%20%20%20%20%7D)%3B%0A%0A%20%20%20%20(function%20loop()%20%7B%0A%20%20%20%20%20%20clockText.text(moment().format(%27HH%3Amm%3Ass%3ASS%27))%3B%0A%20%20%20%20%20%20setTimeout(loop%2C%201)%3B%0A%20%20%20%20%7D)()%3B%0A%20%20%7D)%3B%0A%3C%2Fscript%3E%0A%3C%2Fbody%3E%0A%3C%2Fhtml%3

SHIMIZUさんのブログから元になっています: https://shimz.me/blog/d3-js/4360
見た目がとてもいいので使わせもらいました。ありがとうございます!

Nippと同じところ

  • URLにコードを埋め込んでいる

違うところ

Nippがやりたかったのはクライアントだけで完結するRuby環境なので、一番の違いと目的はこの点だと思います。
それ以外の点として、

  • Data URL: Webサーバ不要
  • Nipp: 静的にファイルをホストするためにWebサーバーが必要
    • 補足:プログレッシブウェブアプリ(PWA)のキャッシュが効いたときに、Webサーバも経由しなくなる
  • Data URL: コードの圧縮はしない2
  • Nipp: コードを圧縮する(deflate or LZMA)

個人的な使わけとしては、NippはRubyを使いたいときにして、UIを完全に置き換える例で紹介した場合のものは、Data URLを使いたいかなと思ってます。

おまけ: Data URLのエディタ

まず最初は、Data URL用のエディタはNippで簡単に作成しました。

Nipp: https://nipp.cf/#Data_URL_Editor/es2017/S0hJLEm0KkmtKNHPKMnN0VGpTs1Lzk9JDQ3ydM7PLcjPS80r0SjW1CtKLchJTE7V0FfXT9dRUFc1MlfXrE0AAA==

それで、このNippを使って、Nippを使わずにData URLだけで、使えるエディタを作成します。これで、完璧にData URLのエディタ自体もData URL上で動きます!

Data URI Editor on Data URI

data:text/html,%3Chtml%3E%0A%20%20%3Chead%3E%0A%20%20%20%20%3Ctitle%3EData%20URI%20Editor%3C%2Ftitle%3E%0A%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20textarea%20%7B%0A%20%20%20%20%20%20%20%20width%3A%20100%25%3B%0A%20%20%20%20%20%20%20%20height%3A%2010em%3B%0A%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20iframe%20%7B%0A%20%20%20%20%20%20%20%20width%3A%20100%25%3B%0A%20%20%20%20%20%20%20%20height%3A%20100%25%3B%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%3C%2Fstyle%3E%0A%20%20%3C%2Fhead%3E%0A%20%20%3Cbody%3E%0A%20%20%20%20%3Ctextarea%20id%3D%27editor%27%20placeholder%3D%22Input%20HTML%22%3E%3C%2Ftextarea%3E%0A%20%20%20%20%3Ctextarea%20id%3D%27data_uri%27%3E%3C%2Ftextarea%3E%0A%20%20%20%20%3Ciframe%20id%3D%22preview%22%3E%3C%2Fiframe%3E%0A%20%20%20%20%3Cscript%3E%0A%20%20%20%20%20%20var%20editor%20%20%3D%20document.getElementById(%27editor%27)%3B%0A%20%20%20%20%20%20var%20dataUri%20%3D%20document.getElementById(%27data_uri%27)%3B%0A%20%20%20%20%20%20var%20preview%20%3D%20document.getElementById(%27preview%27)%3B%0A%20%20%20%20%20%20function%20update()%7B%0A%20%20%20%20%20%20%20%20preview.src%20%3D%20dataUri.value%20%3D%20%22data%3Atext%2Fhtml%2C%22%20%2B%20encodeURIComponent(editor.value).replace(%2F%27%2Fg%2C%20%27%2527%27)%3B%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20editor.onkeyup%20%3D%20update%3B%0A%20%20%20%20%20%20update()%3B%0A%20%20%20%20%3C%2Fscript%3E%0A%20%20%3C%2Fbody%3E%0A%3C%2Fhtml%3E

このシンプルなエディタのコードは他のライブラリへの依存がないので、ポータブルです。

コード
<html>
  <head>
    <title>Data URI Editor</title>
    <style>
      textarea {
        width: 100%;
        height: 10em;
      }

      iframe {
        width: 100%;
        height: 100%;
      }
    </style>
  </head>
  <body>
    <textarea id='editor' placeholder="Input HTML"></textarea>
    <textarea id='data_uri'></textarea>
    <iframe id="preview"></iframe>
    <script>
      var editor  = document.getElementById('editor');
      var dataUri = document.getElementById('data_uri');
      var preview = document.getElementById('preview');
      function update(){
        preview.src = dataUri.value = "data:text/html," + encodeURIComponent(editor.value).replace(/'/g, '%27');
      }
      editor.onkeyup = update;
      update();
    </script>
  </body>
</html>

(変更:iframeでプレビューできるようにしました)


  1. 上の記事で紹介されていたアプリケーションの名前 

  2. おそらく現在のところはないと思いますが、;base64部分に他の文字列を指定するような拡張は簡単にできそうな仕様なので、今後できるようになる可能性はあるかもしれません。