Skip to content

SFC のコンパイルの流れ

さて,最終的な出力コードを出力すための実装を見てみましょう.

改めて,

vue
<template>
  <p>Hello, Vapor!</p>
</template>

から,

js
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-sfcvite-plugin-vue の実装です. 非 Vapor の場合は,概ね,

js
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 を使う時このように書くことがあるかと思います.

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 からモジュールが読み込まれた時」です.
もっというと,importimport() が実行された時です.

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 という関数を実行しています.

transformMainvite-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-sfccompileScriptcompileTemplate が呼ばれています.
これでどのように Vue.js のコンパイラが設定され,どのタイミングで実行されるかがわかったはずです.

transformMain を呼んで出力コードの全体を掴む

このようなコンパイル結果を思い出してください.

js
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",
});

のようなコードをどう出力すれば良いかです.概ね,

js
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
  const n0 = t0();
  return n0;
}

の部分は compileTemplate という関数によって生成されます.

js
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> があった場合は,

js
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 によって生成されます.

つまり,以下のような感じです.

js
// <---------------- 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  }

このような形で,ざっくりとですが,

js
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

あとは,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 で議論しています.

https://github.com/vuejs/core-vapor/issues/198