Vue.js SFC の Custom Block でルーティングを定義してみる.log
の続き。
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 であるということだ。
以下のコマンドで .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 にもそう書かれている。
vue-loader
のドキュメントを見ていると、Custom Blocks にまつわる記述を発見できた。
これの通りにやれば良さそう。
Vue3 における webpack.config.js
Vue.js のバージョン3は、プロジェクトルートに webpack.config.js
を配置してもロードしてくれない。以下のような webpack.config.js
を配置してビルドしてみることで確かめられる。
process.exit(100)
module.exports = {}
ビルドプロセスが exit code 100 で止まらないので、ロードされていないことが確かめられた。(ちなみに vue.config.js
で同じことを試すと exit code 100 で終了する)
Vue3 では、代わりに vue.config.js
の configureWebpack
/ chainWebpack
を使うらしい。
Webpack のドキュメントにもあるとおり、webpack.config.js
が使われるのはあくまで「普通の場合」なので、Vue3 は普通の場合ではないということなのだろう。vue.config.js
に統合されているのは個人的には良いと思う。
ちなみにこれは Vue.js 本体というよりも vue-cli-service の動作のような印象がある。vue inspect
で設定を確認できるが、vue inspect
は vue-cli-service
へのプロキシであるという記述もある。
というわけで、vue.config.js
をいじっていくことにする。
Webpack Loader
vue.config.js
に色々追記して vue-loader
の設定をいじってやれば良さそうということはわかったが、どういじるのかについてはまだ何もわからない。vue-loader
のドキュメントを見る限り、Custom Blocks には自前の Webpack Loader を設定できるらしいが、そもそも Webpack Loader がどういうふうに動作しているのか全く知らないので(外から見ていてこういうことをやってそうというのはもちろん分かるんだけど)、これについても調べておく。
とりあえずドキュメントを眺めてみる。
-
css-loader
とstyle-loader
の違いがちょっと面白い。css-loader
は CSS code を返すが、style-loader
はモジュールを style として DOM に追加する風のことが書いてある。実際、style-loader
とcss-loader
はセットで使うものらしい。 -
less-loader
やsass-loader
は最終的に.css
ファイルを出力すると思うんだけど、変換中のものはどこに配置されるんだろう。途中の処理がどのように行われているのかがわからない。多分、コンパイル用の一時ファイル置き場のようなものがあるんだろうけれど。 -
css-loader
がurl()
などをrequire()
に置き換えられるらしく、それってやばくない? となっている。別に今までも使ってたんだけど、よく考えたらこれはとてもすごいことなのではないだろうか。css-loader
の処理中にrequire()
を生やせるってことは、Rule A の処理中に Rule B の対象を追加できるってことだよね。順序の制御とかどうやってるんだろう。
Loader についての Webpack API のドキュメントも眺めてみる。なんか色々できそうな雰囲気ある。けど css-loader
の url()
とかどうしてるのかイマイチわからんな。そう思って css-loader
style-loader
を読んでみたけど全く読めなかった。そもそもなんで css-loader
が配列を返すのかもわからない。しかも 0 を要素に持つオブジェクトなのか toString を定義された配列なのか曖昧だし。何なんだまじで。style-loader
に至っては pitch ですべてが完結してるし……。まじで謎すぎる。
style-loader
style-loader
は基本的に css-loader
と同時に使うという想定で作られている。これは特殊な Webpack Loader で、要するに pitch loader なのである。らしい。
この Stackoverflow が詳しい。
このドキュメントによれば、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 theb-loader
spitch
method returned something:
b-loader
の pitch
メソッドが値を返せば、c-loader
は呼ばれない。(pitch
が値を返さないなら、c-loader
の pitch
が呼ばれた後、c-loader
にコンテンツが流され、b-loader
-> a-loader
の順に適用されていく)
style-loader
のコードは
const loaderAPI = () => {};
loaderAPI.pitch = function loader(request) { /* do something */ }
export default loaderAPI;
こういう感じになっている。要するに pitch 内部で全部処理しているということになる。肝心の pitch 内部の処理だが、主に2つのパートに分けられる。前半のオプションを展開しているコードと、オプション(主に injectType
)によって処理を分岐している部分だ。injectType
のデフォルト値は styleTag
なので、それ以外のコードを削除したり整理したりしてみる。
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
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
を書く。
{
"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
を見てみる。
"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
は以下。
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
{
"extension": ".vue"
}
エントリポイントとして作る虚無な存在だが、filter
は面倒すぎたので extension
でお茶を濁した。もしかしたら ./views
以下に設置していて route
を持っているのに router
に読ませたくないものが存在するかもしれないと思ったけど、面倒だったので。
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'
の型を定義する必要がある。
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