JavaScript
ECMAScript

今からでも追いつける! JavaScriptの「標準ライブラリ」を学ぶ

標準ライブラリは、大ざっぱに言えばプログラミング言語に標準で備え付けられている機能群です。多くのプログラミング言語では、形態は様々ですが何らかの形で標準ライブラリが備え付けられています。標準ライブラリはプログラミング言語の一部であり、言語のバージョンアップに伴って標準ライブラリに機能が追加されることは良くあります。

この記事ではJavaScriptにおける「標準ライブラリ」がどんなものなのか、その歴史的経緯なども交えながら解説します。「標準ライブラリ? 何それ」という方も「そんなの基本だろw解説されなくても分かるわw」という方もぜひ一読していってください。

なお、記事執筆時点では標準ライブラリに関することは何一つとして標準化が完了しておらず、内容が今後大きく変化する可能性が十分にあります。この記事で概要を理解したあと、いざ使用する際にはご自分で最新の情報を調べたほうがよいでしょう。


広義の標準ライブラリ

JavaScriptに標準で備え付けられている機能といえば、この記事を読んでいる皆さんはいろいろなものが思い浮かぶことでしょう。例えばMathなどはそれっぽいですね。小数点以下を切り捨てるMath.floorなど、さまざまな関数や定数がMathオブジェクトのプロパティとして提供されています。このMath自体はグローバル変数として存在しています。他にも日時を表現するDateオブジェクト、バイナリデータを扱えるArrayBufferUint8Array、グローバル変数として存在するparseInt関数1などもよく使いますね。このように、JavaScriptの組み込みの言語機能はほとんどがグローバル変数として提供されています。

これらはJavaScript (ECMAScript) の言語仕様で定義されている機能ですから、ここまでの話でいうとこれがJavaScriptの標準ライブラリであると言えるかもしれません。しかし、この記事でいう「標準ライブラリ」はこれのことではありません。このようにグローバル変数を介して提供される言語機能をこの記事では広義の標準ライブラリということにしていますが、この記事で解説したいのはこの広義の標準ライブラリではありません。

今後も様々な機能が追加されていくと期待されているJavaScriptにおいて、このような(グローバル変数を用いた)方式での機能拡張というのは限界が来ています。それを解決するのが、この記事で解説する「標準ライブラリ」、すなわち狭義の標準ライブラリです。なお、広義・狭義という分け方はこの記事独自のものであり、一般に通用する言い方ではないかもしれませんのでご注意ください。


復習:モジュール

今回解説する標準ライブラリにはモジュールが大きく関わっていますので、ちょっとモジュールの解説を挟みます。もう知ってるよという方は適当に飛ばしてください。モジュールはES2015でJavaScriptに追加された言語機能で、import文とexport文という2つの構文が特徴です。ざっくり言えば1つのjsファイルが1つのモジュールとなり、export文ではそのモジュールからエクスポートされる関数やその他の値2を宣言します。そして、import文は他のモジュールからエクスポートされるものをインポート、すなわち取り込んで利用することができます。

以下は2つのファイルからなるモジュール利用例です。


foo.js

export function fooFunction(x) {

return x * 2;
}


index.js

import { fooFunction } from './foo.js';

console.log(fooFunction(123)); // 246


この例ではfoo.jsexport文で関数fooFunctionをエクスポートしています。index.jsfoo.jsからfooFunctionをインポートして利用しています。

今回注目していただきたいのは、import文で読み込む対象のモジュールがどのように指定されているかです。この例ではインポート元を"./foo.js"としており、明らかにこれは同じディレクトリにあるfoo.jsを読み込むという意味ですね。

import文で指定されたインポート元をどのように解釈するかは実行環境に委ねられています。現在主要なJavaScriptの実行環境はざっくりブラウザnode.jsの2つですが、それぞれで可能なインポート元が異なってきます3

ブラウザの場合は話が簡単で、インポート元はURLです。上の例は相対URLと見なせますので、ブラウザで動作します。絶対URLもimport React from "https://dev.jspm.io/react";のように使用できます。

一方、node.jsはもう少し話が複雑です。./foo.jsのような相対パスはnode.jsでも動作する4ほか、./fooのように拡張子を省略できるのが特徴的です。また、import React from "react";のようにパスではなくモジュール名を指定することでnode_modulesから該当モジュールを読み込むことも可能です。一方、http://などのスキームを用いてモジュールをインターネット上から読み込むことはできません。


結局標準ライブラリとは何か

では、いよいよ本題に入ります。

結論を一言で言うならば、この記事で紹介する標準ライブラリ(狭義の標準ライブラリ)は特別な名前で提供されるモジュールです。具体例などはあとでお見せしますが、特別な名前というのはjs:で始まるモジュール名です。標準ライブラリのモジュールは処理系に組み込まれており、特に事前の準備なくimport文で読み込むことができます。例えば、temporalというモジュールが標準ライブラリに含まれているとすると、temporalを使用したい場合は以下のようにします5

// 標準ライブラリのtemporalモジュールからCivilDateをインポート

import { CivilDate } from 'js:temporal';

先ほども説明したように、標準ライブラリのモジュールはjs:モジュール名という名前を用いてimport文で読み込みます。ポイントは、このjs:で始まる形式は上で説明した既存のインポート元指定方法(URLやモジュール名など)には当てはまらないということです6。これにより、モジュールを用いた従来のコードを壊すことなく新しいモジュールを追加できるというわけです。さらに、このjs:という文字列はIANAに登録されることになります。これにより、将来的にjs:で始まるURL(というかURI)が他の用途で使用される事態が回避されます。

標準ライブラリとしては、JavaScript本体の言語機能に加えてブラウザ向けの機能なども標準ライブラリとして扱うことができるという方向で話が進んでいます。そうなると、全てをjs:の中に入れていいのか、それともブラウザ向けはhtml:とかnode.js向けはnode:というように名前空間を分けたほうがいいのかという疑問が発生します。この問題については議論がありますが、まだ答えは出ていないようです。


標準ライブラリのプロポーザル

今さらですが、この記事で述べている標準ライブラリというのは、以下のプロポーザル(現在Stage 1)です。プロポーザルということはまだJavaScriptの仕様に正式採用される前の状態ということであり、Stage 1というのは大雑把に言えばTC39(JavaScript (ECMAScript) の標準化を担当する技術委員会)がその問題領域にやっと興味を持ったという段階です。つまり、JavaScriptの標準ライブラリはまだ仕様の完成には程遠く、実用可能になるまであと何年かかってもおかしくないような状態です。いますぐ実用できるものではないという点は理解しておきましょう。

なお、このプロポーザルでは「標準ライブラリという仕組み」を導入することを議論しているのであり,

「どんなモジュールが標準ライブラリとして今後JavaScriptに組み込まれるのか」はまた別の問題であるということに留意してください。

このプロポーザルを主導しているのは、(例によって)Googleの面々です。これはまだ初期段階のプロポーザルですがGoogleはこのプロポーザルの推進に非常に積極的であり、後で紹介しますがGoogle Chromeはすでに実験的な標準ライブラリの実装を持っています。


標準ライブラリの意義

では、なぜJavaScriptに標準ライブラリが必要なのでしょうか。それは、JavaScriptの機能拡大を促進するためです7。JavaScriptの言語仕様はそこそこ複雑ですが、それは構文や文法という面に重きが置かれている傾向にあります。プログラムを書きやすくするための便利な関数が揃っているかとか、そういう観点ではまだJavaScriptの言語機能は豊富とは言えないでしょう。その結果として、lodashのような便利モジュールが人気を得ています。それの何が問題かといえば、lodashが多くのサイトで使われるということはlodashのソースコードが大量にインターネット回線の上を日々飛び交っているということになります。JavaScriptの機能が拡張されてlodashが不要になればこのトラフィックは削減できるのです。

また、少し上で説明したように、従来JavaScriptに組み込みの言語機能は主にグローバル変数を用いて提供されてきました。そうなると、新機能を追加したい場合には新しいグローバル変数を増やすことになります。例えば、最近ではES2017の共有メモリに関する機能追加でSharedArrayBufferAtomicsというグローバル変数が追加されました。

この方式では、既存のコードの動作に影響が出るかもしれない、すなわち後方互換性が失われるかもしれないという問題があります。例えばJavaScriptは最近globalというグローバル変数を追加しようとしましたが、それだと後方互換性が壊れてしまうことが判明したのでglobalThisという名前に変更になりました。(これについては筆者の別の記事で解説していますのでぜひご覧ください。)

JavaScriptは後方互換性を重視する言語ですから、それが壊れる心配がある状態ではJavaScriptへの機能追加の速度が低下します。実際、上記のglobalは後方互換性が壊れた騒動のおかげで標準化が当初より数年くらい遅れています。

この問題は、追加機能をグローバルに追加するのではなく新しいモジュールとして実装してそこからimportする方式にすることで回避できます。追加された新モジュールを既存コードがimportしていることはありえないためです8

また、グローバル変数が増えるとJavaScript実行コンテキストの初期化のコストが増しますので、それを回避できるのも利点となります。モジュールとして機能が用意されていれば、オーバーヘッドはそれがimportされたときしか発生しません9


標準ライブラリの具体例

すでに説明されているように、標準ライブラリはまだ標準化の途上にある仕様です。しかし、早速標準ライブラリに追加されるべきモジュールが議論されています。


KV Storage

KV Storageは、ブラウザ向けの新しいKey-valueストアです。イメージとしてはlocalStorageがモダンになってリメイクされた感じです。もちろんこれも中身が確定していない提案段階の仕様ですが、記事執筆時点で既にGoogle Chromeに実験的な実装が搭載されており、実際に試すことができます。これはthe Web's First Built-in Moduleと銘打って宣伝されていることからも分かる通り、標準ライブラリのモジュールとして提供されています。使い方についてはGoogleの紹介記事それを日本語に直したQiita記事がありますのでそちらを参照してください。1行だけ引用しますが、以下のようにstd:kv-storageモジュールから機能をインポートできます。

import {storage, StorageArea} from 'std:kv-storage';

個人的にはプレフィクスはjs:のはずだったのにこれはstd:な点とかが多少気になりますが、前者についてはそもそもプレフィックスが安定していないことが原因なのでそのうち直るかもしれない10と思っています。

このKV Storageは、この記事に書いてあることの中で唯一今すぐ試せる機能です。試してみるにはGoogle Chromeのexperimentalフラグをオンにする必要があります(詳しくは上記のリンク先を参照してください)が、興味がある方は試してみるのもよいでしょう。


Accessing the originals

これは新しくてまだTC39プロセスに乗ってすらいないただのアイデアですが、グローバル変数として提供されてきた従来の言語機能もモジュールとして標準ライブラリに載せようという提案です。

この提案の背景には、そもそもグローバル変数で提供されるAPIが信用できない場合があるという問題があります。JavaScriptの初期の仕様は処理系から提供されるオブジェクトも自由に書き換え可能なゆるいものであり、JavaScriptは後方互換性を保つために現在までそのゆるさを引きずっています。それゆえ、処理系により提供されているAPIが書き換えられている可能性があるのです。

例えば、Array.isArrayは与えられたオブジェクトが配列かどうかを調べる組み込み機能です。

/** 与えられたオブジェクトが配列なら要素を追加する関数 */

function addToArr(obj) {
if (Array.isArray(obj)) {
obj.push(123);
}
}

addToArr([1, 2, 3]); // pushされる
addToArr({foo: "bar"}); // 何も起きない

ここで定義したaddToArr関数はif文でobjが配列かどうか調べることで、obj.pushでエラーが発生するのを防いでいます。特に問題のあるコードには見えません。

ところが、このコードは次のようにするとエラーが発生してしまいます。

/** 与えられたオブジェクトが配列なら要素を追加する関数 */

function addToArr(obj) {
if (Array.isArray(obj)) {
obj.push(123);
}
}

// Array.isArrayを書き換える!!!!!!
Array.isArray = ()=> true;

addToArr([1, 2, 3]); // pushされる
addToArr({foo: "bar"}); // ここでエラーが発生

このコードではArray.isArrayが常にtrueを返すように変更してしまうことで、addToArrを壊しています。このことから分かるように、addToArrが正しいといえるのはArray.isArrayが書き換えられていないという保証があるときだけなのです。

この例のようにaddToArrの定義のすぐ下でArray.isArrayを書き換える馬鹿はいないと思いますが、Arrayがグローバル変数であるということは全く関係ない別のところでArrayが書き換えられている可能性があるわけで、addToArrがエラーを起こさないことを確信するのがたいへん難しくなっています。

今回の提案“accessing the originals”を使うと、このコードは次のように書き換えられます。

import { isArray_static } from "std:global/Array";

function addToArray(obj) {
if (isArray_static(obj)) {
obj.push(123);
}
}

どう変わったかというと、std:global/Arrayという組み込みモジュールからisArray_static関数を読み込んでそれをArray.isArrayの代わりに使用しています。ざっくり言えば、std:global/ArrayArrayというグローバル変数が持つ機能を提供するものであり、Arrayが持つプロパティであるisArrayisArray_staticという名前でエクスポートされています。このisArray_staticは仕様で定義されたArray.isArrayそのものであり、外的要因によって書き換えられていないことが保証されています。これにより、Array.isArrayが想定通りの動作をしない可能性があるという問題を解決しています。

ここで、十分賢い読者の方は上記のコードにまだ懸念点があることにお気づきになったでしょう。それは、Array.prototype.pushが書き換えられているかもしれないという問題です。上記のコードは次のように攻撃されると想定通りの動作をしません。

import { isArray_static } from "std:global/Array";

function addToArray(obj) {
if (isArray_static(obj)) {
obj.push(123);
}
}

// Array.prototype.pushを消してしまう
Array.prototype.push = null;

addToArray([1, 2, 3]); // エラーが発生

これに対する解決策は、もちろんArray.prototype.pushも標準ライブラリから取得することです。実は、Array.prototype.pushの本来の中身はpushとしてstd:global/Arrayからエクスポートされています。よって、以下のようにすればよいでしょう。

import { isArray_static, push } from "std:global/Array";

function addToArray(obj) {
if (isArray_static(obj)) {
push.call(obj, 123);
}
}

// Array.prototype.pushを消してしまう
Array.prototype.push = null;

addToArray([1, 2, 3]); // OK!

pushはメソッドとして使わなければいけないのでthisobjになるようにしなければいけません。そのためにcallメソッドを使っています。

では、これでaddToArrayは完璧ですね。

……と言いたいところですが、実はそうではありませんね。勘の良い読者の方はお分かりの通り、Function.prototype.callが書き換えられていたらpush.callが失敗する可能性があります。ということで、Function.prototype.callも標準ライブラリからインポートしましょう。

import { isArray_static, push } from "std:global/Array";

import { call } from "std:global/Function";

function addToArray(obj) {
if (isArray_static(obj)) {
call.call(push, obj, 123);
}
}

push.call(obj, 123)callを用いて書き換えるには、callthispushである必要があります。よって、call.call(push, obj, 123);というようにこれを呼び出す必要がありますね。

ここで、いやちょっと待てよと思わなかった方は思慮が不足しています。call.callFunction.prototype.callなのでこれでは問題が解決していません。Function.prototype.callが汚染されているという不安を解消するためにFunction.prototype.callを使用するという本末転倒の状態になっています11

幸いにして、ES2015ではFunction.prototype.callへの依存を回避する手段であるReflect.applyが追加されました。これを代わりに使えば解決です。今回のサンプルの最終形は次のとおりです。

import { isArray_static, push } from "std:global/Array";

import { apply } from "std:global/Reflect";

function addToArray(obj) {
if (isArray_static(obj)) {
apply(push, obj, [123]);
}
}

このサンプルでは、組み込みの機能は全て標準ライブラリから取得しています。よって、外部で何がどう書き換えられていようとこのプログラムは影響を受けません。これは非常に堅牢なプログラムであると言えます。このように、std:global以下のモジュールを標準ライブラリに追加するこの提案によって、外的要因によって壊れないプログラムを書くことができるのです。

しかし、これは元々のプログラムに比べて随分回りくどいというか物々しい感じがします。こうまでしないと安全性を担保できないJavaScriptという言語にそもそも問題があるのではと言われると反論できません。しかし、需要は確かにありそうな感じがします。

ここでお伝えしたいことは、とにかく標準ライブラリにはこのような使い道もあるということです。モジュールからインポートという形を取ることで、目的の機能が外的要因によって(グローバル変数を介することによって)書き換えられるのを防いでいます。


Polyfillはどうするのか

標準ライブラリが導入された後に問題となると考えるのは、Polyfillをどのように作って利用していくかということです。標準ライブラリに含まれるモジュールが今後追加されていくとしても、実行環境によってその実装時期には差がでるでしょう。例えばjs:temporalモジュールが追加されたとしても、未対応のブラウザは以下のコードに対してエラーを発生させるでしょう。

import { CivilDate } from "js:temporal";

これに対処するためには「知らないモジュール名が指定されていたら代わりにPolyfillを読み込む」というメカニズムが必要になります。

現状では、これに対処するための複数の提案がありますのでこれを紹介します。背景にはLayered APIというものがありますがここではこれには深入りしません。


Layered APIs fallback syntax

この提案では、以下のような記法でフォールバック先を指定可能にするという案があります(リンク先を読めば分かる通りあくまで候補の一つですが)。

import { storage } from

"std:kv-storage|https://some-cdn.com/kv-storage.js";

ポイントは、|std:kv-storagehttps://some-cdn.com/kv-storage.jsの2つのモジュール名を区切っている点です。この記法は、前者を読み込んでみて無理だったら後者を読み込むという意味になります。これにより、std:kv-storageをまだ知らないブラウザは後者のURLを読み込んでPolyfillを適用できるというわけです(この場合、|記法にそもそも対応していないブラウザでは動作しないという問題点がありますが)。


import maps

Web標準の方面からも別の提案が出ています。これは前述のKV Storageと一緒にすでにChromeで実験的に実装されています。

import mapsは、HTMLファイル中で以下のように指定します。

<script type="importmap">

{
"imports": {
"std:kv-storage": [
"std:kv-storage",
"https://some-cdn.com/kv-storage.js"
]
}
}
</script>

type="importmap"という特別なtype属性を持ったscript要素がimport mapとして機能します。中身はJSONです。見れば何となくわかる通り、import mapはモジュール名を別のモジュール名やURLに読み替えさせる機能を持ちます。上のimoort mapは"std:kv-storage"というモジュール名が指定されたらまずstd:kv-storageというモジュールを試し、だめだったら/node_modules/kvs-polyfill/index.mjsというモジュールを試すという意味になります。前述の|記法とやっていることは同じですね。

こちらの方法は肝心のimport文がimport from "std:kv-storage";のままでいいという利点がありますが、Webプラットフォームでしか通用しないという欠点があります。そもそもimport mapsを知らないブラウザにとっては意味がないという点はそんなに変わりませんね12


標準ライブラリのimport方法の他の候補

上で説明した通り、標準ライブラリをインポートするには次のようにjs:プレフィックスを持ったモジュール名をimport文で指定します。

import { CivilDate } from "js:temporal";

これが一番支持を得ているようですが、他の候補も以前に議論されており、完全に確定しているわけではありません。std:js:もこの議論で両方姿を現しています。

いずれにせよ、重要なことは従来の(ユーザー定義の)モジュールとは区別できる必要があるという点です。


import元を文字列以外にする案

これは、以下のようにfromの右を文字列以外にすることで構文レベルで別物にするという案です。

imoort { CivilDate } from std.temporal;

stdというグローバル変数がありそうな感じに見えるのが微妙です13

また、以下のような案もあります。

import { CivilDate } from <temporal>;


npmのscoped package を流用する案

以下のように、モジュール名の前に@std/などをつける案です。

import { CivilDate } from "@std/temporal";

これは代替案の中では比較的人気がある案ですが、標準ライブラリとその他のモジュールを区別できるかどうかという点において一段劣っています(一応@stdについては大丈夫なようです)。一方で、Polyfillが比較的容易であるという強みがあります。


URLにする案

import { CivilDate } from "https://www.ecma-international.com/ecmascript/temporal";

長くて覚えられないですが、公式がPolyfillを提供したりするのが楽かもしれません(node.jsが少しつらそうですが)。


まとめ

この記事ではJavaScriptにおける標準ライブラリの概念を説明しました。標準ライブラリは仕様に含まれているモジュール群であり、importするときはjs:(あるいはstd:)で始まるモジュール名を使うのが特徴です。標準ライブラリの概念により後方互換性の心配なく言語機能を追加できるようになるのに加え、機能追加によるオーバーヘッドの対策にもなります。

js:std:でプレフィックスが揺れているなどまだまだ不安定なこの提案ですが、いずれJavaScriptやWebの新機能は(文法的なものを除いて)標準モジュールを介して提供される未来が来ると考えられます。ぜひその時に備えておきましょう。





  1. 新しいNumber.parseIntのほうが推奨されますが、グローバル変数のparseIntのほうが広く使われているように思えます。後方互換性の話もありますし。 



  2. 正確にはエクスポートされているのはバインディングであると言うべきですが。 



  3. 現段階だとこれらの実行環境で直接モジュールを動かすよりも、WebpackやRollupなどのバンドラへの入力として使うほうが馴染み深いという方が多いでしょうが、その話はややこしくなるので今回は避けることにします。 



  4. ただし、node.jsでは"type":"module"を持つpackage.jsonを用意するかモジュールの拡張子を.jsではなく.mjsにする必要があります。 



  5. 標準ライブラリへの導入が確定しているモジュールなどというものは存在しないので仮にtemporalを使用しています。temporalのプロポーザルに“a built in module”と書いてあったので多分標準モジュールになるんじゃないかと思いますが、よく分かりません。 



  6. あとでもう少し詳しく説明しますが、標準ライブラリのモジュールを表す他の案としては全く新規のもの以外にもnpmのscoped modulesの記法を流用した@std/temporalのようなものが提案されています。 



  7. The goal of this proposal is to define a mechanism for enabling a more extensive standard library in JavaScript than is currently available.” 



  8. 正確に言えば、ダイナミックimport()を用いて標準ライブラリを読み込もうとするコードがもし事前にあった場合には動作に影響を与える可能性がありますが、その場合は明らかに標準ライブラリの存在を意識していますし、自己責任と言えるでしょう。 



  9. they won't add any overhead to starting up a new JavaScript runtime context (e.g. a new tab, worker, or service worker), and they won't consume any memory or CPU unless they're actually imported.” 



  10. KV Storageのページにも、std:の部分は変わるかもしれないという註釈があります。 



  11. 一応補足すると、Function.prototype.applyを使う手もありますが、こちらも一緒に書き換えられる可能性があり大して意味はありません。 



  12. import mapsの場合はPolyfillのURLをキーとして先に標準のstd:kv-storageを読み込むようなimport mapsを書いておいて使う側はPolyfillのURLをimportで読むようにしておくことで、import mapsを知らないブラウザは普通にPolyfillを読み込んで、import mapsを知っていてかつPolyfillが必要ないブラウザは組み込みのものを使うようにすることも可能です。このあたりのことを自動的にやってくれる次世代のバンドラ(?)を作ったら受けるんじゃないかという気もします。調べていないので既にあるかもしれませんが。 



  13. The downside of using std.________ is that it start to look like a global object that is also available in other contexts.”