Chienomi

JavaScriptのHTTPライブラリとESモジュール

開発::library

fetchwrapperはJavaScript用のHTTPクライアントライブラリである。

今回はコードではなく、このソフトウェアの周辺の話をしていこう。

JavaScriptとHTTPクライアント

ひょっとしたら未だにJavaScriptのHTTPクライアントといえばXHR[^xhr]と考えている人もいるかもしれない。

まぁそれはともかくとして、JavaScriptのHTTPクライアントライブラリと言えばもう終了したRequest、あるいは今ならAxiosあたりが有名だろうか。

もともとJavaScriptにHTTPクライアントライブラリが望まれたのは、XHRの使いづらさに由来した。 これを低減するためだけにPrototype.jsやJQueryを使うこともあっただろう。 Requestあたりはその文脈だと思って良い。

ところが、現代においてはfetch()という標準関数が存在する。 Axiosはいくらか起用なことをするが、基本的なものの大部分の機能はfetch()にある。 Axiosはクライアントサイドで使うには重すぎて、しかも通常はトランスパイルして使用される。 深く考えずに採用されるケースも多いが、依存関係を減らす意味でも、軽量化の意味でも、必要ないのであればfetch()を使ったほうが良いだろう。

fetch()をラップする必要があるわけ

fetch()を使えと言った舌の根も乾かぬうちにではあるが、Axiosと比べてfetch()が使いづらいのは間違いない。

例えばPOST, PUTにおいてリクエストボディオブジェクトの透過的変換はされないので、{abc: 123}bodyにすると[object Object]が送られることになる。

最も使いづらい部分として、fetch()Promise<Response>を返すというのがある。 Responseはストリームを持っているので、ストリームを読むためのメソッド呼びが必要なのだが、これが非同期関数なので、一発でレスポンスまで取得しようとすると

const data = await (await fetch(URL)).json()

と書く必要がある。

さらに、Response.json()は単純にJSON.parse()に読んだ結果を渡す。 このため、意図せずHTMLが返ってきた場合はもちろん、空ボディが返ってきた場合も(例えステータスが204であっても)例外を発生させる。

このため、現実的にはResponse.text()してからJSON.parse()した方が安全なのだが、それをしようとするとそれだけで最低限

let data = await response.text()
try { data = JSON.parse(data) } catch(e) {}

と書く必要があり、どうしても汚くなる。

空レスポンスを置いておけばResponse.json()でエラーになるということは意図していない状況、つまりはエラーなのだから例外を投げるで良いじゃないかと思うかもしれないが、fetch()4xx, 5xxステータスが返ってきたときは例外にならない。 つまり、「意図せぬレスポンスフォーマット」と「意図せぬレスポンス」を単純にまとめられる挙動にはなっていない。

HTTPリクエストがどのような形式で、どのタイミングで値をセットしたいかというのは好みによるところが大きいが、個人的には

get("http://exmaple.com")
post("http://example.com", {abc: 123})

のようなフォーマットが好みである。 そこで、なるべくそのように書けるようにした。 READMEに記載している形式は

await http.get("https://example.com")
await http.get("https://example.com", options: {query: {foo: "abc123"}})
await http.post("https://example.com/a/b/c", {data: 12345}, options: {query: {foo: "abc123"}})

である。 POSTでレスポンスを取ってエラーもケアするならば

try {
  const data = await http.post(URL, BODY)
} catch(e) {
  console.error(e)
}

のようにすれば良い。シンプルだ。

地味に便利な機能として、bodyが文字列でなく、特に何も指定していない場合は自動でContent-Type: application/jsonがつく。 Axiosでも指定が必要だったりする部分なので、結構便利だと思う。

余談だが、getpostとしてエクスポートせず、httpにラップしてエクスポートしているのは、deleteが演算子として存在しており、グローバルな名前として使えないからだ。

クライアントサイドでのESモジュール

JavaScriptの形式にはCommonJSとESModulesがある。 が、ここでの焦点はクライアントサイド、つまりウェブブラウザ内であるため、「モジュールかそうでないか」の話になる。

結果的に言えばモジュールはESModulesだと言えるが、モジュールでなくてもESなのがブラウザのJavaScriptだ。

fetchwrapperはexport文を使っており、これがモジュールでしか動作しない。 このため、モジュールとして使うことが前提になっている。

モジュールかそうでないかを区別するために、拡張子.js(もしくは.cjs)と.mjsを使うと認識している人もいるかもしれない。 だがこれは、Node.jsがそのようにしているだけで、ウェブサーバーの世界では正しくない。 拡張子は.jsにしておくのが無難だ。

モジュールのロードはimport文、またはimport()関数によって行う。 または、ロードされるスクリプトがモジュールであることを認識させるためにはscript要素のtype属性にmoduleという値を与える。

そして、モジュールをロードするためのimport文はモジュールからしか使えない。 なおかつ、モジュールはグローバル空間にエクスポートできないため、非モジュールからモジュールが定義したものを使えない。

一見、手詰まりで使いようがないように見える。 が、実は簡単な方法が2つある。

ひとつは、async関数内でimport()関数を使うことだ。

(async function() {
  const {http} = await import("fetchwrapper.js")
  http.get("http://exmaple.com")
})()

ほとんどの場合、スクリプト全体がasync関数でラップされていることは問題にならない。 HTMLの最後のほうにあるscript要素で同期的にロードするようになっている場合は別だが、そうでない限り(例えばdefer付きでロードしている場合など)処理順序としては基本的に変わらない。

他のファイルで使うためにグローバルエクスポートしたいなら

var http
(async function() {
  {http} = await import("fetchwrapper.js")
  http.get("http://exmaple.com")
})()

もうひとつは、先にグローバルな名前空間を用意し、そこにインポートしたものを追加する方法だ。

まずa.js(非モジュール、同期ロード)

var myGlobal

b.js(モジュール)

import {http} from 'fetchwrapper.js'

myGlobal.http = http

c.js(非モジュール、defer)

myGlobal.http.get("http://example.com")

で、HTML的には

<script src="a.js"></script>
<script src="b.js" type="module"></script>
<script src="c.js" defer></script>

となる。

ちなみにこう書いた場合、現実的には書いた通りの順序でロードされ、c.jsからmyGlobal.httpが見えないということは起きない。 が、特にそれが起きないことが保証されているわけでもないため、このようにファイル分けするのはクリックイベントリスナーのように、実際に実行されるタイミングは遅延され、なおかつロードが間に合ってなければ再試行可能なものに限ったほうが良い。

もっとダーティな方法で良ければ、モジュールからもwindowオブジェクトが見え、windowオブジェクトのプロパティはグローバルな名前になるため、

import {http} from 'fetchwrapper.js'

window.http = http

としても良い。

で、一見解決したように見えるが、まだ問題がある。 ブラウザはモジュールを絶対にhttp(s)プロトコルでしかロードしないのである。 つまり、fileプロトコルを用いるローカル確認ができず、ウェブサーバーが必要になる。

これに対する私のソリューションがNemo Actions。 私はLinuxでCinnamonを使っているので、Nemo Actionsでまずserv-http-here.rbとして

#!/usr/bin/ruby
require 'webrick'

port = `zenity --entry --text="Port to use (default: 8000) in 127.0.1.1"`
if !port || port.strip.empty?
  port = 8000
end

srv = WEBrick::HTTPServer.new({ DocumentRoot: ARGV[0],
                                BindAddress: '127.0.1.1',
                                Port: port.to_i })
trap("INT"){ srv.shutdown }
srv.start

というのを用意して、90-serv-http-here.nemo_actionとして

[Nemo Action]
Name=Serv HTTP here
Comment=Start Webrick Server and serv this directory.

Exec=<serv-http-here.rb "%P">
Terminal=true

Icon-Name=server

Selection=Any
Extensions=any;

とすることで右クリックから一発でそのディレクトリをルートとするウェブサーバーを立ち上げられるようにしている。

注意点として、サーバーはRubyで書かれているがzenityを使っているためLinux向けであること、Nemo Actionsを使っているためこれはCinnamon向けであること、そしてバインドしているアドレスは(127.0.0.1ではなく)127.0.1.1であることがある。

var, const, let

世の中ではvarconst/letを混ぜてはいけないと言われているらしい。

が、別にそんなことはない。 それを「やめましょうね」と言われるのは、これらに対して理解度の低いプログラマが事故る可能性の高いややこしいものであるからだ。 ちゃんと理解していれば、混ぜて使い分けて問題は全く生じない。

const/letのスコープは、宣言した場所からブロックの末尾までである。 ブロックの外側で宣言された場合はファイル内となる。 これはとてもわかり易いだろう。

(function() {
  { // ブロックの始点
    const a = 1 // 宣言でここから有効
    console.log(a) // 1
  } // スコープここまで
  console.log(a) //エラーになる
})()

varのスコープは、宣言された関数内である。 var宣言が関数の外で行われた場合はグローバルになる。

スコープが「宣言された関数全体」なので、「後で宣言されても関数の先頭から有効」である。 次のコードは当然ながらReferenceErrorになるが

(function() {
  console.log(a) //エラー
})()

console.log()の後ろで宣言してもエラーにならない。

(function() {
  console.log(a) //エラーにならない!!
  var a
})()

このため、混乱を避けることと、宣言忘れを避けるため、varに関しては関数の先頭でその関数で使うすべての名前を宣言しておいたほうが良い。

モダンJavaScriptでvarを使うのは、ほとんどの場合グローバルエクスポートのためである。 関数内で使う場合は先頭でletで宣言しておけば良い。

唯一便利な点として、letは再宣言するとエラーになるが、varはならないため、letで宣言する位置で困るようなコード(条件式によって異なるブロック内で宣言されてしまうケース)ではvarを使うとすっきり書ける場合があるが、そのようなコードは整理すればletを宣言すべき位置が明確になる場合が多い。

モダンJavaScriptはletよりconstを好むが、関数言語的なテイストを入れたいだけというような話なので、無理にconstにする必要はない。

さて、逆に明確に混在させるのを避けたほうが良いケースがある。 それは、「同一の名前を有効なスコープの中で混ぜることだ。

例えば、次の例はvarで宣言された内側でconstで宣言している。

(function() {
  var x = 1
  ;
  (function() {
    const x = 2
    console.log(x) // 2
  })()
  console.log(x) // 1
})()

これは有効なコードだ。 逆にconstで宣言された内側でvar宣言することもできる。

(function() {
  const x = 1
  ;
  (function() {
    var x = 2
    console.log(x) // 2
  })()
  console.log(x) // 1
})()

これは、xが何を指しているどういうスコープのものかが極めて不明瞭になるのでやめたほうが良い。 もっとも、それ自体は「constで宣言された内側でletで宣言する」でも同じことが言える。

(function() {
  const x = 1
  ;
  (function() {
    let x = 2
    console.log(x) // 2
  })()
  console.log(x) // 1
})()

考え方にもよるが、この逆、「letで宣言された内側でconstで宣言する」は問題ないし、割と普通だ。 が、実はJavaScriptではちょっと変な話になる。

一部の言語は、内側のスコープで同名の変数を宣言したときに、そのスコープにローカルな変数を生成するのではなく、そのスコープにローカルな値を生成するという挙動のものがある。この場合、変数で宣言したものを定数で宣言すると、「このスコープではこの名前に変更を加えない」という意味で通用することがある。

が、JavaScriptはそうではなく、letだろうがconstだろうがvarだろうが、ローカルスコープを作った場合は外側のスコープは見えなくなる。つまり、

(function() {
  var x = 1
  ;
  (function() {
    var x = x
    console.log(x)
  })()
  console.log(x)
})()

ではネストされたスコープのx1ではなくundefinedになる。 だから、JavaScriptにおいては自身が宣言した名前のスコープの外側の状態は一切気にしなくて良いのだ。 このため、別に「constで宣言された名前を内側でvarしたっていいじゃん!」は普通に成立する主張なのだが、「そもそも同じ名前をネストした名前空間に入れるとわかりにくい」という話に行き着くだろう。

とはいえ外側の名前には干渉しないため

for (const i of [1, 2, 3]) {
  for (const i of [2, 3, 4]) {
    console.log(i)
  }
}

のようにイテレータ変数の重複解消が面倒なときには多少役に立つ。

むしろその意味では、let, constは実はきわどい挙動を持っている。 例えば次のコード

(function() {
  const x = 1
  ;
  (function() {
    console.log(x)
    let x = 2
    console.log(x)
  })()
  console.log(x)
})()

ネストされた関数はクロージャであるため、その関数で改めて宣言されなければ外側のxが見えている。 ので、最初のconsole.log(x)1になるのが自然だし、そうなる言語が多い。

が、JavaScriptではこれはエラーになる。 初期化する前にxにアクセスした、というのだ。

じゃあ最初のconsole.log(x)xが見えていないのかというとそんなことはなく、let x = 2を消すとちゃんと動作する。

これは型チェックと同じような、一種の安全機構だ。 だが、この挙動のために先程のvarのような

(function() {
  let x = 1
  ;
  (function() {
    let x = x
    console.log(x)
  })()
  console.log(x)
})()

と書くとlet x = xxにアクセスすることでエラーになる。