JavaScript
TypeScript
webpack
css-modules

TypeScript2 + webpack2でのcss-loader, file-loader周辺をなんとかする

More than 1 year has passed since last update.

WebpackとTypeScriptの扱いについて、まだ手探りだが、今のところのプラクティスをまとめたい。

css-loader, file-loaderなどで読み込むリソースが型エラーになるのをなんとかする話

webpack + babel + css-loader という構成のところからwebpack + typescriptにしていくにあたって、js, ts以外のファイルの読み込みは、当然そのままだと型がエラーとなる。

これを解決するのに、2点ほど手順が必要だった。

手順1. 読み込み方法を変更

file-loaderなどはmodule.exports = "some-resource"という形で吐き出しされるので、TypeScriptにあわせるのであれば、importの仕方を変える方が良い。
(Webpack2のtree shakingを利用する場合は、元のコードでも壊れず動いてしまうのだが、これについては後述)

// 元のコード
import style from "./foo.css"

これをこうしていく。

import * as style from "./foo.css"
// または
import style = require("./foo.css")

2. typing

型定義をしないとtypescriptは落ちるので行う。
tsconfg.jsonで独自なtypingsディレクトリを用意した上で型定義をすると良さそう。

{
    ...
    "typeRoots" : [
        "typings",
        "node_modules/@types/"
    ]
    ...
}

型定義にはWildcard module declarationという定義方法を使う事で乗り越えられる。

「とりあえずanyでいいから!なんとかしたい!」という場合は、こんな具合。

// typings/resource.d.ts

declare module "*.css"
declare module "*.png"
declare module "*.jpg"

「もうちょっと毛が生えた感じに!」という場合はこのぐらいまでならサクッと定義できる

// typings/resource.d.ts
declare module "*.css" {
  const classes: {[className: string]: string} // css-moduleの結果をstring型のobjectに
  export = classes
  // import style from "./foo.css"で読み込みたいなら下記(後述)
  // export default classes
}

declare module "*.png" {
  const content: string
  export = content
  // import style from "./some.png"で読み込みたいなら下記(後述)
  // export default content
}

この定義はこのへんを参考にした。
* https://github.com/s-panferov/awesome-typescript-loader/issues/146#issuecomment-248808206
* https://github.com/Quramy/typed-css-modules/issues/2#issuecomment-256794347)
* https://github.com/Microsoft/TypeScript/issues/6615#issuecomment-188593420

「もっとちゃんと型定義したい!」という場合は、下記などを導入するのが良いだろう。

webpackでのimport解決のされ方はtsconfig.jsonのcompilerOptions.moduleによって変わるという話

TL;DR
* module: es2015の場合、typescriptはモジュール解決に関与しない。webpackにモジュール解決が移譲されて、babelっぽく扱われる(babelと互換性が維持されるが、ESmoduleの仕様とズレるので、今後どこかで変更されるだろうと思われる)
* module: commonjsだと、typescriptがimport / exportを変換する。互換性は維持されない(正しいESmoduleの仕様に準拠する。babelとの互換性はなくなる)

前提(と疑問)

例えばurl-loaderfile-loaderは、下記のような吐き出しをする

/******/ ([
/* 0 */
/***/ (function(module, exports) {

module.exports = "data:text/plain;base64,aGVsbG8="

/***/ }),

ESmoduleの読み方で言えばimport * as foo from "foo"という読み方でないと読み込めない理屈になる。
しかし、自分の手元で正しく動いていたので、「これは何故だろう?」という疑問が出てきた。

実験

まずこんなTypeScriptファイルを用意

import * as txt1 from "./foo.txt"
import txt2 from "./foo.txt"

console.log("===========")
console.log("import * as txt1")
console.log("=>", txt1)
console.log("===========")
console.log("===========")
console.log("import txt2")
console.log("=>", txt2)
console.log("===========")

Webpack設定を用意。
今回は、外からcompilerOptions.moduleを変更できるようにする。

module.exports = ( env ) => {
  const { moduleType } = env
  return {
    entry: './src/index.ts',
    output: {
      path: `output/${moduleType}`,
      filename: 'bundle.js'
    },
    resolve: {
      extensions: ['.ts', '.js']
    },
    module: {
      rules: [
        { test: /\.txt$/, use: 'url-loader' },
        { test: /\.ts$/, use: [{
          loader: 'ts-loader',
          options: {
            compilerOptions: {
              // tsconfig.jsonのmoduleを上書き
              module: moduleType
            }
          }
        }]},
      ]
    }
  }
}

そしてビルド→実行をする

まずcommonjsの場合

$ webpack --env.moduleType=commonjs
$ node output/commonjs/bundle.js
===========
import * as txt1 from "
=> data:text/plain;base64,aGVsbG8=
===========
===========
import txt2
=> undefined
===========

結果:import txt2 from "./foo.txtundefinedになる。

次にes2015

$ webpack --env.moduleType=es2015
$ node output/es2015/bundle.js
===========
import * from txt1
=> data:text/plain;base64,aGVsbG8=
===========
===========
import txt2
=> data:text/plain;base64,aGVsbG8=
===========

結果:import * as txt1 from "./foo.txt"import txt2 from "./foo.txt"、どちらもstringが取得できる。

解説

commonjsの場合の話

Typescript(ts-loader)の段階で、import module from "./moduleは下記のような感じで変換される

var module_1 = require("./module");
var a = function () {
    module_1["default"]();
};

最終的にこんな感じに変換される。

var txt1 = __webpack_require__(0);
var foo_txt_1 = __webpack_require__(0); // txt2として読み込んだもの
console.log("===========");
console.log("import * as txt1");
console.log("=>", txt1);
console.log("===========");
console.log("===========");
console.log("import txt2");
console.log("=>", foo_txt_1.default); // .defaultにアクセスしてる
console.log("===========");

file-loaderはmodule.exports.defaultに何も吐き出してないので、commonjsモードでimport foo from "./foo"と読み込むと、undefinedになってしまう。

es2015の場合の話

TypeScriptはimport / export構文をいじらず、そのまま吐き出す。

import mod from "./module"
const a = () => {
    mod()
}

これをwebpackが受け取り処理する。

webpackのloaderはbabel同様、__esModuleフラグを利用した後方互換維持の処理をしている。(これを ESM interopと呼んでるそうだ)
source

/******/    // getDefaultExport function for compatibility with non-harmony modules
/******/    __webpack_require__.n = function(module) {
/******/        var getter = module && module.__esModule ?
/******/            function getDefault() { return module['default']; } :
/******/            function getModuleExports() { return module; };
/******/        __webpack_require__.d(getter, 'a', getter);
/******/        return getter;
/******/    };

最終的にはこんな感じになっている。

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__foo_txt__ = __webpack_require__(0);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__foo_txt___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__foo_txt__);


console.log("===========");
console.log("import * as txt1");
console.log("=>", __WEBPACK_IMPORTED_MODULE_0__foo_txt__);
console.log("===========");
console.log("===========");
console.log("import txt2");
console.log("=>", __WEBPACK_IMPORTED_MODULE_0__foo_txt___default.a); // .defaultへのアクセスではなく、Object.definePropertyのgetterとしてアクセスするように変換されている
console.log("===========");

これによって、結果的に`import foo from "foo.css"でも読み出せるようになってる模様。

この後方互換は今後どうするかは議論対象になっている様なので、ある程度注意はしたほうが良さそう(とはいえ、議論を軽く追ってみるとwebpack2の間に突然消える、のようなことが起きることはなさそうに思える)

Webpack2(babel)の仕様に併せてimportが壊れるケースもあるらしいので注意

まとめ

  • webpackのtree shakingな読み方はbabelと同じ事やってるので、es2015にすればloader系がいきなりぶっ壊れたりはしない。
  • 可能なら、型によって読み出しを制御しつつ、TypeScriptの手法に併せていった方が無難と思われる。
  • 「どうせloader使ってるならwebpackに併せてしまえばいいのでは?」という判断とかもありえるが、そこらへんは自己判断で。
  • commonjsにして変な読み出しをさせないようにする、というのも無くはなさそうだが、型で防ぐでも十分には思える
  • webpackもそのうちこの辺の読み出しに動きがあると思われるので、何処かでは書き換えが発生するものと考えたほうが良いだろう。