Unicode正規化に関する問題で少し困ったので簡単な再現コードと解決をまとめておく
概要
macOS/Safariでのみ確認
具体的には以下のようなコードを実行したとき、Blob書き込み前と後で異なる文字が得られる
const invalidChar = '\u0374';
const blob = new Blob([invalidChar], { type: 'text/plane' });
const reader = new FileReader();
reader.onload = function (event) {
// Safari: false
// others: true
console.log(event.target.result === invalidChar);
// in Safari... invalidChar === '\u02B9'
};
reader.readAsText(blob);
この2つの文字は「Unicodeの等価性」という原則に基づいて「同じ文字」であるらしい(詳しくないのでこの表現が正しいかどうか分からない)が、フロントエンドマンはこの2つが一緒だと困るのである
解決
結論から言うと、いい解決策は見つけられなかった
エスケープすれば元の値のまま保持できる
let invalidChar = '\u0374';
invalidChar = invalidChar.replace(/\u0374/, '\\u0374');
const blob = new Blob([invalidChar], { type: 'text/plane' });
const reader = new FileReader();
reader.onload = function (event) {
console.info(event.target.result === invalidChar); // true!
};
reader.readAsText(blob);
しかし問題は残る
-
\u0374
だけでなく、多くのutf-8文字がこの現象を引き起こす - 対象の文字が多すぎるとき、エスケープ後のファイルサイズがとても大きくなる
-
String.prototype.replace
のパフォーマンス的問題 - 「そのブラウザが問題を起こすかどうか」を簡単に見分ける方法が思いつかない
- これについては後述
上手くいかなかった方法
- Blob初期化時に文字コードを指定する
- readAsTextに文字コードを指定する
- Blob初期化時にBOMをつける
コードはgistに上げてある
実験
以下のスクリプトはSafariの開発者ツールに付属しているコンソールでその行だけを実行して結果を確認しています
アンエスケープしてエスケープしてみる
escape(unescape('\u0374')) === '%u0374' // true
これは問題ない
\u0374
をコピーペーストしてエスケープしてみる
// ʹ === \u0374
escape('ʹ') === '%u0374' // false
ここに原因があるらしい
\u0374
と\u02B9
を直接比較する
'ʹ' === 'ʹ' // true
// ※Safari以外のブラウザでは false
上記が最も単純な検証コードである。これを利用すれば、前述の「そのブラウザが問題を起こすかどうか」を判定できるかも知れない
ところで、上に挙げた3つの検証用コードをエディタ(Atom)を使ってファイルに書き込んでscriptタグから実行させた場合、問題は起こらない
そもそもどこに責任があるのか?
- エディタで保存したとき…………上手くいく
- コンソールから実行したとき…………失敗する
- エディタで保存した文字列をもとにBlobを初期化したとき…………失敗する
ということから、問題は 読み取り時ではなく書き込み時に起きていると予想される
SafariのコンソールにJavaScriptコードを入力したとき、おそらくSafariは評価用のVMにコードを渡すために何らかの方法で コードをファイルに書き込む。このときに\u0374
を\u02B9
として書き込んでしまうのだろう
そして、これと同じ現象がBlob初期化時にも発生していると思われる
…という線でググってみたものの、それらしいWebKitのバグは見当たらず…そもそもこの問題はバグとして扱われていないのかも?
どうして問題になったのか
この問題に気付いた経緯はこんな感じである
- ある開発中のサービスで
babel-standalone
を使っていた - 変換処理が長いとブラウザが固まってしまう
- そうだ、Workerを使おう!!
- 諸事情によりエントリーポイントは1ファイルにする必要がある
- そうだ、Blob URL Schemeを使おう!!
- ビルド済みの
babel-standalone
をconcat -> Blob URL - とりあえずWorkerを走らせてみる
- Workerがエラーを吐く
SyntaxError: Invalid regular expression: range out of order in character class
- 該当箇所
var nonASCIIidentifierStartChars = "ªµºÀ-ÖØ-öø-ˁˆ-ˑˠ-ˤˬˮͰ-ʹͶͷͺ-ͽͿΆΈ-ΊΌΎ-ΡΣ-ϵϷ-ҁҊ-ԯԱ-Ֆՙա-ևא-תװ-ײؠ-يٮٯٱ-ۓەۥۦۮۯۺ-ۼۿܐܒ-ܯݍ-ޥޱߊ-ߪߴߵߺࠀ-ࠕࠚࠤࠨࡀ-ࡘࢠ-ࢴࢶ-ࢽऄ-हऽॐक़-ॡॱ-ঀঅ-ঌএঐও-নপ-রলশ-হঽৎড়ঢ়য়-ৡৰৱਅ-ਊਏਐਓ-ਨਪ-ਰਲਲ਼ਵਸ਼ਸਹਖ਼-ੜਫ਼ੲ-ੴઅ-ઍએ-ઑઓ-નપ-રલળવ-હઽૐૠૡૹଅ-ଌଏଐଓ-ନପ-ରଲଳଵ-ହଽଡ଼ଢ଼ୟ-ୡୱஃஅ-ஊஎ-ஐஒ-கஙசஜஞடணதந-பம-ஹௐఅ-ఌఎ-ఐఒ-నప-హఽౘ-ౚౠౡಀಅ-ಌಎ-ಐಒ-ನಪ-ಳವ-ಹಽೞೠೡೱೲഅ-ഌഎ-ഐഒ-ഺഽൎൔ-ൖൟ-ൡൺ-ൿඅ-ඖක-නඳ-රලව-ෆก-ะาำเ-ๆກຂຄງຈຊຍດ-ທນ-ຟມ-ຣລວສຫອ-ະາຳຽເ-ໄໆໜ-ໟༀཀ-ཇཉ-ཬྈ-ྌက-ဪဿၐ-ၕၚ-ၝၡၥၦၮ-ၰၵ-ႁႎႠ-ჅჇჍა-ჺჼ-ቈቊ-ቍቐ-ቖቘቚ-ቝበ-ኈኊ-ኍነ-ኰኲ-ኵኸ-ኾዀዂ-ዅወ-ዖዘ-ጐጒ-ጕጘ-ፚᎀ-ᎏᎠ-Ᏽᏸ-ᏽᐁ-ᙬᙯ-ᙿᚁ-ᚚᚠ-ᛪᛮ-ᛸᜀ-ᜌᜎ-ᜑᜠ-ᜱᝀ-ᝑᝠ-ᝬᝮ-ᝰក-ឳៗៜᠠ-ᡷᢀ-ᢨᢪᢰ-ᣵᤀ-ᤞᥐ-ᥭᥰ-ᥴᦀ-ᦫᦰ-ᧉᨀ-ᨖᨠ-ᩔᪧᬅ-ᬳᭅ-ᭋᮃ-ᮠᮮᮯᮺ-ᯥᰀ-ᰣᱍ-ᱏᱚ-ᱽᲀ-ᲈᳩ-ᳬᳮ-ᳱᳵᳶᴀ-ᶿḀ-ἕἘ-Ἕἠ-ὅὈ-Ὅὐ-ὗὙὛὝὟ-ώᾀ-ᾴᾶ-ᾼιῂ-ῄῆ-ῌῐ-ΐῖ-Ίῠ-Ῥῲ-ῴῶ-ῼⁱⁿₐ-ₜℂℇℊ-ℓℕ℘-ℝℤΩℨK-ℹℼ-ℿⅅ-ⅉⅎⅠ-ↈⰀ-Ⱞⰰ-ⱞⱠ-ⳤⳫ-ⳮⳲⳳⴀ-ⴥⴧⴭⴰ-ⵧⵯⶀ-ⶖⶠ-ⶦⶨ-ⶮⶰ-ⶶⶸ-ⶾⷀ-ⷆⷈ-ⷎⷐ-ⷖⷘ-ⷞ々-〇〡-〩〱-〵〸-〼ぁ-ゖ゛-ゟァ-ヺー-ヿㄅ-ㄭㄱ-ㆎㆠ-ㆺㇰ-ㇿ㐀-䶵一-鿕ꀀ-ꒌꓐ-ꓽꔀ-ꘌꘐ-ꘟꘪꘫꙀ-ꙮꙿ-ꚝꚠ-ꛯꜗ-ꜟꜢ-ꞈꞋ-ꞮꞰ-ꞷꟷ-ꠁꠃ-ꠅꠇ-ꠊꠌ-ꠢꡀ-ꡳꢂ-ꢳꣲ-ꣷꣻꣽꤊ-ꤥꤰ-ꥆꥠ-ꥼꦄ-ꦲꧏꧠ-ꧤꧦ-ꧯꧺ-ꧾꨀ-ꨨꩀ-ꩂꩄ-ꩋꩠ-ꩶꩺꩾ-ꪯꪱꪵꪶꪹ-ꪽꫀꫂꫛ-ꫝꫠ-ꫪꫲ-ꫴꬁ-ꬆꬉ-ꬎꬑ-ꬖꬠ-ꬦꬨ-ꬮꬰ-ꭚꭜ-ꭥꭰ-ꯢ가-힣ힰ-ퟆퟋ-ퟻ豈-舘並-龎ff-stﬓ-ﬗיִײַ-ﬨשׁ-זּטּ-לּמּנּסּףּפּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-ﷻﹰ-ﹴﹶ-ﻼA-Za-zヲ-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ";
var nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
- …今に至る
/[x-y]/
みたいな正規表現があったときx
がy
よりも大きい(文字コード的に順番が後である)ときRegExp
のコンストラクタが「そんな範囲ありえないよ!」と出すのがこのエラーである
たくさんエラーが起きる箇所があると思うが、例として[Ͱ-ʹ]
は本来[\u0370-\u0374]
を表す正規表現だったのに対してBlobに書き込まれたあとは[\u0370-\u02B9]
となってしまい、範囲が定まらなかった、というのがエラーの原因である
余談
逆戻りする例があったからまだ良かったものの、右のほうが大きいまま値だけ変わっていたらと思うとゾッとする(とはいえ3時間ほど費やした)
Unicodeの等価性というものは初めて知ったのでいい勉強になった
babel-standalone
にissueを送ろうと思うが、「あなたのライブラリを文字列にしてconcatして実行時にBlobにした上でWorker上で走らせたら、なんかSafariだけ様子がオカシイ」と言われたら、 wontfix のラベルを付けたくなるだろうなと思うので、控えめにしておこうと思う