JSでnew Functionの使いどころ

はじめに

markdownを書くと、ソシャゲみたいにキャラと対話してるUIが作れるというクローズドなライブラリを仕事でメンテしています。

ADVゲームのシナリオをエンジニアじゃなくても書けるようにするやつですね。

それのメンテでJSのFunctionのconstructorを使う機会があったので、それの経緯を書き連ねていきます。

markdownでシナリオ

markdownでシナリオがどういう感じかというとこんな感じ。

# first-conversation

こんにちは、${context.name}さん。
このトークはサンプルです。

# single-select-list

元気ですか?
選択肢を選んでください。

- [元気です](multiple-select-list?mt.fine=1)
- [元気ではないです](multiple-select-list?mt.fine=2)

# multiple-select-list

複数選べる選択肢はこんな感じになります。

- [ ] [お好み焼き](?liked=お好み焼き)
- [ ] [**たこ焼き**](?liked=たこ焼き)
- [ ] [もんじゃ焼き](?liked=もんじゃ焼き)

H1ごとに段落になっていて、H1の内容は内部的なidとして使われるだけで会話の内容には影響がありません。

markdownといいつつ、独自記法を含んだmarkdownっぽい何かです。

例えば${context.name}は、対話のためのReact componentに渡すpropsの中にあるcontextオブジェクトの値を参照してこんにちは、fooさん。になります。

ここで内部的にはeval()を使っていました。

hermes

この対話エンジン、reactで動くことはもちろん、react-nativeでも、cliでも動きます。

ところで、Facebookは2019年7月にreact-native向けにhermesというJS engineを発表しました。

reactnative.dev

hermes、今のところはAndroidでopt inして始めて使えるエンジンですが、react-native-reanimatedのv2-alphaがhermesしかサポートしていなかったり、react-native本体でもiOShermesを動かせるようにするPRが作られていたりとecosystemがhermesを前提にしてきそうな動きがあります。

docs.swmansion.com github.com

もちろん、すぐにhermesがデフォルトになるということはなさそうですが、hermesをonにしたときのAndroidのパフォーマンス向上は捨てがたいです。

engineering.fb.com

ということであるプロジェクトで無邪気にhermesを有効化したんですが、react-nativeのアプリがクラッシュするようになりました。

クラッシュメッセージはUncaught ReferenceError: Property 'context' doesn't exist

なんでや。

evalの挙動

react-nativeは、普段はJavaScriptCoreSafariに積まれてるやつ)でJSを動かしています。なのでWebで動くものは基本的に動きます。

ところが、hermesはreact-native向けに新しく開発されたエンジンなので、一部機能が動きません。また、ドキュメント上でサポートから除外される機能が列挙されています。

Local mode eval() (use and introduce local variables)

github.com

犯人はこいつでした。

Local mode eval()とはなんぞやということで、困ったときのMDN。

eval 関数を eval 以外の名前を参照して呼び出すことで間接的に使用した場合、ECMAScript 5 以降ではローカルスコープではなくグローバルスコープで機能します。これは例えると、関数定義によりグローバル関数が作成されるため、評価されたコードはその呼び出されたスコープ内のローカル変数にアクセスできなくなる、ということです。

developer.mozilla.org

つまり、evalを直接呼び出したときはlocal scopeを参照しますが、別名をつけて間接的に呼び出すとglobal scopeを参照します。

そして、hermesはlocalモードでのeval呼び出しをサポートしていないので、globalに存在しないcontextというオブジェクトを呼び出そうとしてクラッシュしていました。

なるほど理解。

ということで、おもむろにhermesを手元でbuildしてMDNのサンプルのscriptを走らせるとどんな挙動をするか見てみます。

console.logが動かないのでprintに置き換え。

"use strict";
function test() {
  var x = 2, y = 4;
  print(eval('x + y')); // 直接呼び出し、ローカルスコープを使用し、結果は 6 となる
  var geval = eval; // グローバルスコープでのeval呼び出しと同等
  print(geval('x + y')); // 間接呼び出し、グローバルスコープを使用し、 x は未定義となるため ReferenceError が発生する
  (0, eval)('x + y'); // 間接的な呼び出しのもう一つの例
}
test();

これをこうじゃ

> ./build/bin/hermes test.js
test.js:25:9: warning: Direct call to eval(), but lexical scope is not supported.
  print(eval('x + y')); // 直接呼び出し、ローカルスコープを使用し、結果は 6 となる
Uncaught ReferenceError: Property 'x' doesn't exist
    at eval (JavaScript:1:1)
    at test (test.js:25:13)
    at global (test.js:30:5)

hermes、evalを直接呼んでるけど、lexical scopeはサポートされてないよとwarningを出してくれて大変親切。

MDNと違って、最初のevalがglobal scopeなので結果が6にならずにReferenceErrorになりました。

原因は分かったのでこれをfixします。

new Functionの使いどころ

MDNのevalのページにeval() を使わないでください!という項があって、そこでnew Functionする方法が書いてあります。

ということで、FunctionのMDN

developer.mozilla.org

なんとFunctionのconstructorに引数を与えるとglobal scopeで実行される関数が動的に作れるらしいです。

const sum = new Function('a', 'b', 'return a + b');

console.log(sum(2, 6));
// expected output: 8

つまり、これをこうしてやれば動くのでは?

const testContextProperty = new Function("context", "return context.foo");
const testCallContext = new Function("context", "return context.bar()");
const context = {
  foo: 'foo',
  bar: function() {
    return 'bar'
  }
}
print('access foo: ' + testContextProperty(context))
print('call bar: ' + testCallContext(context))

hermesで実行

> ./build/bin/hermes test.js
access foo: foo
call bar: bar

動きました 🎉

ということで、これを使ってevalを置き換えて、無事hermesでも件のライブラリが動くようになりました。