SFC のコンパイルの流れ
さて,最終的な出力コードを出力すための実装を見てみましょう.
改めて,
<template>
<p>Hello, Vapor!</p>
</template>
から,
const _sfc_main = {};
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
const n0 = t0();
return n0;
}
export default Object.assign(_sfc_main, {
render: _sfc_render,
vapor: true,
__file: "/path/to/App.vue",
});
を作る方法です.
前ページまでで最も複雑な render の出力はわかっているので,ここからはさらっと行けるはずです.
compiler-sfc と vite-plugin-vue を読む
ここからは Vapor Mode というより,compiler-sfc
と vite-plugin-vue
の実装です. 非 Vapor の場合は,概ね,
const _sfc_main = {};
import { createElement as _createElement } from "vue";
function _sfc_render(_ctx) {
return _createElement("p", null, "Hello, World!");
}
export default Object.assign(_sfc_main, {
render: _sfc_render,
__file: "/path/to/App.vue",
});
のようになるだけで,周辺のコードや変換の流れは変わりません.
しかるべきタイミングで compiler-vapor
に実装されてコンパイラが呼び出されるだけです.
SFC はいつコンパイルされる?
compiler-sfc
のエントリポイントを見てもわかる通り,ここにはバラバラの compiler が export されているだけです.
4export { parse } from './parse'
5export { compileTemplate } from './compileTemplate'
6export { compileStyle, compileStyleAsync } from './compileStyle'
7export { compileScript } from './compileScript'
これらを統合的に扱う実装はここにはありません.
Vite のプラグインシステム
冒頭でも述べた通りこれらの実装はバンドラ等のツールによって呼び出され,それぞれのコンパイルが実行されます.
ツールは様々ありますが,今回は Vite
を前提に見てみます.
Vite
でこれを担うのは公式のプラグインである vite-plugin-vue
が有名です.
https://github.com/vitejs/vite-plugin-vue
プラグインは vite.config.js
に設定することで有効になります.
皆さんは Vite で Vue.js を使う時このように書くことがあるかと思います.
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
まさにこの vue()
がプラグインコードを生成しています.
このプラグインの実装を見る前に,まずは Vite のプラグインのコンセプトを把握しましょう.
主に重要になるのは transform
というフックです.
https://vitejs.dev/guide/api-plugin.html#simple-examples
前提として, Vite のプラグインシステムは rollup のスーパーセットであり,production build では実際に rollup のプラグインシステムが使われます.
https://vitejs.dev/guide/api-plugin.html#plugin-api
Vite plugins extends Rollup's well-designed plugin interface with a few extra Vite-specific options. As a result, you can write a Vite plugin once and have it work for both dev and build.
transform フック
プラグインを実装する際,この transform
というフックに処理を書くことでモジュールを変換することができます.
ざっくり,ここでコンパイラを動かすことで SFC をコンパイルすることができます.
そして,この transform
がいつ実行されるか,というのは概ね「JavaScript からモジュールが読み込まれた時」です.
もっというと,import
や import()
が実行された時です.
development mode
そして,いつ import が実行されるのかというのはモードによって違います. 開発者モードの場合はブラウザ上で import
が記述された JavaScrip が読み込まれ,そこか実行された時にブラウザ Native ESM の仕組みを使い開発サーバーにリクエストが飛びます.
開発サーバーはそれをハンドリングし,transform
を実行,結果をブラウザに返します.
この先は Native ESM と同じです.
この仕組みは Vite が実装しています.
67export function transformRequest(
68 environment: DevEnvironment,
69 url: string,
70 options: TransformOptions = {},
71): Promise<TransformResult | null> {
126 const request = doTransform(environment, url, options, timestamp)
190 const result = loadAndTransform(
191 environment,
192 id,
193 url,
194 options,
195 timestamp,
196 module,
197 resolved,
198 )
344 const transformResult = await pluginContainer.transform(code, id, {
345 inMap: map,
346 })
456 for (const plugin of this.getSortedPlugins('transform')) {
457 if (this._closed && this.environment.config.dev.recoverable)
458 throwClosedServerError()
459 if (!plugin.transform) continue
460
461 ctx._updateActiveInfo(plugin, id, code)
462 const start = debugPluginTransform ? performance.now() : 0
463 let result: TransformResult | string | undefined
464 const handler = getHookHandler(plugin.transform)
465 try {
466 result = await this.handleHookPromise(
467 handler.call(ctx as any, code, id, optionsWithSSR),
468 )
469 } catch (e) {
production mode
プロダクションモードのビルドでは rollup
のバンドラが動きます.
バンドラはモジュールを解決するときに import
を読みます.
その読み込みの際に,transform
を実行し,結果をその解決結果とします.
これは rollup が実装しています.
Vite は概ね rollup の bundle
関数を呼んでいるだけです.
507export async function build(
508 inlineConfig: InlineConfig = {},
509): Promise<RollupOutput | RollupOutput[] | RollupWatcher> {
838 const { rollup } = await import('rollup')
839 startTime = Date.now()
840 bundle = await rollup(rollupOptions)
rollup が transform を呼び出しているコード:
31export default async function transform(
32 source: SourceDescription,
33 module: Module,
34 pluginDriver: PluginDriver,
35 log: LogHandler
36): Promise<TransformModuleJSON> {
102 code = await pluginDriver.hookReduceArg0(
103 'transform',
327 await module.setSource(
328 await transform(sourceDescription, module, this.pluginDriver, this.options.onLog)
329 );
191 hookReduceArg0<H extends AsyncPluginHooks & SequentialPluginHooks>(
192 hookName: H,
193 [argument0, ...rest]: Parameters<FunctionPluginHooks[H]>,
194 reduce: (
195 reduction: Argument0<H>,
196 result: ReturnType<FunctionPluginHooks[H]>,
197 plugin: Plugin
198 ) => Argument0<H>,
199 replaceContext?: ReplaceContext
200 ): Promise<Argument0<H>> {
201 let promise = Promise.resolve(argument0);
202 for (const plugin of this.getSortedPlugins(hookName)) {
203 promise = promise.then(argument0 =>
204 this.runHook(
205 hookName,
206 [argument0, ...rest] as Parameters<FunctionPluginHooks[H]>,
207 plugin,
208 replaceContext
209 ).then(result => reduce.call(this.pluginContexts.get(plugin), argument0, result, plugin))
210 );
211 }
212 return promise;
213 }
vite-plugin-vue の transform フック
vite-plugin-vue の transform
フックの実装はこの辺りです.
320 transform(code, id, opt) {
321 const ssr = opt?.ssr === true
322 const { filename, query } = parseVueRequest(id)
323
324 if (query.raw || query.url) {
325 return
326 }
327
328 if (!filter.value(filename) && !query.vue) {
329 return
330 }
331
332 if (!query.vue) {
333 // main request
334 return transformMain(
335 code,
336 filename,
337 options.value,
338 this,
339 ssr,
340 customElementFilter.value(filename),
341 )
ここで transformMain
という関数を実行しています.
transformMain
は vite-plugin-vue/packages/plugin-vue/src/main.ts に実装されています.
30export async function transformMain(
31 code: string,
32 filename: string,
33 options: ResolvedOptions,
34 pluginContext: TransformPluginContext,
35 ssr: boolean,
36 customElement: boolean,
37) {
この中で,compiler-sfc
の compileScript
や compileTemplate
が呼ばれています.
これでどのように Vue.js のコンパイラが設定され,どのタイミングで実行されるかがわかったはずです.
transformMain を呼んで出力コードの全体を掴む
このようなコンパイル結果を思い出してください.
const _sfc_main = {};
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
const n0 = t0();
return n0;
}
export default Object.assign(_sfc_main, {
render: _sfc_render,
vapor: true,
__file: "/path/to/App.vue",
});
のようなコードをどう出力すれば良いかです.概ね,
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
const n0 = t0();
return n0;
}
の部分は compileTemplate
という関数によって生成されます.
const _sfc_main = {};
// <---------------- insert compileTemplate result
export default Object.assign(_sfc_main, {
render: _sfc_render,
vapor: true,
__file: "/path/to/App.vue",
});
もし <script>
や <script setup>
があった場合は,
const constant = 42;
const _sfc_main = {
props: {
count: {
type: Number,
required: true,
},
},
setup() {
const localCount = ref(0);
return { localCount };
},
};
// <---------------- insert compileTemplate result
export default Object.assign(_sfc_main, {
render: _sfc_render,
vapor: true,
__file: "/path/to/App.vue",
});
のようなコードが生成されますが,こは主に compileScript
によって生成されます.
つまり,以下のような感じです.
// <---------------- insert compileScript result
// <---------------- insert compileTemplate result
export default Object.assign(_sfc_main, {
render: _sfc_render,
vapor: true,
__file: "/path/to/App.vue",
});
そして,最後の,_sfc_main
にプロパティを追加する部分ですが,これは attachedProps
として収集され,コードとして展開されます.
ここまでの話のソースコードが以下の部分になります.
(compileScript
, compileTemplate
ではなく genScriptCode
, genTemplateCode
を呼び出してますが,ラッパー関数だと思ってください.)
68 const attachedProps: [string, string][] = []
71 // script
72 const { code: scriptCode, map: scriptMap } = await genScriptCode(
73 descriptor,
74 options,
75 pluginContext,
76 ssr,
77 customElement,
78 )
79
80 // template
81 const hasTemplateImport =
82 descriptor.template && !isUseInlineTemplate(descriptor, options)
83
84 let templateCode = ''
85 let templateMap: RawSourceMap | undefined = undefined
86 if (hasTemplateImport) {
87 ;({ code: templateCode, map: templateMap } = await genTemplateCode(
88 descriptor,
89 options,
90 pluginContext,
91 ssr,
92 customElement,
93 ))
94 }
122 const output: string[] = [
123 scriptCode,
124 templateCode,
125 stylesCode,
126 customBlocksCode,
127 ]
231 output.push(
232 `import _export_sfc from '${EXPORT_HELPER_ID}'`,
233 `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
234 .map(([key, val]) => `['${key}',${val}]`)
235 .join(',')}])`,
236 )
(output を join したものが最終的な結果になります.)
240 let resolvedCode = output.join('\n')
(※ attachedProps の収集)
96 if (hasTemplateImport) {
97 attachedProps.push(
98 ssr ? ['ssrRender', '_sfc_ssrRender'] : ['render', '_sfc_render'],
99 )
100 } else {
131 if (devToolsEnabled || (devServer && !isProduction)) {
132 // expose filename during serve for devtools to pickup
133 attachedProps.push([
134 `__file`,
135 JSON.stringify(isProduction ? path.basename(filename) : filename),
136 ])
137 }
このような形で,ざっくりとですが,
const _sfc_main = {};
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
const n0 = t0();
return n0;
}
export default Object.assign(_sfc_main, {
render: _sfc_render,
vapor: true,
__file: "/path/to/App.vue",
});
のような最終的なコードを生成しています.
Vapor Mode のコンパイラの切り替え
Vapor Mode のコンパイラとそうでないコンパイラはどのように切り替えられているのでしょうか,というのを最後に見てこのページは終わります.
実は,vite-plugin-vue
には vapor
とう Vapor Mode 専用のブランチがあります.
というのも,Vapor Mode は現在 R&D の段階です.vuejs/core-vapor
は既存のコードベースに影響しないように切り出されて開発が進んでいます.vite-plugin-vue
においてもこれは同じです.
コンパイラの切り替えのようなものは vite-plugin-vue
に一部侵食してしまうのはまぁ仕方がないことです.
こちらの方はブランチを切り替えて npm の配布パッケージ名を変えるという方法で回避されています.
ブランチはこれです.
https://github.com/vitejs/vite-plugin-vue/tree/vapor
配布されているパッケージはこれで,@vue-vapor/vite-plugin-vue
として配布されています.
https://www.npmjs.com/package/@vue-vapor/vite-plugin-vue
そしてこのブランチには,vapor
であるかどうかを切り替えるフラグが用意されています.
正確には,このオプションは vuejs/core-vapor の実装へ fallthrough される前提なので,型から Omit するように記述されています.
42 script?: Partial<
43 Omit<
44 SFCScriptCompileOptions,
45 | 'id'
46 | 'isProd'
47 | 'inlineTemplate'
48 | 'templateOptions'
49 | 'sourceMap'
50 | 'genDefaultAs'
51 | 'customElement'
52 | 'defineModel'
53 | 'propsDestructure'
54 | 'vapor'
55 >
67 template?: Partial<
68 Omit<
69 SFCTemplateCompileOptions,
70 | 'id'
71 | 'source'
72 | 'ast'
73 | 'filename'
74 | 'scoped'
75 | 'slotted'
76 | 'isProd'
77 | 'inMap'
78 | 'ssr'
79 | 'ssrCssVars'
80 | 'preprocessLang'
81 | 'vapor'
82 >
83 >
つまり,定義自体は SFCScriptCompileOptions
, SFCTemplateCompileOptions
の方に存在しています.
128 /**
129 * Force to use of Vapor mode.
130 */
131 vapor?: boolean
60 vapor?: boolean
あとは,plugin を設定する際に引数としてこのフラグを渡せばコンパイラを切り替えることができます.
参考までに,vuejs/core-vapor
の playground では以下のように設定しています.
18 plugins: [
19 Vue({
20 vapor: true,
21 compiler: CompilerSFC,
22 }),
あとはここから fallthrough されたフラグをもとにコンパイラを切り替わる実装がされていれば良いはずです.
この実装は以下で行われています.
212 const defaultCompiler = vapor
213 ? // TODO ssr
214 (CompilerVapor as TemplateCompiler)
215 : ssr
216 ? (CompilerSSR as TemplateCompiler)
217 : CompilerDOM
218 compiler = compiler || defaultCompiler
コンパイラ切り替えの API について
将来的にはコンパイラの切り替えはコンポーネントごとに出来るようになります.
API はまだ定まっていませんが, <script vapor>
のようなものが案として挙げられます.
ちなみに,API の策定は以下の issue で議論しています.