0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

const top と書いたら画面が真っ白になった。windowの組み込み名1,235個に let・var・function を当てて測ってみた

0
Posted at

はじめに

自分で作っているHTMLテンプレートのスクリーンショットを、ヘッドレスのChromeで撮ったところ、画面が真っ白だった。HTMLの中身は正しい。CSSも読み込めている。それなのに、JavaScriptが組み立てるはずのヘッダーが1つも出ていない。

コンソールを開くと、エラーは1行だけだった。

Uncaught SyntaxError: Identifier 'top' has already been declared

原因は、<script> の一番上に書いた const top = document.querySelector(...) だった。名前を hdr に変えたら、ページは出た。メモには「top parent name status length self window location をトップレベルの変数名にしない」と書き残した。

ただ、後からこのメモを見直して、引っかかった。8個の名前を並べたが、どの宣言でどう壊れるのかを測っていない。const でだけ落ちるのか、var でも落ちるのか。落ちずに、黙って別の動きをする名前はないのか。

この記事は、その測り直しの記録だ。ChromeとWebKit(Safariのエンジン)の2種類で、window が最初から持っている名前の全部に let・class・var・function を当てた。結果、メモの8個のうち3個しか const では落ちなかった。落ちない5個のほうに別の落とし穴があった。

この記事で分かること

  • クラシックスクリプトの最上位で let・const・class・function が SyntaxError になる名前は、Chrome 155 と WebKit の両方で同じ7個だった(top window document location undefined NaN Infinity)
  • var は落ちない。ただし Chrome では153個、WebKit では146個の名前で、代入が黙って無視された(var history = [1, 2] の history は配列にならない)
  • 無視される名前のうち28個(WebKitでは24個)は、'use strict' を付けた途端 TypeError で止まる
  • name と status は代入すると文字列に変わる。function 宣言は、7個以外の全部を上書きできる
  • 最初のメモは8個を挙げていたが、const で落ちるのはそのうち3個だけだった。メモを訂正した

目次


実測した環境

測定は2026年10月、1台のMac(macOS 26.5、arm64)で行った。

対象 バージョン 備考
Chrome 155.0.8059.84 --headless=new で実行
WebKit macOS 同梱の WKWebView Swift 6.3.2 の小さな実行ファイルから読み込み。Safari本体ではない
Firefox 未測定 この環境に入っていない
Node.js 未測定 この環境に入っていない(ブラウザの window の話なので対象外)

Object.getOwnPropertyNames(window) が返す名前のうち、変数名として書ける形のもの(英数字・_・$)を全部調べた。Chrome は 1,235個、WebKit は 1,028個。共通して存在するのは941個だった。

名前の数はエンジンの版や、拡張機能・実験フラグでも変わる。この記事の数字は「この2つの環境で測った値」だ。


測定のやり方

1つの名前につき、新しい空の iframe を1枚作り、その中の <script> で1行だけ実行した。

const f = document.body.appendChild(document.createElement('iframe'));
const w = f.contentWindow;
w.addEventListener('error', e => { /* メッセージを記録 */ e.preventDefault(); });
const s = w.document.createElement('script');
s.textContent = 'let top = 1;';   // ← ここを名前ごとに差し替える
w.document.head.append(s);

iframe を毎回作り直すのは、前の試行が後の試行を汚さないためだ。最初は同じページで続けて試したら、let Object = 1; が成功してしまい、次の行で Object.getOwnPropertyDescriptor is not a function と出て全体が止まった。let は「書き換えられる組み込み名」の上に、影を作るだけで成功する。この性質は後で効いてくる。

試した宣言は4種類。

宣言 書いたコード 見たもの
let / const let N = 1; エラーが出たか
class class N {} エラーが出たか
var var N = 123; window.N が 123 になったか
function function N() {} window.N が関数になったか

var の結果は3つに分けた。assigned(123が入った)、ignored(実行しても変わらなかった)、changed(変わったが123ではない)。'use strict' を付けた版も別に測った。


再現した症状

まず、冒頭の症状を最小の形で再現した。iframe の中に、次の3つの <script> を並べる。

<div id="x">orig</div>
<script>const top = document.getElementById('x'); top.textContent = 'changed';</script>
<script>window.__second = 1;</script>

図1

Chrome でも WebKit でも、1つ目の <script> は1文も実行されなかった。'changed' に書き換わらず、__second は立った。つまり止まるのはそのスクリプトタグ全体で、ページ全体ではない。ただし、スクリプトが組み立てるはずの画面は何も出ないので、見た目は真っ白になる。

const を var に変えると、エラーは消える。ところが、top.textContent = 'changed' はやはり効かない。var top = el; の代入が黙って捨てられ、top はウィンドウのままだからだ。エラーが出ないぶん、こちらのほうが気づきにくい。


仕組み

挙動は、ECMAScript の仕様で説明できる。クラシックスクリプトを実行するとき、グローバルオブジェクト(ブラウザでは window)に対して宣言を作る手順がある。その中に、次の判定がある。

  • let・const・class の名前に対して、window が「作り直せない(non-configurable)自前のプロパティ」を持っていたら SyntaxError
  • var は、すでに同名のプロパティがあれば、宣言そのものは何もしない。そのあとの = 123 が、普通の代入として window に流れる
  • function は、同名のプロパティが作り直せる(configurable)ものなら、データプロパティとして作り直す

つまり let が落ちる名前は、window の自前プロパティのうち configurable が false のものと一致するはずだ。実測でそうなった。Chrome では次の7個だけが false だった(測定用ページ自身が作った関数を除く)。

Infinity  NaN  undefined  window  document  location  top

window・document・location・top は、WebIDL で [LegacyUnforgeable] が付いている属性だ。作り直しも上書きもできないように作られている。Infinity・NaN・undefined は、JavaScript 自身が持つ定数だ。

図2

var で値が入るかどうかは、別の性質で決まる。代入が window のプロパティの setter に流れるので、setter の有無と中身で結果が変わる。

  • setter がない getter だけのプロパティ(history など)は、普通のスクリプトでは無視される。strict モードでは TypeError
  • setter はあるが、関数以外を受け取ると null に変える(onclick など)。無視されたように見える
  • setter が受け取った値を文字列に変える(name と status)

function の宣言は代入ではなく、プロパティの作り直しなので、setter を通らない。この違いが、後の表で var と function の結果が割れる理由だ。


宣言4種の結果

全名前の集計は次のとおり。location は var で代入すると画面が遷移するおそれがあるので、var と function の試行から外した(let const function class は別に測り、エラーになった)。

図3

宣言 Chrome 155(1,235個) WebKit(1,028個)
let / const が SyntaxError 7個 7個
class が SyntaxError 8個(上の7個+eval) 8個
var が assigned(123が入った) 1,079個 879個
var が ignored(効かない) 153個 146個
var が changed(文字列に変わった) 2個(name status) 2個(同じ)
function が上書きに成功 1,228個 1,021個

class eval {} だけが別の理由で落ちる。class の本体は常に strict モードなので、eval という名前そのものが禁止されているからだ。エラー文も違い、Identifier 'eval' has already been declared ではなく Unexpected eval or arguments in strict mode だった。

2つのエンジンで、落ちる7個は名前まで完全に同じだった。差が出たのは、var で無視される名前の数だけだ。


varが黙って無視される153個

var が ignored になった名前を、無視の仕方で分けた。

図4

Chrome の153個の内訳は、on で始まるイベントハンドラ属性が125個、それ以外が28個。WebKit は146個で、on… が122個、それ以外が24個だった。

  • on… の125個(WebKit 122個):var onclick = 123; は、123が関数ではないので null に変わる。もともと null だったので、実行前後で見た目は同じになる。'use strict' を付けても TypeError にはならない
  • それ以外の28個(WebKit 24個):getter だけで setter がないプロパティ。普通のスクリプトでは黙って無視され、'use strict' を付けると全部エラーで止まった(Chrome で確認したメッセージは TypeError)。history navigator localStorage sessionStorage crypto caches closed などが入る

'use strict' で止まる名前は、テンプレートを strict モードに切り替えた日に突然見つかる。普段はエラーが出ないので、切り替えのコミットを疑う前に、この28個を疑える。


よく使う名前の一覧

変数名として思いつきやすい名前を、両エンジンの結果でまとめた。ここに載せた行は、Chrome と WebKit で結果が1つも違わなかった(external だけは WebKit に存在しない)。

図5

注目したのは次の点だ。

  • let / const で落ちる:top window document location
  • var が効かない:history closed navigator crypto caches localStorage など。var history = [1, 2]; の直後に Array.isArray(history) を調べると false、typeof history は 'object'(History のまま)だった
  • var が文字列に変わる:name status。var name = { a: 1 }; の直後に typeof name を調べると 'string'、中身は '[object Object]'
  • function history() {} は成功し、typeof window.history が 'function' になった。var では守られている名前が、function 宣言では普通に上書きされる
  • length self parent event screen stop open print find など:let は成功し、var は代入される。var stop = 1 は window.stop を実際に上書きするので、後で window.stop() を呼ぶと壊れる

つまり**「落ちる」「無視される」「上書きしてしまう」は別の問題**で、名前ごとに違う。メモに8個を並べるだけでは、どれがどの種類か分からない。


直し方

同じ const top = el; を、5通りの書き方で試した。

図6

書き方 実行されたか 結果(Chrome・WebKit 同じ)
最上位で const top = el されない SyntaxError。後続の <script> は動く
最上位で var top = el される エラーなし。top.textContent = … は効かない
'use strict' +最上位の var top = el されない エラー。Chrome のメッセージは「Cannot set property top of # which has only a getter」の TypeError(WebKit のこの測り方では "Script error." としか取れなかった)
(() => { const top = el; … })() される 要素が書き換わる
<script type="module"> の const top = el される 要素が書き換わる

即席の直しは、名前を変えることだ。それより根本的なのは、グローバル(最上位)に宣言を置かないことだった。即時実行関数の中や、type="module" の中の宣言は、window の持ち物と衝突しない。

let が成功する名前(name など)を、最上位で let name = {a: 1} と書いた場合も測った。次の <script> で調べると、typeof name は 'object'(let の値が見える)、typeof window.name は 'string' で値は空文字(元のまま)だった。同じ名前なのに name と window.name が別物になる。let は落ちないが、window.name を読む別のコードと食い違うので、安全とは言い切れない。


手元のHTMLを検査する

この問題は、書いた本人が気づいたときには直っていることが多い。残っていないかを、手元のHTMLに当てて調べた。最上位({} と () の外)で、これらの名前を宣言している箇所を数える、粗い検査だ。HTMLの <script> の中身だけを取り出し、コメントと文字列を落としてから、最上位かどうかを {} と () の深さで判定している。外部ファイルと type="module" は対象から外した。

検査スクリプト

import re, sys

LEX = {"top", "window", "document", "location", "undefined", "NaN", "Infinity"}   # let/const/class/function で SyntaxError
IGN = {"history", "closed", "navigator", "crypto", "caches", "customElements",
       "localStorage", "sessionStorage", "name", "status"}                       # var が効かない・型が変わる(一部)
DECL = re.compile(r"(?<![\w$.])(const|let|class|function|var)\s+([A-Za-z_$][\w$]*)")

def strip(js):                                   # コメントと文字列を落とす(雑でよい)
    return re.sub(r"//[^\n]*|/\*.*?\*/|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`", '""', js, flags=re.S)

def scan(html):
    hits = []
    for attrs, body in re.findall(r"<script\b([^>]*)>(.*?)</script>", html, re.S | re.I):
        if "src=" in attrs or re.search(r"type\s*=\s*[\"']?module", attrs, re.I):
            continue                              # 外部ファイルと module は別扱い
        js = strip(body)
        for m in DECL.finditer(js):
            head = js[:m.start()]
            depth = head.count("{") - head.count("}") + head.count("(") - head.count(")")
            kind, name = m.groups()
            if depth == 0 and ((kind != "var" and name in LEX) or (kind == "var" and name in LEX | IGN)):
                hits.append((kind, name))
    return hits

if __name__ == "__main__":
    for p in sys.argv[1:]:
        for kind, name in scan(open(p, encoding="utf-8").read()):
            print(f"{p}: {kind} {name}")

検査器そのものを確かめる

先に、陽性対照を8件で確かめた。最上位の const top と class document、var history は検出される。関数の中の const top、文字列や注釈の中の const top、type="module"、即時実行関数の中は検出されない。8件とも期待どおりだった。

図7

そのうえで、手元の静的HTML 4,213ファイルに当てた。結果は 0件だった。冒頭の1件は直し済みで、同じ型が残っていない。

この検査には、既知の取りこぼしがある。if (…) { var name = 2; } のようにブロックの中に書いた var も、実は最上位のグローバル変数になる。このスクリプトは {} の中をすべて除外するので、それは数えない。「0件」は「最上位の直書きがない」であって、「安全」ではない。


名前を試すスニペット

「この名前は大丈夫か」を、その場で確かめる関数も置いておく。測定に使った仕組みの縮小版で、ブラウザの開発者ツールのコンソールに貼れる。

// 使い捨ての空の window(iframe)で、名前ごとに宣言を試す
function checkName(name) {
  const run = (code, ok) => {
    const f = document.body.appendChild(document.createElement('iframe'));
    const w = f.contentWindow;
    let err = null;
    w.addEventListener('error', e => { err = e.message; e.preventDefault(); });
    const s = w.document.createElement('script');
    s.textContent = code;
    w.document.head.append(s);
    const got = ok ? ok(w) : 'ok';
    f.remove();
    return err ? 'error' : got;
  };
  return {
    let: run(`let ${name} = 1;`),
    var: run(`var ${name} = 123;`, w => w[name] === 123 ? 'assigned' : w[name] === '123' ? 'coerced' : 'ignored'),
    fn:  run(`function ${name}() {}`, w => typeof w[name] === 'function' ? 'assigned' : 'ignored'),
  };
}

Chrome 155 と WebKit の両方で、checkName('top') は {let: 'error', var: 'ignored', fn: 'error'}、checkName('history') は {let: 'ok', var: 'ignored', fn: 'assigned'}、checkName('name') は {let: 'ok', var: 'coerced', fn: 'assigned'} を返した。checkName('hdr') は3つとも通る。

var の答えは window を直接見ているので、let の ok は「エラーにならなかった」だけを意味する。ok でも window の名前を隠すだけなので、他のスクリプトとの共有には使わないほうがいい。


メモの訂正と未測定の範囲

最初のメモは、top parent name status length self window location の8個を、変数名として使うなと書いていた。測り直した結果、次のとおり訂正する。

  • let / const で落ちるのは top window location(と、書き漏らしていた document undefined NaN Infinity)。parent name status length self は let では落ちない
  • それでも避けたほうがいいのは、var で値が入らない名前(history など28個)と、型が変わる名前(name status)。parent length self は var で実際に上書きされる

もう1つ、測っていないものがある。Firefoxは入っていないので測れていない。仕様どおりなら同じ結果になるはずだが、実測した値ではない。Safari本体も測っておらず、使ったのは同じ WebKit を使う WKWebView だ。名前の数は、拡張機能や実験機能で増減する。ワーカー(self が window でないスコープ)も対象外だ。

また、eslint の no-redeclare に builtinGlobals、no-shadow-restricted-names、no-restricted-globals といったルールがある。ただし、このMacにNode.jsが入っておらず、今回は実行していない。この記事の主張には使っていない。


参考(MDN)

参考(仕様とツール)

まとめ

  • クラシックスクリプトの最上位で let const class function が SyntaxError になる名前は、Chrome 155 と WebKit で同じ7個だった。window の作り直せないプロパティと一致した
  • var は落ちないが、Chrome 153個・WebKit 146個の名前で代入が黙って無視される。うち28個(WebKit 24個)は strict モードで TypeError になる
  • name と status は文字列に変わる。function 宣言は、7個以外を上書きできる
  • 直し方は、名前を変えるより、最上位に宣言を置かない(即時実行関数か type="module")ほうが根本的だった
  • 手元の静的HTML 4,213ファイルには、この型は残っていなかった。粗い検査器の取りこぼしも書いた
  • Firefox は未測定。Safari本体ではなく WKWebView で測った

「真っ白になったらコンソールを見る」は変わらない。変えたのは、名前のリストを書くときに、それがどの宣言で、どう壊れるのかまで測っておくことだ。

次の1アクション

手元のテンプレートやHTMLに、最上位の const・let・var が残っていないかを、上の scan_globals.py で、まず1回、回してみてほしい。0件なら、それで終わりだ。

同じ系統の測定記事として、正規表現の \b を処理系21設定で測った記事、「見つかる」「見える」「押せる」を27状態で測った記事、HTTP 200 は生きている証明にならない記事 がある。

公開しているサイトの描画や表示崩れを、実機のブラウザで機械的に点検したい場合は、サイト監査の窓口で相談を受けている。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?