3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Vite と VitePress のつなぎ込み - dev 編

Last updated at Posted at 2020-07-06

はじめに

  • バージョン ... 0.31
  • SHA-1 ハッシュ ... 7519f1f6e356a5521404116aba57009d123d6572
  1. VitePress のディレクトリ構成
  2. src/client/app と src/client/theme-default のつなぎ込み
  3. Markdown ファイルと src/client/app, Vue インスタンス本体のつなぎ込み
  4. Vite と VitePress のつなぎ込み
  5. dev 編 <-- いまココです。
  6. build 編(未着手)

概要

◯ Vite と VitePress のつなぎこみ

Vite 向けの VitePress のプラグインを噛ませつなぎこみを行なっています。Vite はプラグインが多段に組まれていています。後述します。また、以下のスライドの中に図示されている箇所があるので、雰囲気が、つかめるかもしれません。

具体的に VitePress のプラグインを噛ませているのは Step 2 になります。ここでは主に VitePress のプラグインを見ていきます。

◯ 通信

例えば、いま http://127.0.0.1:3000/hello.html にアクセスしたとします。Chrome のデベロッパツールを開いて確認するとわかりやすいかもしれません。動画作りました。

IMAGE ALT TEXT HERE

inde.html
<div id="app"></div>
<script type="module" src="/@app/index.js"></script>

/@app/index.js が呼び出されます。これは /src/client/app/index.js です。

パス変換のロジックが記述されている箇所は、以下をご確認ください。resolver のあたりは、あまりよくわかっていません。

/src/client/app/index.js は Vue.js のインスタンスを作成して、そのインスタンスをマウントしていることが確認できます。

/src/client/app/index.js#L112-L118
if (inBrowser) {
  // 1) Vue.js のインスタンスを作成
  const { app, router } = createApp()

  // 2) そのインスタンスをマウント
  // wait unitl page component is fetched before mounting
  router.go().then(() => {
    app.mount('#app')
  })
}

さらに router.go の中を覗くと location.href に遷移していることがわかります。

/src/client/app/router.ts#L28-L36
  function go(href?: string) {
    // location.href で対象のコンポーネントを読み込みます。
    href = href || (inBrowser ? location.href : '/')

    if (inBrowser) {
      // save scroll position before changing url
      history.replaceState({ scrollPosition: window.scrollY }, document.title)
      history.pushState(null, '', href)
    }
    return loadPage(href)
  }

ルータの実装については、以下の記事で詳細を後述します。

Step 1. VitePress - エントリーポイント

yarn dev, yarn build を実行すると、ここから起動します。

/bin/vitepress.js
//
// こっちが実行されます。dev
//
if (!command || command === 'dev') {
  const port = argv.port || 3000
  const root = command === 'dev' && argv._[1]
  if (root) {
    argv.root = root
  }
  require('../dist/node')  // <--- ../src/node を参照します。
    .createServer(argv)
    .then((server) => {
      server.listen(port, () => {
        console.log(`listening at http://localhost:${port}`)
      })
    })
    .catch((err) => {
      console.error(chalk.red(`failed to start server. error:\n`), err)
    })
//
// こっちじゃない。build
//
} else if (command === 'build') {
  require('../dist/node')
    .build(argv)
    .catch((err) => {
      console.error(chalk.red(`build error:\n`), err)
    })
} else {
  console.log(chalk.red(`unknown command "${command}".`))
}

Step 2. VitePress - createServer

Step 1 の createServer は、以下のように定義されています。Step 1 の実引数 argv は、Step 2 の仮引数 options に代入されます。VitePress は Vite のプラグインとして、実装されていることがわかります。

/src/node/server.ts#L137-L145
export async function createServer(options: ServerConfig = {}) {
  const config = await resolveConfig(options.root)
  
  return createViteServer({                   // `vite` から import した `createServer` です。
    ...options,                               // Step 1 のコマンドライン引数 `argv` をアンパックしています。
    plugins: [createVitePressPlugin(config)], // ポイント ... これが VitePress の本体です。
    resolvers: [config.resolver]              // resolvers は、後述します。
  })
}

createViteServervite から import した createServer です。

/src/server.ts#L2-L7
import {
  createServer as createViteServer, // <--- ココ
  cachedRead,
  ServerConfig,
  ServerPlugin
} from 'vite'

また、ここでのポイントは VitePress の本体に当たる部分が Vite Server の Plugin として渡されていることです。これは追って見ていきます。

    plugins: [createVitePressPlugin(config)], // ポイント ... これが VitePress の本体です。

◯ resolveConfig

resolveConfig って、何してるのかな?と思ったのですが、サイトのメタデータを作成していました。resolve というよりは create なイメージでした。

export async function resolveConfig(
  root: string = process.cwd()
): Promise<SiteConfig> {
  const site = await resolveSiteData(root)


  // resolve theme path
  const userThemeDir = resolve(root, 'theme')
  const themeDir = (await exists(userThemeDir))
    ? userThemeDir
    : path.join(__dirname, '../lib/theme-default')


  const config: SiteConfig = {
    root,
    site,
    themeDir,
    pages: await globby(['**.md'], { cwd: root, ignore: ['node_modules'] }),
    configPath: resolve(root, 'config.js'),
    publicDir: resolve(root, 'public'),
    outDir: resolve(root, 'dist'),
    tempDir: path.resolve(APP_PATH, 'temp'),
    resolver: createResolver(themeDir)
  }


  return config
}

また、どこで md ファイルを収集しているか?疑問だったのですが、ここで configpage プロパティに注目すると globby を使って Markdown ファイルのパスを集めていることが確認できます。

Step 3. VitePress - createVitePressPlugin

2 つの watcher.on と 1 つの app.use をしています。watcher.on はホットリロードの対象を登録しています。app.use は Koa の middleware を登録しています。app が Vue の app ではなく Koa の app であることに留意してください。

/src/node/server.ts#L15-L124
function createVitePressPlugin({
  configPath,
  site: initialSiteData
}: SiteConfig): ServerPlugin {
  return ({ app, root, watcher, resolver }) => {
    const markdownToVue = createMarkdownToVueRenderFn(root)

    //
    // 記事となる Markdown ファイルの変更を監視登録しています。
    //
    // hot reload .md files as .vue files
    watcher.on('change', async (file) => {
      //
      // 後述 1
      //
    })

    //
    // 設定ファイルの変更を監視登録しています。
    //
    // hot reload handling for siteData
    // the data is stringified twice so it is sent to the client as a string
    // it is then parsed on the client via JSON.parse() which is faster than
    // parsing the object literal as JavaScript.
    let siteData = initialSiteData
    let stringifiedData = JSON.stringify(JSON.stringify(initialSiteData))
    watcher.add(configPath)
    watcher.on('change', async (file) => {
      //
      // 後述 2
      //
    })


    // inject Koa middleware
    app.use(async (ctx, next) => {
      //
      // 後述 3
      //
    })
  }
}

◯ 後述 1

Markdown ファイルの更新を監視するコードが書かれています。こんなに繊細に書かれているとは思いませんでした。もっとざっくりとディレクトリ配下は全部監視!みたいなのかと思っていました...orz

    // hot reload .md files as .vue files
    watcher.on('change', async (file) => {
      if (file.endsWith('.md')) {
        debugHmr(`reloading ${file}`)
        //
        // cachedRead
        //    キャッシュを読み込みます。
        //    キャッシュってなんだろうと思ってコードを追ってみたのですが、
        //    このキャッシュは、メモリからのキャッシュです。
        //    ファイルが読み込まれていなければ fs.open() します。
        //
        const content = await cachedRead(null, file)

        // Vite では timestamp をキャッシュを更新するか、
        // しないかの判断に使うことがあるそうです。
        // このコードブロックの末尾を確認すると雰囲気がわかります。
        //   watcher.handleVueReload(file, timestamp, vueSrc)
        const timestamp = Date.now()

        //
        // Markdown ファイルが Vue の SFC の template に変換されます。
        //
        const { pageData, vueSrc } = markdownToVue(
          content,
          file,
          timestamp,
          // do not inject pageData on HMR
          // it leads to vite to think <script> has changed and reloads the
          // component instead of re-rendering.
          // pageData needs separate HMR logic anyway (see below)
          false
        )


        // notify the client to update page data
        watcher.send({
          type: 'custom',
          id: 'vitepress:pageData',
          customData: {
            path: resolver.fileToRequest(file),
            pageData
          },
          timestamp: Date.now()
        })


        // reload the content component
        watcher.handleVueReload(file, timestamp, vueSrc)
      }
    })

◯ 後述 2

省略

◯ 後述 3 - inject Koa middleware とは

上記コードのコメントに "inject Koa middleware" という文言が見えます。Koa の middleware は、簡単に言えばフックです。HTTP リクエストをフックして処理を追加します。

app.use(async (ctx, next) => { ... } に着目していきます。ctx には HTTP リクエストの内容が、next には次に控えている middleware を呼び出す callback 関数が代入されています。await next() すると次の middleware に操作が移り、return されると戻って来ます。

Vite の middleware 間の関係は、わかっていません。await next() にかかる挙動は、以下 Developers.IO の記事がわかりやすいです。

"inject Koa middleware" 以下の内容を抜粋しました。3種類のファイルをフックして、処理を加えていることがわかります。

/src/node/server.ts#L76-L122
    // inject Koa middleware
    app.use(async (ctx, next) => {
      //
      // 1) @siteDate の場合
      //    サイトのタイトルなどのメタデータが保存されています。
      //
      // serve siteData (which is a virtual file)
      if (ctx.path === SITE_DATA_REQUEST_PATH) {
        ctx.type = 'js'
        ctx.body = `export default ${stringifiedData}`
        debug(ctx.url)
        return
      }

      //
      // 2) Markdown ファイルの場合
      //    例えば、動画の最後でダウンロードされた hello.md ファイルが該当します。
      //
      // handle .md -> vue transforms
      if (ctx.path.endsWith('.md')) {
        //
        // /index.md -> docs/index.md に変換します。
        // resolver については、後述します。
        const file = resolver.requestToFile(ctx.path)

        // cachedRead
        //   ctx.body にファイルの中身が設定されます。
        //
        await cachedRead(ctx, file)

        // let vite know this is supposed to be treated as vue file
        ctx.vue = true


        const { vueSrc, pageData } = markdownToVue(
          ctx.body,
          file,
          ctx.lastModified.getTime(),
          false
        )
        ctx.body = vueSrc
        debug(ctx.url, ctx.status)


        await next()


        // make sure this is the main <script> block
        if (!ctx.query.type) {
          // inject pageData to generated script
          ctx.body += `\nexport const __pageData = ${JSON.stringify(
            JSON.stringify(pageData)
          )}`
        }
        return
      }


      await next()

      //
      // 3) 動画の最初にダウンロードされた hello.html
      //    全て index.html が返されます。
      //    router.go で hello.md を表示しています。
      //    SPA のような動作をしています。
      //

      // serve our index.html after vite history fallback
      if (ctx.url.endsWith('.html')) {
        await cachedRead(ctx, path.join(APP_PATH, 'index.html'))
        ctx.status = 200
      }
    })

TODO:
cachedRead, cachedRead といった文言が見え、Markdown ファイルを読んでいるのは、わかるが、どこで app と繋がっているかわからないので、調べる。





ここから Vite のコードを追いかけます。

Step 4. Vite - createServer

Vite のコードを読むと大量の Vite のプラグインが登録されていることを確認できます。登録の順番は、引数で追加したもの -> Vite が最初からつけているもの、となっているようです。

  1. koa のインスタンスを作りサーバを走らせる。
  2. chokidar で hot reload を有効にする。
  3. 何をしているのか、わからない。
  4. 引数で渡された plugins を実行する。
/src/node/server/index.ts
import { vuePlugin } from './serverPluginVue' // <--- 後述します。

// ... 中略

const internalPlugins: Plugin[] = [
  hmrPlugin,
  moduleRewritePlugin,
  moduleResolvePlugin,
  vuePlugin,  // <--- 後述します。
  esbuildPlugin,
  jsonPlugin,
  cssPlugin,
  assetPathPlugin,
  serveStaticPlugin
]

export function createServer(config: ServerConfig = {}): Server {
  const {
    root = process.cwd(),
    plugins = [],
    resolvers = [],
    jsx = {}
  } = config

  //
  // 1) app を生成して create サーバに渡す。
  //   ここでの app は Vue のインスタンスではなく、
  //   Koa のインスタンスを参照していることに留意したい。
  //
  const app = new Koa()
  const server = http.createServer(app.callback())

  //
  // 2) `/node_modules/` 以外に hot reload がかかるように登録している。
  //
  const watcher = chokidar.watch(root, {
    ignored: [/node_modules/]
  }) as HMRWatcher

  //
  // 3) 何をしているのか、わからない。
  //
  const resolver = createResolver(root, resolvers)

  //
  // 4) createServer を実行したタイミングで
  // plugins を実行していることがわかる。
  // 
  const context = {
    root,
    app,
    server,
    watcher,
    resolver,
    jsxConfig: {
      jsxFactory: jsx.factory,
      jsxFragment: jsx.fragment
    }
  }
  ;[...plugins, ...internalPlugins].forEach((m) => m(context))


  return server
}

以下のディレクトリの配下に internalPlugins が保存されています。一覧すると雰囲気がつかめるかもしれません。

Step 5. Vite - serverPluginVue

serverPluginVue を覗くと compileSFCTemplate, compileSFCStyle という文言が見えます。ここで SFC, Single File Component を変換, compile しているようです。

/src/node/server/serverPluginVue.ts#L94-L109
    if (query.type === 'template') {
      const templateBlock = descriptor.template!
      if (templateBlock.src) {
        filename = await resolveSrcImport(templateBlock, ctx, resolver)
      }

      // ... 中略

      return etagCacheCheck(ctx)
    }
   
    if (query.type === 'style') {
      const index = Number(query.index)
      const styleBlock = descriptor.styles[index]
      if (styleBlock.src) {
        filename = await resolveSrcImport(styleBlock, ctx, resolver)
      }
      const id = hash_sum(publicPath)
      const result = await compileSFCStyle(
        root,
        styleBlock,
        index,
        filename,
        publicPath
      )
      ctx.type = 'js'
      ctx.body = codegenCss(`${id}-${index}`, result.code, result.modules)
      return etagCacheCheck(ctx)
    }





ここから resolver を追いかけます。

Step 6. VitePress - resolver

VitePress の中で resolve, resolver という単語は、色々な意味で使われますが、Vite 本体に渡す resolver について見て行きます。Vite 本体に渡す resolver は HTTP リクエストのパスを、ファイルシステムのどのパスに対応づけるかを記述します。

例えば、以下のように docs ディレクトリを指定した場合...

$ npx vitepress dev docs

HTTP リクエストは以下のように変換されて欲しいです。

/             ->  /docs/index.md
/hello        ->  /docs/hello.md
/hello/nihao  ->  /docs/hello/nihao.md

該当箇所を引用しつつ resolvers に何が代入されているかを追いかけに行きます。

/src/node/server.ts#L137-L145
export async function createServer(options: ServerConfig = {}) {
  const config = await resolveConfig(options.root) // <--- ここに注目してください。
  
  return createViteServer({
    ...options,
    plugins: [createVitePressPlugin(config)],
    resolvers: [config.resolver] // <--- ここに注目してください。
  })
}

config を作成している resolveConfig を見て行きます。

export async function resolveConfig(
  root: string = process.cwd()
): Promise<SiteConfig> {
  const site = await resolveSiteData(root)


  // resolve theme path
  const userThemeDir = resolve(root, 'theme')
  const themeDir = (await exists(userThemeDir))
    ? userThemeDir
    : path.join(__dirname, '../lib/theme-default')


  const config: SiteConfig = {
    root,
    site,
    themeDir,
    pages: await globby(['**.md'], { cwd: root, ignore: ['node_modules'] }),
    configPath: resolve(root, 'config.js'),
    publicDir: resolve(root, 'public'),
    outDir: resolve(root, 'dist'),
    tempDir: path.resolve(APP_PATH, 'temp'),
    resolver: createResolver(themeDir) // <--- ここに注目してください。
  }


  return config
}

resolver.js 全体を貼り付けます。

/src/node/resolver.ts
import path from 'path'
import { Resolver } from 'vite'


export const APP_PATH = path.join(__dirname, '../client/app')


// special virtual file
// we can't directly import '/@siteData' becase
// - it's not an actual file so we can't use tsconfig paths to redirect it
// - TS doesn't allow shimming a module that starts with '/'
export const SITE_DATA_ID = '@siteData'
export const SITE_DATA_REQUEST_PATH = '/' + SITE_DATA_ID


// これは vite に渡されるパスリゾルバで、/@app や /@theme で始まる
// カスタムリクエストを解決することができます。
// また、vite HMR が正しい更新通知をクライアントに送信できるように、
// ファイルのパスをパブリックにサーブされたパスにマップする必要があります。
// this is a path resolver that is passed to vite
// so that we can resolve custom requests that start with /@app or /@theme
// we also need to map file paths back to their public served paths so that
// vite HMR can send the correct update notifications to the client.
export function createResolver(themeDir: string): Resolver {
  return {
    alias: {
      // このようにして /@app/ /@theme/ などがある理由は、
      // Vue のインスタンスや theme の SFC が個別にダウンロードされるからです。
      // (うまい説明が思いつきません... orz)
      // デベロッパツールのネットワークを開き、
      // @app や @theme へのリクエストの中身を見ると雰囲気がわかるかもしれません。
      '/@app/': APP_PATH,
      '/@theme/': themeDir,
      vitepress: '/@app/exports.js',
      [SITE_DATA_ID]: SITE_DATA_REQUEST_PATH
    },
    //
    // HTTP リクエストから -> サーバのファイルパスへの変換
    //
    requestToFile(publicPath) {
      if (publicPath === SITE_DATA_REQUEST_PATH) {
        return SITE_DATA_REQUEST_PATH
      }
    },
    //
    // サーバのファイルパスから -> HTTP リクエストへの変換
    //
    fileToRequest(filePath) {
      if (filePath === SITE_DATA_REQUEST_PATH) {
        return SITE_DATA_REQUEST_PATH
      }
    }
    // ここでなぜ requestToFile, fileToRequest が
    // このような書き方をされているのか、まだつかめていません。
  }
}

Step 7. Vite - resolver

次は Vite の resolver を見て行きます。Step 6 の createViteServer を思い出してください。

/src/node/server/index.ts#L53-L81
export function createServer(config: ServerConfig = {}): Server {
  const {
    root = process.cwd(),
    plugins = [],
    resolvers = [],  // <--- ここに注目してください。
    jsx = {}
  } = config
  const app = new Koa()
  const server = http.createServer(app.callback())
  const watcher = chokidar.watch(root, {
    ignored: [/node_modules/]
  }) as HMRWatcher
  const resolver = createResolver(root, resolvers)  // <--- ここに注目してください。
  const context = {
    root,
    app,
    server,
    watcher,
    resolver, // <--- ここに注目してください。
    jsxConfig: {
      jsxFactory: jsx.factory,
      jsxFragment: jsx.fragment
    }
  }


  ;[...plugins, ...internalPlugins].forEach((m) => m(context))


  return server
}

const resolver = createResolver(root, resolvers) を見ていきます。引数に注目します。

root にはドキュメント、マークダウンファイルの保存されたディレクトリの root が代入されます。例えば docs です。

resolvers には、VitePress から呼び出す時は、Step6 で見てきた resolvers が代入されています。コードを見ると、カスタムの resolver が該当しなければデフォルトの resolver で解決するということをしているようです。

/src/node/resolver.ts#L79-L114
export function createResolver(
  root: string,
  resolvers: Resolver[]
): InternalResolver {
  return {
    requestToFile: (publicPath) => {
      let resolved: string | undefined
      for (const r of resolvers) {
        const filepath = r.requestToFile(publicPath, root)
        if (filepath) {
          resolved = filepath
          break
        }
      }
      if (!resolved) {
        resolved = defaultRequestToFile(publicPath, root)
      }
      resolved = resolveExt(resolved)
      return resolved
    },
    fileToRequest: (filePath) => {
      for (const r of resolvers) {
        const request = r.fileToRequest(filePath, root)
        if (request) return request
      }
      return defaultFileToRequest(filePath, root)
    },
    idToRequest: (id: string) => {
      for (const r of resolvers) {
        const request = r.idToRequest && r.idToRequest(id)
        if (request) return request
      }
      return defaultIdToRequest(id)
    }
  }
}

おわりに

以上になります。ありがとうございました。build 編は、まだ未着手です。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?