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を発表しました。
hermes、今のところはAndroidでopt inして始めて使えるエンジンですが、react-native-reanimatedのv2-alphaがhermesしかサポートしていなかったり、react-native本体でもiOSでhermesを動かせるようにするPRが作られていたりとecosystemがhermesを前提にしてきそうな動きがあります。
もちろん、すぐにhermesがデフォルトになるということはなさそうですが、hermesをonにしたときのAndroidのパフォーマンス向上は捨てがたいです。
ということであるプロジェクトで無邪気にhermesを有効化したんですが、react-nativeのアプリがクラッシュするようになりました。
クラッシュメッセージはUncaught ReferenceError: Property 'context' doesn't exist
なんでや。
evalの挙動
react-nativeは、普段はJavaScriptCore(Safariに積まれてるやつ)でJSを動かしています。なのでWebで動くものは基本的に動きます。
ところが、hermesはreact-native向けに新しく開発されたエンジンなので、一部機能が動きません。また、ドキュメント上でサポートから除外される機能が列挙されています。
Local mode eval() (use and introduce local variables)
犯人はこいつでした。
Local mode eval()とはなんぞやということで、困ったときのMDN。
eval 関数を eval 以外の名前を参照して呼び出すことで間接的に使用した場合、ECMAScript 5 以降ではローカルスコープではなくグローバルスコープで機能します。これは例えると、関数定義によりグローバル関数が作成されるため、評価されたコードはその呼び出されたスコープ内のローカル変数にアクセスできなくなる、ということです。
つまり、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
なんと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でも件のライブラリが動くようになりました。