はじめに
自分で作っている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個だった(topwindowdocumentlocationundefinedNaNInfinity) -
varは落ちない。ただし Chrome では153個、WebKit では146個の名前で、代入が黙って無視された(var history = [1, 2]のhistoryは配列にならない) - 無視される名前のうち28個(WebKitでは24個)は、
'use strict'を付けた途端 TypeError で止まる -
nameとstatusは代入すると文字列に変わる。function宣言は、7個以外の全部を上書きできる - 最初のメモは8個を挙げていたが、
constで落ちるのはそのうち3個だけだった。メモを訂正した
目次
- 実測した環境
- 測定のやり方
- 再現した症状
- 仕組み
- 宣言4種の結果
- varが黙って無視される153個
- よく使う名前の一覧
- 直し方
- 手元のHTMLを検査する
- 検査スクリプト
- 検査器そのものを確かめる
- 名前を試すスニペット
- メモの訂正と未測定の範囲
- 参考(MDN)
- 参考(仕様とツール)
- まとめ
- 次の1アクション
実測した環境
測定は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>
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 自身が持つ定数だ。
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 は別に測り、エラーになった)。
| 宣言 | 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 になった名前を、無視の仕方で分けた。
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)。historynavigatorlocalStoragesessionStoragecryptocachesclosedなどが入る
'use strict' で止まる名前は、テンプレートを strict モードに切り替えた日に突然見つかる。普段はエラーが出ないので、切り替えのコミットを疑う前に、この28個を疑える。
よく使う名前の一覧
変数名として思いつきやすい名前を、両エンジンの結果でまとめた。ここに載せた行は、Chrome と WebKit で結果が1つも違わなかった(external だけは WebKit に存在しない)。
注目したのは次の点だ。
-
let/constで落ちる:topwindowdocumentlocation -
varが効かない:historyclosednavigatorcryptocacheslocalStorageなど。var history = [1, 2];の直後にArray.isArray(history)を調べるとfalse、typeof historyは'object'(Historyのまま)だった -
varが文字列に変わる:namestatus。var name = { a: 1 };の直後にtypeof nameを調べると'string'、中身は'[object Object]' -
function history() {}は成功し、typeof window.historyが'function'になった。varでは守られている名前が、function宣言では普通に上書きされる -
lengthselfparenteventscreenstopopenprintfindなど:letは成功し、varは代入される。var stop = 1はwindow.stopを実際に上書きするので、後でwindow.stop()を呼ぶと壊れる
つまり**「落ちる」「無視される」「上書きしてしまう」は別の問題**で、名前ごとに違う。メモに8個を並べるだけでは、どれがどの種類か分からない。
直し方
同じ const top = el; を、5通りの書き方で試した。
| 書き方 | 実行されたか | 結果(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件とも期待どおりだった。
そのうえで、手元の静的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で落ちるのはtopwindowlocation(と、書き漏らしていたdocumentundefinedNaNInfinity)。parentnamestatuslengthselfはletでは落ちない - それでも避けたほうがいいのは、
varで値が入らない名前(historyなど28個)と、型が変わる名前(namestatus)。parentlengthselfはvarで実際に上書きされる
もう1つ、測っていないものがある。Firefoxは入っていないので測れていない。仕様どおりなら同じ結果になるはずだが、実測した値ではない。Safari本体も測っておらず、使ったのは同じ WebKit を使う WKWebView だ。名前の数は、拡張機能や実験機能で増減する。ワーカー(self が window でないスコープ)も対象外だ。
また、eslint の no-redeclare に builtinGlobals、no-shadow-restricted-names、no-restricted-globals といったルールがある。ただし、このMacにNode.jsが入っておらず、今回は実行していない。この記事の主張には使っていない。
参考(MDN)
- MDN: let — グローバルスコープでの再宣言の扱い
-
MDN: var — グローバルでの
varとプロパティ - MDN: Window.top — 読み取り専用の属性
- MDN: Window.name — 文字列として扱われる
- MDN: Window.history — 読み取り専用
- MDN: Strict モード — getter だけのプロパティへの代入
- MDN: script 要素(type="module") — モジュールのスコープ
-
MDN: iframe 要素 — 測定の使い捨て
window
参考(仕様とツール)
-
ECMAScript: GlobalDeclarationInstantiation —
HasRestrictedGlobalPropertyの判定 - Web IDL: [LegacyUnforgeable] — 作り直せない属性
-
HTML Standard: The Window object —
windowの属性定義 -
ESLint: no-redeclare —
builtinGlobalsオプション - ESLint: no-restricted-globals — 使わせない名前の指定
- WKWebView(Apple Developer) — WebKit 側の測定に使った部品
- Headless Chrome(Chrome for Developers) — Chrome 側の測定に使った実行方法
まとめ
- クラシックスクリプトの最上位で
letconstclassfunctionが 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 は生きている証明にならない記事 がある。
公開しているサイトの描画や表示崩れを、実機のブラウザで機械的に点検したい場合は、サイト監査の窓口で相談を受けている。






