エフアンダーバー

個人でのゲーム開発

WebpackでTypeScriptのモジュール解決

TypeScriptのコンパイラオプションには baseUrl、paths、rootDirsといういくつかの特別なモジュール解決の設定があります。

しかし、これらはあくまでもTypeScriptコンパイラのための設定でWebpackやts-loaderはこれらを考慮してくれません。

そこで、Webpackにこれらの設定を反映させるためのプラグインを書いてみました。

TypeScriptのモジュール解決

TypeScriptのコンパイラオプションには主に次の四つのモジュール解決に関する設定があります。

  • moduleResolution
  • baseUrl
  • paths
  • rootDirs

moduleResolutionは"Classic"か"Node"のどちらかでNode.jsのモジュール解決に準拠するか否かの設定です。

baseUrlは非相対パスが与えられたときにどの基準となる場所を指定します。 tsconfig.json内で指定する場合にはtsconfig.jsonを基準に、直接コンパイラで指定する場合にはカレントディレクトリを基準に設定します。

pathsはpath mappingのためのオプションです。 例えば"@model/foo"というモジュールを"src/model/foo"あるいは"lib/model/foo"に対応させたい場合には、

paths: {
  "@model/*": [
    "src/model/*",
    "lib/model/*"
  ]
}

のように書いておくと、コンパイラが探して解決してくれます。

rootDirsは同じ構造を持つ複数のディレクトリを同一のディレクトリのように扱う設定です。 例えば、自動生成されるコードをgeneratedという別のディレクトリに分離しておきたい場合に、

rootDirs: [
  "src",
  "src/generated"
]

と書いておくと、相対パスで指定した時にこれらのディレクトリはまるでマージされたひとつのディレクトリのように振る舞います。

Webpackにおける問題

TypeScriptのモジュール解決は便利ですが、これらの設定をWebpackやts-loaderは考慮してくれません。 例えエディタ上でエラーが発生していなくても、Webpackでビルドした際にモジュールを解決できずにエラーになります。

moduleResolutionは"Node"にしておけば特に問題は起きませんが、 baseUrl, paths, rootDirsはWebpackの設定ではどうにもならない場合があります (aliasの設定等でなんとかなる場合もあります)。

awesome-typescript-loaderのTsConfigPathsPluginを使うとpathsに関しては解決できるらしいのですが、 残念ながら自分の環境では動作しませんでした(Windowsだから?)。

モジュール解決プラグイン

そこで、TypeScriptのモジュール解決用のプラグインを自作することにしました。

ソースコードは後述。

使い方はwebpack.config.js内でインポートして、resolve直下のplugins内にインスタンスを設定します。

const TsConfigPlugin = require("./tsconfig-webpack-plugin");
...

resolve: {
  plugins: [
    new TsConfigPlugin()
  ]
}

デフォルトではカレントディレクトリのtsconfig.jsonの設定を読みに行きます。

別の場所にあるtsconfig.jsonを読ませたいときは場所を指定します。

new TsConfigPlugin("ts/tsconfig.json")
new TsConfigPlugin({ config: "ts/tsconfig.json" })

tsconfig.jsonを読ませたくない場合にはnullを指定します。 その場合、各オプションはoptionsで直接指定します。

new TsConfigPlugin({
  config: null,
  options: { baseUrl: "src" }
})

デフォルトでは.tsファイルや.tsxファイル以外からモジュール解決をリクエストされても無視します。 それ以外のファイルからのリクエストも受け入れる場合にはtestに対象を正規表現で指定します。

new TsConfigPlugin({ test: /\.\w+$/ })

デフォルトではbaseUrl、paths、rootDirsのすべてを解決しようとしますが、個別に指定することもできます。

const { TsBaseUrlPlugin, TsPathsPlugin, TsRootDirsPlugin } = require("./tsconfig-webpack-plugin");

ソースコード

WebpackのResolverプラグインに関する資料が少なすぎて、 正直ただしく書けている自信がないのですがとりあえず自分の環境では動いてます。

gist.github.com

おわりに

とりあえず作ったので公開してみたのですが、 実は自分に必要な部分以外はほぼテストしていません。 プラグインのオプション周りとかrootDirsとかは動くか怪しいので、 もし動かないようであれば連絡もらえるとありがたいです。



執筆時のwebpackのバージョン: 3.8.1

【Webpack】 css-loader周りのSourceMap関連バグと戦う

css-loaderを使っていてSourceMapのパスが壊れるバグにハマったのでその際調べたことのまとめです。

長くなるのでタイトルに入れていませんが、 css-loaderpostcss-loadersass-loader辺りのバグです。

問題

Windows環境で次のようにローダーを構成してSourceMapを生成したところ、SourceMapのパスが壊れる現象が発生しました。

[
        {
            loader: 'css-loader',
            options: {
                url: false,
                importLoaders: 2,
                sourceMap: true
            }
        },
        {
            loader: 'postcss-loader',
            options: {
                sourceMap: true
            }
        },
        {
            loader: 'sass-loader',
            options: {
                includePaths: [
                    path.resolve(__dirname, "src/scss")
                ],
                sourceMap: true
            }
        }
]

壊れ方はだいたい三通りで、

  • C:/foo/bar/C:/foo/barのように謎の繰り返しが起きる
  • ./foo/C:/foo/barのように本来のパスの前に余計なパスが挟まる
  • C:/baz/barのようにカレントディレクトリ(C:/baz)直下にファイルが移動する

といった感じでした。

原因

いろいろと調べた結果、どうも原因がひとつではなかったようなのですべて列挙します。

原因1:パスとURLの混同

おそらくWindowsを使っている場合に発生するバグの大部分がこれです。

そもそもSourceMapにおいてソースの指定にはURLを用いるそうです。 PostCSS(css-loaderおよびpostcss-loaderが内部で使用)やその他多くのライブラリがSourceMapの生成に用いる source-mapパッケージでもURLしか受け入れないようになっています。

しかし、それにも関わらず多くのライブラリがNode.jsのpathモジュールで処理した結果をそのままsource-mapに渡してしまっています。 というのも、Windows以外(POSIX)では多くの場合にファイルシステムのパスをURLとしても解釈できるので問題が起こりづらいためです。

一方、Windowsのパスは基本的にURLとして想定通りには解釈されません。 そもそも区切り文字が違いますし、ドライブレターの問題もあります。 C:/foo/barはWindows的には絶対パスでも、URL的には相対パスです。 絶対パスのURLにするためには/C:/foo/barとする必要があります。

さらに面倒くさいことにせっかく/C:/foo/barというようにURLに変換しても、 これにpath.resolveを適用するとC:\C:\foo\barという壊れたパスに変換されます。 パスをURLとして扱うことも、URLをパスとして扱うこともできないわけです。

ちなみにWindowsだと被害が大きいというだけで、これは特にWindows限定のバグというわけではないと思います。 Windows以外でもURLに使えない文字(スペースや日本語など)があれば、何らかのバグが発生すると思われます。 PostCSS内でも<no source><input css 1>といったデフォルト値が見つかりますが、少し怪しい感じがします。

自分のケースでこれが問題となったのはsass-loaderの出力したSourceMapです。 sass-loaderが内部で用いるnode-sass(あるいはlibsass)の出力がURLに変換されていないため、 ドライブレターを含む絶対パスが相対パスとみなされ、 後のローダーの大元のソースを探す処理でおかしな連結が行われていました (コード)。

また、直接の影響はないみたいですが、 libsassの時点では/だったパスの区切りがsass-loaderのpath.normalizeによって\に変えられています。 sass-loaderもパスとURLを区別していないという証拠なのでちょっと怖いところ。

原因2:相対パスから絶対パスへの誤変換

PostCSSは前述のWindowsの絶対パスの問題を避けるためか、やたらと絶対パスを相対パスに変換しようとします。 sass-loaderから繋いだ場合など、前のSourceMapが存在する場合には元のsourceの値が使われるためあまり相対パスが返ってくることもないのですが、 変換元のsourceが見つからない場合には新しく生成されたものとして処理元のファイルへの相対パスを返します。

このとき返されるパスはSourceMapの生成位置からの相対パスなのですが、 postcss-loaderは何故か返されたパスのみを引数にpath.resolveを呼び出します (コード)。 path.resolveの最初の引数に相対パスを指定した場合、それはカレントディレクトリからの相対パスとみなされるため、 おかしな場所にソースが存在することになってしまいます。 また、path.resolveの結果をそのままSourceMapに設定しているので、postcss-loaderもパスとURLを区別していないことがわかります。

自分のケースでこれが問題となったのは、 font-familyの指定に日本語を指定した結果、libsassによって自動的に@charsetが挿入され、 この行のソースが存在しないことでPostCSSが相対パスを返したためでした。

原因3:sourceRootの消失

css-loaderでは何故か入力のSourceMapのsourceRootをクリアします (コード)。 Issueを少し眺めたところ、css-loaderでのsourceRootのサポートに関しては揺れているようです。

webpackでは最終的にsourceRootを空にしなければならないらしく(参照)、 css-loaderではそのためにsourceRootをクリアしているのだと思われます。 それにしてもsourcesに結合するなり、空じゃないなら例外を投げるなり、もう少しいい方法がありそうな気がしますが。

この処理に関してはソースコードを読んでいる際にたまたま見つけただけで、特に自分が踏み抜いたわけではないのですが、 sass-loader辺りはsourceRootに値を入れたSourceMapを返すので、直接css-loaderに繋げるとバグりそうな気がします。 ちなみにpostcss-loaderを間に挟むと処理中にsourcesに結合されて、sourceRootは空になるので問題は起きません。

原因4:css-loaderのハック

css-loaderでは次のハックによって、PostCSSにパスを書き換えられるのを防いでいます。

pipeline.process(inputSource, {
    // we need a prefix to avoid path rewriting of PostCSS
    from: "/css-loader!" + options.from,
    to: options.to,
    ...

map.sources = map.sources.map(function(source) {
    return source.split("!").pop().replace(/\\/g, '/');
}, this);

どうもこれが原因でパスが壊れるケースがあるみたいです。

また、奇妙なことにこのハックを無効にすると、 前述のURL問題に起因するバグが直ることがあるようです。 そのため自分も最初はこれが原因だと考えていたのですが、 URL問題を解決したらこれがあってもバグが発生しなくなったので関係なかったようです。

原因5:webpackの仕様

ここまでの原因をすべて取り除いて、css-loaderの出力するSourceMapも問題なし、 さあこれで完璧だ、と結果を確認したところ未だにSourceMapのパスが壊れていました。

どうもwebpack自体がドライブレターから始まるWindowsのパスを表すURLである/C:/foo/barという指定を解釈してくれないようです。 結局、先頭の/を取り除いてC:/foo/barに戻したところ正しく表示されました。

さすがに疲れたのでソースコードまでは確認していませんが、原因1のパスURL問題と同じような原因だと思います。

対処

本来はそれぞれの原因に対して修正を加えるべきなのでしょうが、 パッケージ外からそれをしようとするとかなり複雑になるので別の方法で対処することにします。

また、原因3、4については自分の環境で特に問題が発生していないので特に何もしません。

対処1:source-mapのパス対応

原因1は本来source-mapモジュールにファイルシステムのパスを渡してしまう方に問題があるのですが、 あまりに間違っている方が多すぎるので、source-mapモジュール自体をパスに対応させてしまうことで対処します。

幸いにもsource-mapではURL関係の処理をいくつかのユーティリティ関数のみで処理しているようなので、そこに手を加えます。 次のコードをwebpack.config.jsあたりに書いておくと、source-mapが(ある程度)パスを扱ってくれます。

const path = require('path');

if (process.platform === 'win32') {
    const utils = require('source-map/lib/util');
    const { urlParse, normalize, join, isAbsolute, relative } = utils;

    utils.normalize = function (aPath) { return toPath(normalize.call(this, (toUrl(aPath)))); }
    utils.join = function (aRoot, aPath) { return toPath(join.call(this, toUrl(aRoot), toUrl(aPath))); }
    utils.isAbsolute = function (aPath) { return isAbsolute.call(this, aPath) || path.isAbsolute(aPath); }
    utils.relative = function (aRoot, aPath) { return toPath(relative.call(this, toUrl(aRoot), toUrl(aPath))); }

    function toUrl(p) {
        if (!isAbsolute(p)) {
            p = encodeURI(p.replace(/\\/g, "/"));
            return (path.isAbsolute(p) ? "/" + p : p);
        }
        return p;
    }

    function toPath(url) {
        if (!urlParse(url)) {
            url = decodeURI(url).replace(/\//g, "\\");
            return (url.charAt(0) === "\\" ? url.slice(1) : url);
        }
    }
}

URLに変換して処理をした後、再度パスへと戻しているのは、 結果のSourceMapのsourcesやsourceRootにpathモジュールを適用されても問題が発生しないようにするためです。 また、原因5の対処にもなっています。

対処2:PostCSSに相対パスを返させない

原因2はもちろんカレントディレクトリで相対パスをpath.resolveしてしまうことが問題ですが、 postcss-loaderの問題コードの位置が悪くパッチを当てづらいので、相対パスが返ってこないようにすることで対処します。

PostCSSで相対パスが返ってくるのは、対象コードのソースが入力のSourceMapに見つからないことでした。 そこで、sass-loaderとpostcss-loaderの間に独自のローダーを挟んで、SourceMapを加工することにしました。

const { SourceMapConsumer, SourceMapGenerator } = require('source-map');

const CHARSET_CODE = "@charset \"UTF-8\";";

module.exports = function (content, map, meta) {
    if (map) {
        if (content.startsWith(CHARSET_CODE) && map.sourcesContent.every(content => !content.startsWith(CHARSET_CODE))) {
            const consumer = new SourceMapConsumer(map);
            const generator = SourceMapGenerator.fromSourceMap(consumer);
            generator.addMapping({
                source: map.sources[0],
                original: { line: 1, column: 0 },
                generated: { line: 1, column: 0 },
            });
            map = generator.toJSON();
        }
    }

    this.callback(null, content, map, meta);
}

自分の場合は@charset "UTF-8";というコードが挿入されることが原因だったため、 それを探して適当なソースを指定しています。

ローダーの指定はこんな感じ。

{
    loader: 'postcss-loader',
    options: {
        sourceMap: true
    }
},
{
    loader: require.resolve("./my-loader")
},
{
    loader: 'sass-loader',
    options: {
        includePaths: [
            path.resolve(__dirname, "src/scss")
        ],
        sourceMap: true
    }
}

おわりに

最近Webpackに手を出してコードを書くのは非常に快適になったのですが、その分構成周りでかなり苦労しています…。 Web関係は変化が速いせいか定番ツールにも関わらず、結構バグが残っていたりするみたいですね。 特にWindowsは見捨てられているような気がします。



執筆時のバージョン
webpack: 3.8.1
css-loader: 0.28.7
postcss-loader: 2.0.9
sass-loader: 6.0.6

SVGをCanvasに変換しようとして失敗した話

Electronでsvg要素を画像として保存したいと思い、Canvasに変換してからPNGとして保存しようと試みた結果、失敗したという話。

最終的に諦めたのでこの記事に解決策は書いてありません。 同じことをしたい人が時間を無駄にしないための記事です。

drawImageによる変換

svg要素はシリアライズしてからBlobURLに変換するとImageのソースとすることができ、 ImageはdrawImageメソッドによりCanvasに描画できます。

const serializer = new XMLSerializer();
const data = serializer.serializeToString(svg);
const blob = new Blob([data], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);

const image = new Image();
image.addEventListener('load', () => {
    const canvas = document.createElement('canvas');
    canvas.width = image.width;
    canvas.height = image.height;
    canvas.getContext('2d').drawImage(image, 0, 0);
    URL.revokeObjectURL(url);

    // ...
});
image.src = url;

元画像とこの方法による描画結果はそれぞれ次の通り。

f:id:fspace:20171023183142p:plain
元画像(スクリーンショットで保存)

f:id:fspace:20171023183152p:plain
drawImageによる描画結果

元画像と比べると描画結果の線は若干太く、ジャギーが目立ちます。

WebGLによる変換

CanvasのdrawImageでは何らかのフィルタ処理がかかってしまうのではないかと考え、 今度はWebGLでコピーしてみることにしました。

drawImageを次のblit関数に置き換えて描画します。

f:id:fspace:20171023183200p:plain
WebGLによる描画結果

今度は一部の線の色が周期的に変わってしまっています。 この手の模様が発生してしまうケースはどこかで見たような気がするんですが忘れてしまった・・・。

余談;ElectronでWebGLが利用できない場合の対処

実装時、Electronでcanvas.getContext('webgl')を呼んだところエラーが発生。

[11304:1020/195856.066:ERROR:gles2_cmd_decoder.cc(2510)] [GroupMarkerNotSet(crbug.com/242999)!:9896753476020000]GL ERROR :GL_INVALID_ENUM : BackFramebuffer::Create: <- error from previous GL command
[11304:1020/195856.066:ERROR:gles2_cmd_decoder_autogen.h(1612)] [.Offscreen-For-WebGL-000001A9DA2BA5E0]GL ERROR :GL_INVALID_ENUM : GetIntegerv: <- error from previous GL command

呼んでもいないAPIに対してエラーが出ているので、どうもgetContextの内部実装に問題があるようです。

調べたところ--disable-gpu--disable-d3d11といったオプションを指定すると直る場合があるとのこと。

--disable-gpuはGPUハードウェアアクセラレーションを無効にするためのオプションです。 これを指定してみたところエラー自体は消えましたが、同時にWebGLも無効となってしまうようで戻り値がnullになってしまいました。 この動作はバグじゃないのかということでissueが挙がっていたので今後修正されるかもしれません。

--disable-d3d11はDirect3D 11による描画を無効にするためのオプションです。 今回はこれを指定したところエラーが消え、WebGLによる描画もできるようになりました。

原因の推測

うまく描画されない原因についての推測。

drawImageに関してはSVGをソースとするImageを指定した場合のレンダリング方法が表示時と違うのだと思います。 ラスター画像の場合でも拡大縮小時にはフィルタの粗さに違いがあるらしく、ImageをきれいにCanvasに変換するのは難しいらしいです (ただし、最近の仕様ではいくらか調整可能な模様 cf. imageSmoothingEnabled, imageSmoothingQuality)。

WebGLの方はSVGをソースとするImageをTextureへと変換する処理あたりかなと思ってはいますが、 WebGLの機能や仕様自体それほど把握していないのでなんとも言えません。 今回初めてWebGLのコードを書いたので、単純にコードが間違っているだけの可能性もなきにしもあらず。

おわりに

ブラウザに表示されるのと同じように保存したいだけだったのですが、案外うまくいかないものですね。

使用した図から想像がつくかと思いますが、関数グラフの簡易描画ソフトを作ろうとしていました。 フリーのものを探したのですが、自由度が高く、描画がきれいで、広告のあるブログに貼れるとなると結構難しいようです。 とはいえ、これ以上手間をかけて実装する気もないので、どこかで妥協するしかないですね・・・。