とある事情でEventEmitter3の処理速度を測定していた際、不可解な現象に遭遇しました。
WebpackのproductionモードでバンドルするとChromeでの性能が著しく劣化する
この原因について調査したので共有します。
結論から
- classのインスタンス生成をある形式で実装すると、ChromeやFirefoxでインスタンス参照時のパフォーマンスが劣化する(っぽい)
- productionモードで使用されるUglifyjsWebpackPluginによって、EventEmitter3の内部コードがその形式に変換されていた
- レアケースですが CommonJS + uglifyjs では要注意
環境
eventemitter3 : 3.1.0
webpack : 4.17.1
Chrome : 70
事象
- productionモードでバンドルするとパフォーマンスが劣化した
- ChromeとFirefoxで再現した
- Safariでは再現しない
事象が発生したコード
import { EventEmitter } from 'eventemitter3';
const eventEmitter = new EventEmitter();
// 'test'イベントに10000個のリスナーを登録
for (let i = 0; i < 10000; i++){
eventEmitter.on('test', () => { });
}
const loop = () => {
const before = performance.now();
// 毎フレーム'test'イベント発火
eventEmitter.emit('test');
// 処理時間をログ出力
console.log(performance.now() - before);
requestAnimationFrame(loop);
};
loop();
1つのイベントに10000件のリスナーを登録して、毎フレーム発火させる処理です。
イベント発火時の処理時間を計測してログに出力しています。
計測結果
圧倒的劣化っ・・・・・・!
productionモードの何が原因か?
productionモードは本番環境向けに最適化プラグインなどを適用してくれる設定ですね。Mode: production
適用されるプラグインのいずれかに原因があると考えて、developモードに設定しつつoptimizationプロパティを手動で切り替えて調査した結果・・・
minimize: true
が原因だとわかりました。
UglifyjsWebpackPluginを使用してコードをminifyしてくれる設定ですね。
どんなコードをminifyすると再現するのか?
次にEventEmitter3をバラしながら原因のコードを探りました。
再現するコード
EventEmitter3の実装を簡易化した以下のコードで、minify時のみ性能劣化を確認しました。
function Listener(fn) {
this.fn = fn;
}
class EventEmitter {
constructor() {
this.listeners = [];
}
on(fn) {
this.listeners.push(new Listener(fn));
}
emit() {
for (let i = 0; i < this.listeners.length; ++i){
this.listeners[i].fn();
}
}
}
const eventEmitter = new EventEmitter();
for (let i = 0; i < 10000; i++){
eventEmitter.on(() => { });
}
const loop = () => {
const before = performance.now();
eventEmitter.emit();
console.log(performance.now() - before);
requestAnimationFrame(loop);
};
loop();
再現しないコード
色々試した中で劣化しない書き方もいくつか見えました。
パターン1: Listenerクラスにprototypeを定義
Listenerを多少クラスらしくしてあげました。
function Listener(fn) {
this.fn = fn;
}
Listener.prototype.hoge = () => { };
パターン2: ListenerクラスをES2015構文で書く
ES2015構文で明確にclassとして定義します。
class Listener {
constructor(fn) {
this.fn = fn;
}
}
パターン3: Listenerがクラスである必要ないでしょ
コンストラクタだけのクラスならobjectで事足りますよね
// this.listeners.push(new Listener(fn));
this.listeners.push({ fn: fn });
minify後の差分はどうなっているのか?
再現するコード、再現しないコードのminify後のコードを見てみました。
劣化するコード
Listenerクラスの定義が無くなった。
this.listeners.push()
時に都度定義して即時実行されている。
const n = new class {
constructor() {
this.listeners = []
}
on(e) {
this.listeners.push(new function (e) {
this.fn = e
}(e))
}
emit() {
for (let e = 0; e < this.listeners.length; ++e) this.listeners[e].fn()
}
};
for (let e = 0; e < 1e4; e++) n.on(() => {});
const r = () => {
const e = performance.now();
n.emit(), console.log(performance.now() - e), requestAnimationFrame(r)
};
r()
劣化しないパターン1
Listenerクラスがfunction n
として定義されるようになった。
function n(e) {
this.fn = e
}
n.prototype.hoge = (() => {});
const r = new class {
constructor() {
this.listeners = []
}
on(e) {
this.listeners.push(new n(e))
}
emit() {
for (let e = 0; e < this.listeners.length; ++e) this.listeners[e].fn()
}
};
for (let e = 0; e < 1e4; e++) r.on(() => {});
const o = () => {
const e = performance.now();
r.emit(), console.log(performance.now() - e), requestAnimationFrame(o)
};
o()
劣化しないパターン2
Listenerクラスがclass n
として定義されている。
class n {
constructor(e) {
this.fn = e
}
}
const r = new class {
constructor() {
this.listeners = []
}
on(e) {
this.listeners.push(new n(e))
}
emit() {
for (let e = 0; e < this.listeners.length; ++e) this.listeners[e].fn()
}
};
for (let e = 0; e < 1e4; e++) r.on(() => {});
const o = () => {
const e = performance.now();
r.emit(), console.log(performance.now() - e), requestAnimationFrame(o)
};
o()
劣化しないパターン3
ただのobjectとしてthis.listeners.push()
されている。
const n = new class {
constructor() {
this.listeners = []
}
on(e) {
this.listeners.push({
fn: e
})
}
emit() {
for (let e = 0; e < this.listeners.length; ++e) this.listeners[e].fn()
}
};
for (let e = 0; e < 1e4; e++) n.on(() => {});
const r = () => {
const e = performance.now();
n.emit(), console.log(performance.now() - e), requestAnimationFrame(r)
};
r()
怪しい差分は?
on()
でのインスタンス生成の差分が怪しそうです。
on(e) {
this.listeners.push(new function (e) {
this.fn = e
}(e))
}
function n(e) {
this.fn = e
}
//省略
on(e) {
this.listeners.push(new n(e))
}
CodePenで確認
怪しい箇所にあたりがついたのでCodePenで再現コードを書いてみました。
See the Pen aQBmzv by zprodev (@zprodev) on CodePen.
See the Pen QJGKjz by zprodev (@zprodev) on CodePen.
自分の環境では1つ目のコードで劣化が確認できますが、みなさんの環境ではどうでしょうか?
まとめ
classのインスタンス生成の仕方によって、参照時(?)のパフォーマンスが劣化するという事象が確認できました。
珍しいケースだとは思いますが、パフォーマンスが求められる場面で CommonJS + uglifyjs は要注意かもしれません。