Closed7

[キャッチアップ] setTimeout と window と globalThis

shingo.sasakishingo.sasaki

概要

ある日、 setTimeout を使った以下のような JavaScript コードを TypeScript に置き換えようとした。(※ 以下コードは冗長だがサンプルとして改変している)

function startTimer(callback, millisec) {
  const timerId = setTimeout(() => {
    callback();
  }, millisec);
  return timerId;
}

setTimeout の返り値といったら、 number だろうと、以下のように片付けを行ったところ、エラーが発生していることに気づいた。

function startTimer(callback, millisec) {
  const timerId: number = setTimeout(() => {
    callback();
  }, millisec);
  return timerId
}

Type 'Timeout' is not assignable to type 'number'.ts(2322)

エディタ上で確認できる型推論によると、 setTimeout の返り値は number ではなく、 NodeJS.Timeout になるらしい。

function startTimer(callback, millisec) {
  const timerId: NodeJS.Timeout = setTimeout(() => {
    callback();
  }, millisec);
  return timerId;
}

この型は一体何者なのか、 Node と書いてあるが、ブラウザで動かす場合に問題はないのか?

わからないことばかりだったので、詳しく調べてみることにした。

shingo.sasakishingo.sasaki

関連記事を調べる

同じ疑問に辿り着く先人は多数いたようで、以下の記事が非常に参考になった。

TypeScriptでsetTimeout()がNodeJS.Timerになる理由から、window.setTimeout()との違いを理解する

既によくまとまった内容だが、さらに知りたいポイントだけまとめると以下ようになる

  • window.setTimeout と明記することで、戻り値は想定通りに number となる
  • window.setTimeout は、ブラウザが用意している setTimeout を明示的に使うことができる
  • Node における setTimeout は、ブラウザ外のJSや、自分たちで用意した関数も考慮される
  • 確実にブラウザの setTimeout を動かす場合は、 window を省略しないほうが良さそう

どうやらブラウザにおける window の省略と、 Node によるブラウザ互換のAPIが噛み合って起こっている事象らしい。

shingo.sasakishingo.sasaki

そもそも window ってなんだっけ

Window - Web API | MDN によると、 window オブジェクト は以下のようなものとされている

  • スクリプトを実行しているウィンドウを表すグローバル変数
  • ウィンドウの概念とは必ずしも直結しない、関数/名前空間/オブジェクト/コンストラクタのホームとなっている
  • ブラウザ内の JavaScript は、 window がグローバルオブジェクトとなり、全てのコードは window のコンテキスト内で実行される

ブラウザコンソールを開いて、 this を実行すると window オブジェクトが取得できるのがそういうこと。

this
Window {0: global, 1: Window, window: Window, self: Window, document: document, name: "", location: Location,}

なら window.setTimeout のように、 window を明示できるのは何故なのかと言うと、 Window#window が定義されているからだった。

window.window - Web API | MDN

shingo.sasakishingo.sasaki

Node における setTimeout は何なのか

を見比べてみると、ブラウザにおける setTimeout と、Node における setTimeout には以下のような違いがあった。

ブラウザ版は、実行するコードを文字列で渡すことができるが、Node 版はできない

ブラウザ

setTimeout('alert("OK")', 1000)
7115

Node

> setTimeout('alert("OK")', 1000)
Uncaught:
TypeError [ERR_INVALID_CALLBACK]: Callback must be a function. Received 'alert("OK")'

ブラウザ版の返り値は数値 で、 Node 版はオブジェクト

ブラウザ

setTimeout(() => {}, 1000)
7451

Node

> setTimeout(() => {}, 1000)
Timeout {
  _idleTimeout: 1000,
  _idlePrev: [TimersList],
  _idleNext: [TimersList],
  _idleStart: 128598,
  _onTimeout: [Function],
  _timerArgs: undefined,
  _repeat: null,
  _destroyed: false,
  [Symbol(refed)]: true,
  [Symbol(asyncId)]: 133,
  [Symbol(triggerId)]: 5
}

Node 版は Timer オブジェクト経由でタイマーをリフレッシュできる

ブラウザ

const timerId = setTimeout(() => {}, 5000)
clearTimeout(timerId)

Node

const timer = setTimeout(() => {}, 5000)
timer.refresh()
shingo.sasakishingo.sasaki

TypeScript ではどう型を付けるべきか

これまでのように、ブラウザでコードが実行されることを考えると、 window を省略して setTimeout を書きたくなるものだが、TypeScript コンパイラは Node の世界で動く以上、 setTimeout と書いてしまうと Node.Timeout と推論されてしまう。

エディタの補完に従って、以下のようなコードを書いてしまうと、コンパイルは正常に行われるが、いざブラウザで実行すると、以下のようなエラーが出てしまう。

function startTimer(callback, millisec) {
  return setTimeout(() => {
    callback();
  }, millisec);
}

const timer = startTimer(() => {}, 1000)
timer.refresh()
VM607:1 Uncaught TypeError: timer.refresh is not a function
    at <anonymous>:1:3

上記のような Node.Timeout 特有の機能を使わなければこれでも問題ないが、やはり安全策として window を明記するようにし、不可能な参照はコンパイル段階で弾けるようにしておきたい。

function startTimer(callback, millisec) {
  return window.setTimeout(() => {
    callback();
  }, millisec);
}

const timer = startTimer(() => {}, 1000)
timer.refresh() // Property 'refresh' does not exist on type 'number'.
shingo.sasakishingo.sasaki

globalThis について

ブラウザスコープでのみ window がグローバルオブジェクトになると言った、JavaScript の実行環境ごとにグローバルオブジェクトが異なる問題を調べていると登場するのが globalThis だ。 globalThis は ES2020 で追加された新機能になる。

まずは以下辺りに目を通してどんなものかを調べてみた。

わかったことは以下の通り

  • グローバルオブジェクトは処理系によって大きく異る
    • ブラウザは window
    • Node は global
    • WebWorker は `self
  • 同じコードでもそれぞれのグローバルオブジェクトを参照できるのが globalThis
  • IE11 はサポートされていない

ちなみに IE11 でも corejs を用いた Polifill が適用可能なので、 babel なりのトランスパイルをちゃんと組んでいれば IE11 をサポートするコードでも使用可能なようです。

https://github.com/zloirock/core-js/blob/master/packages/core-js/internals/global.js

shingo.sasakishingo.sasaki

globalThis と TypeScript

では処理系によって参照するグローバルオブジェクト切り替えるための globalThis を、 TypeScript で利用した場合にその型はどうなるのだろうか。

globalThis を参照すると、型は globalThis モジュールとして推論される

const _this = globalThis

また Node のグローバルオブジェクトである global の型は NodeJS.Global & typeof globalThis に、 ブラウザのグローバルオブジェクトである windowWindow & typeof globalThis となっている。

const _global = global // NodeJS.Global & typeof globalThis
const _window = window // Window & typeof globalThis

このことから、 TypeScript は Node 及び ブラウザ両処理系のグローバルオブジェクトの型をサポートしており、 typeof globalThis との Intersect によって、実行環境で利用可能なプロパティのみのインタフェースが提供できるようになっていることがわかる。

(この辺りはちょっと理解が怪しい)

このスクラップは2021/04/27にクローズされました