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でも指定が必要だったりする部分なので、結構便利だと思う。
余談だが、get
やpost
としてエクスポートせず、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")
.get("http://exmaple.com")
http })()
ほとんどの場合、スクリプト全体がasync関数でラップされていることは問題にならない。
HTMLの最後のほうにあるscript
要素で同期的にロードするようになっている場合は別だが、そうでない限り(例えばdefer
付きでロードしている場合など)処理順序としては基本的に変わらない。
他のファイルで使うためにグローバルエクスポートしたいなら
var http
async function() {
(= await import("fetchwrapper.js")
{http} .get("http://exmaple.com")
http })()
もうひとつは、先にグローバルな名前空間を用意し、そこにインポートしたものを追加する方法だ。
まずa.js
(非モジュール、同期ロード)
var myGlobal
b.js
(モジュール)
import {http} from 'fetchwrapper.js'
.http = http myGlobal
c.js
(非モジュール、defer)
.http.get("http://example.com") myGlobal
で、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'
= `zenity --entry --text="Port to use (default: 8000) in 127.0.1.1"`
port if !port || port.strip.empty?
= 8000
port end
= WEBrick::HTTPServer.new({ DocumentRoot: ARGV[0],
srv BindAddress: '127.0.1.1',
Port: port.to_i })
trap("INT"){ srv.shutdown }
.start srv
というのを用意して、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
世の中ではvar
とconst/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)
})()
ではネストされたスコープのx
は1
ではなく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 = x
でx
にアクセスすることでエラーになる。