🚥

Vue.js SFC の Custom Block でルーティングを定義してみる.log

2021/12/30に公開

https://zenn.dev/niaeashes/articles/9cf121b46af2a0

の続き。

Vue.js without Nuxt.js での SSR ができるようになってプロジェクトが最低限の体裁を整えたので、本当にやりたかったことを試していく。今回は Vue.js における SFC の Custom Block を試す。

Vue.js の SFC (Single File Component) は、1つのファイルで Vue Component を定義できるやつで、普通にみんな使っている .vue ファイルである。これは <template> <script> <style> の3つのブロックで構成されている。実はこのブロックは自由に追加することができる。やってみたことはないので、今回は <route> ブロックを追加して、router.ts を Webpack を使って構成するということをやってみる。

事前調査

vue-loader

.vue ファイルは JavaScript ファイルではない。しかし、最終的には実行可能な JavaScript のソースコードに変換されているはずである。その変換を行っているのが vue-loader だ。これは、Webpack Loader で、つまり .vue を JavaScript に変換しているのは Webpack であるということだ。

https://vue-loader.vuejs.org/

以下のコマンドで .vue ファイルのコンパイル時のルールを確認できる。

vue inspect --rule vue
/* config.module.rule('vue') */
{
  test: /\.vue$/,
  use: [
    /* config.module.rule('vue').use('vue-loader') */
    {
      loader: '<project-root>/node_modules/vue-loader-v16/dist/index.js',
      options: {
        cacheDirectory: '<project-root>/node_modules/.cache/vue-loader',
        cacheIdentifier: '***',
        babelParserPlugins: [
          'jsx',
          'classProperties',
          'decorators-legacy'
        ]
      }
    }
  ]
}

確かに .vue ファイルのコンパイルに vue-loader が利用されるという記述になっている。キャッシュにまつわるオプションがあるけど、これはまああんまり気にしなくて良さそう。

SFCのコンパイルに vue-loader が使われているなら、ブロックを処理しているのもこいつのはずだ。vue-loader の README にもそう書かれている。

https://github.com/vuejs/vue-loader

vue-loader のドキュメントを見ていると、Custom Blocks にまつわる記述を発見できた。

https://vue-loader.vuejs.org/guide/custom-blocks.html

これの通りにやれば良さそう。

Vue3 における webpack.config.js

Vue.js のバージョン3は、プロジェクトルートに webpack.config.js を配置してもロードしてくれない。以下のような webpack.config.js を配置してビルドしてみることで確かめられる。

webpack.config.js
process.exit(100)
module.exports = {}

ビルドプロセスが exit code 100 で止まらないので、ロードされていないことが確かめられた。(ちなみに vue.config.js で同じことを試すと exit code 100 で終了する)

Vue3 では、代わりに vue.config.jsconfigureWebpack / chainWebpack を使うらしい。

https://cli.vuejs.org/config/#configurewebpack

Webpack のドキュメントにもあるとおり、webpack.config.js が使われるのはあくまで「普通の場合」なので、Vue3 は普通の場合ではないということなのだろう。vue.config.js に統合されているのは個人的には良いと思う。

ちなみにこれは Vue.js 本体というよりも vue-cli-service の動作のような印象がある。vue inspect で設定を確認できるが、vue inspectvue-cli-service へのプロキシであるという記述もある。

https://cli.vuejs.org/guide/webpack.html#modifying-options-of-a-plugin

というわけで、vue.config.js をいじっていくことにする。

Webpack Loader

vue.config.js に色々追記して vue-loader の設定をいじってやれば良さそうということはわかったが、どういじるのかについてはまだ何もわからない。vue-loader のドキュメントを見る限り、Custom Blocks には自前の Webpack Loader を設定できるらしいが、そもそも Webpack Loader がどういうふうに動作しているのか全く知らないので(外から見ていてこういうことをやってそうというのはもちろん分かるんだけど)、これについても調べておく。

https://webpack.js.org/loaders/

とりあえずドキュメントを眺めてみる。

  • css-loaderstyle-loader の違いがちょっと面白い。css-loader は CSS code を返すが、style-loader はモジュールを style として DOM に追加する風のことが書いてある。実際、style-loadercss-loader はセットで使うものらしい。
  • less-loadersass-loader は最終的に .css ファイルを出力すると思うんだけど、変換中のものはどこに配置されるんだろう。途中の処理がどのように行われているのかがわからない。多分、コンパイル用の一時ファイル置き場のようなものがあるんだろうけれど。
  • css-loaderurl() などを require() に置き換えられるらしく、それってやばくない? となっている。別に今までも使ってたんだけど、よく考えたらこれはとてもすごいことなのではないだろうか。css-loader の処理中に require() を生やせるってことは、Rule A の処理中に Rule B の対象を追加できるってことだよね。順序の制御とかどうやってるんだろう。

https://webpack.js.org/api/loaders

Loader についての Webpack API のドキュメントも眺めてみる。なんか色々できそうな雰囲気ある。けど css-loaderurl() とかどうしてるのかイマイチわからんな。そう思って css-loader style-loader を読んでみたけど全く読めなかった。そもそもなんで css-loader が配列を返すのかもわからない。しかも 0 を要素に持つオブジェクトなのか toString を定義された配列なのか曖昧だし。何なんだまじで。style-loader に至っては pitch ですべてが完結してるし……。まじで謎すぎる。

style-loader

style-loader は基本的に css-loader と同時に使うという想定で作られている。これは特殊な Webpack Loader で、要するに pitch loader なのである。らしい。

https://stackoverflow.com/questions/55789849/how-style-loader-works-with-css-loader

この Stackoverflow が詳しい。

https://webpack.js.org/api/loaders/#pitching-loader

このドキュメントによれば、Webpack Loader は通常の処理順とは逆の順序で pitch メソッドが呼ばれていくようになっている。ここで、もし pitch メソッドが値を return すれば、それ以後の Loader は処理されない。

module.exports = {
  //...
  module: {
    rules: [
      {
        //...
        use: ['a-loader', 'b-loader', 'c-loader'],
      },
    ],
  },
};

Second, if a loader delivers a result in the pitch method, the process turns around and skips the remaining loaders. In our example above, if the b-loaders pitch method returned something:

b-loaderpitch メソッドが値を返せば、c-loader は呼ばれない。(pitch が値を返さないなら、c-loaderpitch が呼ばれた後、c-loader にコンテンツが流され、b-loader -> a-loader の順に適用されていく)

style-loader のコードは

style-loader/src/index.js
const loaderAPI = () => {};

loaderAPI.pitch = function loader(request) { /* do something */ }

export default loaderAPI;

こういう感じになっている。要するに pitch 内部で全部処理しているということになる。肝心の pitch 内部の処理だが、主に2つのパートに分けられる。前半のオプションを展開しているコードと、オプション(主に injectType)によって処理を分岐している部分だ。injectType のデフォルト値は styleTag なので、それ以外のコードを削除したり整理したりしてみる。

style-loader/src/index.js
loaderAPI.pitch = function loader(request) {
  const options = this.getOptions(schema);
  const injectType = options.injectType || "styleTag";
  const esModule =
    typeof options.esModule !== "undefined" ? options.esModule : true;
  const runtimeOptions = {};

  if (options.attributes) {
    runtimeOptions.attributes = options.attributes;
  }

  if (options.base) {
    runtimeOptions.base = options.base;
  }

  const insertType =
    typeof options.insert === "function"
      ? "function"
      : options.insert && path.isAbsolute(options.insert)
      ? "module-path"
      : "selector";

  const styleTagTransformType =
    typeof options.styleTagTransform === "function"
      ? "function"
      : options.styleTagTransform && path.isAbsolute(options.styleTagTransform)
      ? "module-path"
      : "default";

  switch (injectType) {
    case "styleTag": {
      return `
      ${getImportStyleAPICode(esModule, this)}
      ${getImportStyleDomAPICode(esModule, this, false, false)}
      ${getImportInsertBySelectorCode(esModule, this, insertType, options)}
      ${getSetAttributesCode(esModule, this, options)}
      ${getImportInsertStyleElementCode(esModule, this)}
      ${getStyleTagTransformFnCode(
        esModule,
        this,
        options,
        false,
        styleTagTransformType
      )}
      ${getImportStyleContentCode(esModule, this, request)}
      ${
        esModule
          ? ""
          : `content = content.__esModule ? content.default : content;`
      }
var options = ${JSON.stringify(runtimeOptions)};
${getStyleTagTransformFn(options, false)};
options.setAttributes = setAttributes;
${getInsertOptionCode(insertType, options)}
options.domAPI = ${getdomAPI(isAuto)};
options.insertStyleElement = insertStyleElement;
var update = API(content, options);
${getExportStyleCode(esModule, this, request)}
`;
    }
  }
};

……まあ雰囲気くらいはわかる気がする。getImportStyleAPICode だかなんだかに移譲しまくっているので、見ても全くどういうことなのか理解できないが。めんどくなったので node_modules 下にある node_modules/style-loader/dist/index.js をいじって実際に生成された pitch の返り値を取り出したのが以下。

import API from "!../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js";
import domAPI from "!../node_modules/style-loader/dist/runtime/styleDomAPI.js";
import insertFn from "!../node_modules/style-loader/dist/runtime/insertBySelector.js";
import setAttributes from "!../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js";
import insertStyleElement from "!../node_modules/style-loader/dist/runtime/insertStyleElement.js";
import styleTagTransformFn from "!../node_modules/style-loader/dist/runtime/styleTagTransform.js";

import content, * as namedExport from "!!../node_modules/css-loader/dist/cjs.js!./main.css";

var options = {};

options.styleTagTransform = styleTagTransformFn;
options.setAttributes = setAttributes;

options.insert = insertFn.bind(null, "head");

options.domAPI = domAPI;
options.insertStyleElement = insertStyleElement;

var update = API(content, options);

// *1
export * from "!!../node_modules/css-loader/dist/cjs.js!./main.css";
export default content && content.locals ? content.locals : undefined;

import content, * as namedExport from "!!../node_modules/css-loader/dist/cjs.js!./main.css"; の部分で css-loader の返り値を受け取っているようだ。つまり、pitch 内部で生成したコード上で css-loader を適用した .css ファイルをインポートしているので、Webpack でのコンパイル時点でそれ以上ロードする必要はないということだろうか。

style-loader は基本的に <style> タグを HTML に挿入することで CSS をページにロードするように動作するので、コンパイル時点では style-loader を適用するファイルが適切に配置されさえすれば良い。

しかしそのように動作した場合、Webpack は main.css をどのように検出して処理するんだろうか。pitch の返り値が eval されてるのだろうか。そう思って最後2行以外をすべてコメントアウトした状態で *1 をコメントアウトしたり戻したりしてみたが、!!../node_modules/css-loader/dist/cjs.js!./main.css がインポートされている行があるかどうかでコンパイル対象になるかどうかが変わった。

……よくよく考えれば当たり前のような気もする。例えば JS ファイルを Webpack で処理する場合を考えると、ファイル内部にある import を検出して、そのファイルをふさわしいルールに基づいて loader に処理させなければならない。CSS だろうがほかのものだろうが、この点に変わりはないってことだろう。

ということは、ファイルAをロードしたときに別のファイルをランタイムのロード対象にしたい場合、 pitch の返り値に import を挿入しておけば良いということになる。

実装

調査でめちゃ疲れてしまった。実装する。

仕様

routes-json-loader.js

src/router/index.ts
import { createRouter, RouterHistory, Router } from 'vue-router'
import { routes } from '../views/views.route.json'

const router = function (history: RouterHistory): Router {
  return createRouter({ history, routes })
}

export default router

このようにできる views.route.json の Webpack Loader route-loader.js を書く。

views.route.json
{
    "filter": "*.vue"
}

書く意味あるのか怪しいが views.route.json の中身はこんな感じ。Webpack はディレクトリのロードというのがないようなので、ロード対象となるファイルを捏造した。単に views.route ファイルでも良いかもしれない。このあたりは実装してみて考える。

route-block-loader.js

<route> Custom Block を処理する Webpack Loader。まあ普通に書く。

<route> ブロックの中身は単純な JSON で良さそう。念のため(?) <route lang="json"> とかしたほうがいいのかもしれない。いやしなくていいか。

TypeScript で書くのめんどいので JavaScript で書く。

<route> Custom Block

適当にドキュメントを参考にやってみたが、何もうまくいかない。どうやらドキュメントは古いらしい。そして vue-loader のリポジトリにはいくつか Custom Block に関係する Issue が上がっているが、そちらもとても古い。最高に面倒な気分になってきた。

module.exports = function (source, map) {
  this.callback(
    null,
    `export default function (Component) {
      Component.options.__docs = ${
        JSON.stringify(source)
      }
    }`,
    map
  )
}

例示されているこれを実行しても options が undefined です としか言われず「???」状態。import Home from './src/views/Home.vue' した時の Home と、Custom Block Loader の第一引数(↑でいう Component)がぜんぜん違うものっぽい。

めちゃくちゃいろいろ調べた(だいぶ疲れてたので)が、途中で方向転換して vue-loader が実際に生成しているコードを見ることにした。

import script from "./Home.vue?vue&type=script&lang=ts"
export * from "./Home.vue?vue&type=script&lang=ts"
/* custom blocks */
import block0 from "./Home.vue?vue&type=custom&index=0&blockType=route"
if (typeof block0 === 'function') block0(script)

import exportComponent from "<project-root>/node_modules/vue-loader-v16/dist/exportHelper.js"
const __exports__ = /*#__PURE__*/exportComponent(script, [['render',render],['__file',"src/views/Home.vue"]])

export default __exports__

長いので Hot Reload に関係するコードは削除している。Custom Block の Webpack Loader が動作しているのは 4-5 行目のこの部分。

import block0 from "./Home.vue?vue&type=custom&index=0&blockType=route"
if (typeof block0 === 'function') block0(script)

block0に渡しているのは script というやつらしい。で、実際に export されている Vue Component は8行目で __exports__ にセットされている。

const __exports__ = /*#__PURE__*/exportComponent(script, [['render',render],['__file',"src/views/Home.vue"]])

こっちを引数で渡してくれ……。

vue-loader に文句を言っても始まらないので、解決策を考える。要するに __exports__ と同じものが得られれば良い。そこで、__exports__ を生成している exportComponent を見てみる。

node_modules/vue-loader-v16/dist/exportHelper.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// runtime helper for setting properties on components
// in a tree-shakable way
exports.default = (sfc, props) => {
    const target = sfc.__vccOpts || sfc;
    for (const [key, val] of props) {
        target[key] = val;
    }
    return target;
}

シンプルなファイルだ。まあよくわからんけど、const target = sfc.__vccOpts || sfc; がキモっぽい。sfc は第一引数なので、vue-loader の生成ファイルでいう script に該当する。

ということは、最初の例は以下のように修正すれば良い。

module.exports = function (source, map) {
  this.callback(
    null,
-     `export default function (Component) {
+     `export default function (script) {
+     const Component = sfc.__vccOpts || sfc
      Component.options.__docs = ${
        JSON.stringify(source)
      }
    }`,
    map
  )
}

動くかな。しらんけど、options は存在しない気がする。最終的に作った route-block-loader.js は以下。

route-block-loader.js
module.exports = function loader(source, map) {
  this.callback(
    null,
    `export default function (sfc) {
      /* [!] node_modules/vue-loader-v16/dist/exportHelper.js */
      const target = sfc.__vccOpts || sfc
      target.__route = ${source}
    }`,
    map
  )
}

__route に型を設定する方法はわからなかったし正直なところどうでもいいと思ったので利用時に as any することにした。虚無。いつか TypeScript に詳しくなったらやってもいいかもしれない。

実際は "./Home.vue?vue&type=custom&index=0&blockType=route" これに反応する declare module を書いてやれば良さそうに思うので、暇があったらやってみよう。(TypeScript のトランスパイルが死んだ時のエラーメッセージが情報量少なすぎてダルいんですが、 tsconfig.json になにか書けば解決するのかな。types/*.d.ts が TypeScript に読まれていない時、エラーが出ずに単に無視されて原因がわからず3時間くらい無駄にした)

話を戻すと、↑ の実装は exportHelper.js の実装が変われば壊れるので危険度は高い。良くないけど vue-loader 側がいい感じになってくれないとどうしようもないので、一旦これで。

views.routes.json

views.routes.json
{
    "extension": ".vue"
}

エントリポイントとして作る虚無な存在だが、filter は面倒すぎたので extension でお茶を濁した。もしかしたら ./views 以下に設置していて route を持っているのに router に読ませたくないものが存在するかもしれないと思ったけど、面倒だったので。

routes-json-loader.js
const path = require('path')
const fs = require('fs/promises')

module.exports = function loader (source) {
  const options = JSON.parse(source)
  console.log('routeLoader', { options })
  const extension = options.extension || '.vue'
  const callback = this.async()

  let imports = []
  let pushes = []

  const crowlDir = async (dirpath) => {
    const files = await fs.readdir(dirpath)
    await Promise.all(files.map(async (file) => {
      const fullpath = path.join(dirpath, file)
      const stat = await fs.stat(fullpath)
      if (stat.isDirectory()) {
        await crowDir(fullpath)
      } else if (file.endsWith(extension)) {
        const relativePath = path.relative(path.dirname(this.resourcePath), fullpath)
        const name = file
          .replace(extension, '')
          .replace(/[\.\-]/g, '')
        pushes.push(`if (${name}.__route) routes.push({ ...${name}.__route, component: ${name} })\nelse console.error("${name} is no route. add <route> custom block in ${name}.vue file.")`)
        imports.push(`import ${name} from './${relativePath}'`)
      }
    }))
  }

  crowlDir(path.dirname(this.resourcePath))
    .then(() => {
      const content = `
${imports.join("\n")}

let routes = []
${pushes.join("\n")}
export { routes }`
      callback(null, content)
    })
}

きたねえコードだ。真面目に書くならエラーのハンドリングと、ヒットしないファイルがあった場合になにかメッセージを残すとかやったほうが良いかも?(一応、Webpack の機能で生成されたファイルの内容は確認できるけど……)

const name = file
  .replace(extension, '')
  .replace(/[\.\-]/g, '')

ファイル名からコンポーネント名を作っているが、.- 以外にも処理したほうが良いなにかがあるかもしれない。わからない。

./views 以下にある Vue Component が <route> ブロックを持っていない場合、 console.error されます。

この Loader によって生成されたファイルを利用する側(つまり src/router/index.ts)は TypeScript なので、import './views/views.routes.json' の型を定義する必要がある。

types/routes.d.ts
declare module '*.routes.json' {
  import { RouteRecordRaw } from 'vue-router'
  export var routes: Array<RouteRecordRaw>;
}

vue.config.js

いろいろ書き足します。

+const path = require('path')

const { WebpackManifestPlugin } = require('webpack-manifest-plugin')
const nodeExternals = require('webpack-node-externals')

module.exports = {
  chainWebpack: webpackConfig => {

+    webpackConfig.module
+      .rule('routes.json')
+      .test(/\.routes\.json$/)
+      .use('./loader/routes-json-loader.js')
+        .loader(path.resolve('./loaders/routes-json-loader.js'))
+        .end()
+      .type('javascript/auto')

+    webpackConfig.module
+      .rule('routeBlock')
+      .resourceQuery(/blockType=route/)
+      .use('./loaders/route-block-loader.js')
+        .loader(path.resolve('./loaders/route-block-loader.js'))
+        .end()

疲れた。

「Custom Block、使ってみたら超カンタンだったぜ!みんなも使おう!」って書く予定だったけど無理だわ。

終わりに

Custom Block、多分だれも使ってなくてしょっぱい感じだった。しかし夢はある気がする。

<dummy target="data"> でダミーデータのデフォルト値を入れて VS Code で Vue Component をプレビューできるような Extension を書くとか、<docs> でドキュメントを入れるとか、色々できそう。

あーあと、ネストしたルーティングを処理できないのでいずれどうにかします。

Discussion