feature image

2022年4月18日 | ブログ記事

350行でつくるVite⚡

この記事はtraP新歓ブログリレー2022 41日目(4/18)の記事です。

こんにちは、19Bの翠(sappi_red)です。
普段はSysAd班で活動しています。

この記事ではVite-likeなウェブフロントエンドツールをつくっていきながら、Viteの大まかな仕組みを説明していきます。

できる限り細かく手順を書いたので、ぜひ実際に追ってみてください!

実際に完成したものはこのリポジトリ(sapphi-red/micro-vite)にあります。名称は愚直にMicroViteにしました。

ある程度Viteに近い実装にはなっていますが、そこそこいろんな箇所が異なります

また、動作確認はNode.js v16.14.2で行っています。(がおそらくv12以降なら動くと思います)

準備

とりあえず動作確認用のごく単純なウェブアプリケーションを作ります。
動作確認用なのでplaygroundというディレクトリを作って、その中にindex.htmlsrc/main.tssrc/content.tsを作成します。tsconfig.jsonも用意しておきます。 (c194bbf)

HTMLから直接.tsファイルを読み込んでいること以外は特に変哲もないものになっています。
また、playgroundnpm initを実行します。すべてEnter連打で大丈夫です。もし、間違えてしまってもあとからpackage.jsonを編集すれば問題ありません。(e215ad2)

さて、MicroViteをつくるディレクトリを用意します。今度はplaygroundと同じ階層にmicro-viteというディレクトリを作ります。その中でnpm initを実行します。今回はentry point: (index.js)のときだけdist/index.jsを指定します。ほかはEnter連打で大丈夫です。(c89d11a)

ここでMicroViteのソースコードをビルドできるように設定します。TypeScriptをビルド+バンドルできれば、何でも問題ないですがここではtsupを利用します。
micro-vitenpm i -D tsupを行ったのち、package.json'scripts'を以下のように変更します。

    "build": "tsup src/index.ts",
    "watch": "tsup src/index.ts --watch"

また、tsconfig.jsonも用意しておきます。これによってmicro-vitenpm run buildを行うことでmicro-vite/src/index.tsに書いた内容がバンドルされて、micro-vite/dist/index.jsに出力されるようになりました。(88f7e76)
npm run watchを実行すると変更があった際に自動でバンドルし直されるので、実行しておくと便利です。

ここ以降ではnpm run buildを省略しますが、micro-vite/src以下を編集した場合、npm run watchを実行していなければ、実行することが必要です

最後にplaygroundディレクトリでnpm link -D ../micro-viteを実行します。これでplaygroundmicro-viteディレクトリがパッケージとして利用できるようになります。(f024596)

コマンドラインからのWebサーバー起動

まずは、コマンドラインから静的配信が行えるWebサーバーを起動できるようにするところまで実装していきます。

CLIの実装 (7e74bc5)

ここではViteでも利用されているcacというライブラリを利用します。micro-viteディレクトリでnpm i cacを実行します。

micro-vite/src/index.tsに実装をしていきます。ここでは、devbuild--helpが実行できるようにしました。

import cac from 'cac'

const cli = cac()

cli.command('dev')
  .action(() => {
    console.log('dev server start')
  })

cli.command('build')
  .action(() => {
    console.log('build start')
  })

cli.help()

cli.parse()

ただ、このままではmicro-viteのように呼び出しても実行されません。そのため、次のような設定が必要です。

これで、micro-viteのように呼び出したときにmicro-vite/cli.jsが呼び出されてmicro-vite/src/index.tsの内容が実行されるようになりました。

今度はplayground/package.jsonscriptsを以下のように変更します。

  "scripts": {
    "dev": "micro-vite dev",
    "build": "micro-vite build"
  },

その後、playgroundディレクトリでnpm link -D ../micro-viteを再度実行します。
これでplaygroundディレクトリでnpm run devを実行した際に、以下のような出力が得られるようになったはずです!

$ npm run dev

> playground@1.0.0 dev
> micro-vite dev

dev server start

静的配信サーバーの実装 (0b8772c)

ここでは、Viteでも利用されているconnectsirvを利用します。利用するライブラリをインストールするためにmicro-viteディレクトリでnpm i connect connect-history-api-fallback sirvnpm i -D @types/connect @types/connect-history-api-fallbackを実行します。

micro-vite/src/dev.tsに静的配信サーバーを実装します。

import connect from 'connect'
import historyApiFallback from 'connect-history-api-fallback'
import sirv from 'sirv'

export const startDev = () => {
  const server = connect()
  server.listen(3000, 'localhost')

  server.use(
    sirv(undefined, {
      dev: true,
      etag: true
    })
  )
  server.use(historyApiFallback() as any) // ファイルが存在しなかったときにindex.htmlを返すようにするミドルウェア

  console.log('dev server running at http://localhost:3000')
}

micro-vite/src/index.tsから先ほどのstartDev関数を呼ぶようにします。

import cac from 'cac'
import { startDev } from './dev' // 追加

const cli = cac()

cli.command('dev')
  .action(() => {
    startDev() // 書き換え
  })

/* 以下略 */

これでplaygroundディレクトリでnpm run devを実行すると、localhost:3000のレスポンスがかえってきます!
しかし、アクセスはできるものの画面は真っ白です。

tsファイルの読み込みエラーの対処 (76a33d7)

先ほどの画面でブラウザの開発者ツールのコンソールを確認すると下のようなエラーが出ていることに気づきます。

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "video/mp2t". Strict MIME type checking is enforced for module scripts per HTML spec.

<script type="module">を利用する際はMIMEがapplication/javascriptなどでない場合に読み込まれないようになっています。また、.tsの拡張子はTypeScriptだけでなく、MPEG2-TSでも利用されているため、拡張子からMIME Typeを付与する際にvideo/mp2tが選ばれます。(仮にTypeScriptに専用のMIME Typeが付与されていたとしてもHTMLの仕様で許可されなければ同様に動きません)
そのため、サーバーで.tsのMIME Typeがapplication/javascriptになるようにする必要があります。

sirvでTypeScriptを返す際にMIME Typeを指定するようにします。

    sirv(undefined, {
      dev: true,
      etag: true,
      setHeaders(res, pathname) { // ここのメソッドを追加します
        if (/\.[tj]s$/.test(pathname)) {
          res.setHeader('Content-Type', 'application/javascript')
        }
      }
    })

これで先ほどのエラーは解決できました。

オンデマンド・トランスパイル

まだブラウザの画面は真っ白のままですね。再度コンソールを確認するとこのようなエラーが出ています。

Uncaught SyntaxError: Unexpected token '!' (at main.ts:3:59)

このエラーは、単純にTypeScriptをトランスパイルしていないため発生しています。
今のところは静的配信しか行っていないので当然ですね。

さて、ここからViteの肝であるオンデマンド・トランスパイルを実装していきます。

transformMiddleware (0a89822)

まずは特定の拡張子のファイルの応答を書き換えるミドルウェアの実装をしてみましょう。

micro-vite/src/transformMiddleware.tsにそれを実装します。

import { NextHandleFunction } from 'connect'

export const transformMiddleware = (): NextHandleFunction => {
  const transformRequest = async (pathname: string): Promise<{ mime: string, content: string } | null> => {
    if (pathname.endsWith('.ts')) { // 拡張子が.tsだったとき
      return {
        mime: 'application/javascript',
        content: `console.log('file: ${pathname}')`
        // 例えば `console.log('file: /src/main.ts')` になる
      }
    }
    return null
  }

  return async (req, res, next) => {
    if (req.method !== 'GET') {
      return next()
    }

    // req.urlのパース
    let url: URL
    try {
      url = new URL(req.url!, 'http://example.com')
    } catch (e) {
      return next(e)
    }

    const pathname = url.pathname

    try {
      const result = await transformRequest(pathname)
      if (result) {
        res.statusCode = 200
        res.setHeader('Content-Type', result.mime)
        return res.end(result.content)
      }
    } catch (e) {
      return next(e)
    }

    // transformRequestで処理されなかったものは次のミドルウェアに託す
    // 例えば静的配信をするsirv
    next()
  }
}

micro-vite/src/dev.tsから先ほどのtransformMiddleware関数を呼ぶようにします。

import connect from 'connect'
import historyApiFallback from 'connect-history-api-fallback'
import sirv from 'sirv'
import { transformMiddleware } from './transformMiddleware' // 追加

export const startDev = () => {
  const server = connect()
  server.listen(3000, 'localhost')

  server.use(transformMiddleware()) // 追加
  server.use(
    sirv(undefined, {
      dev: true,
/* 以下略 */

これでplaygroundディレクトリでnpm run devを実行しlocalhost:3000/src/main.tsにアクセスすると、内容がconsole.log('file: /src/main.ts')に置き換えられていることがわかります。

pluginContainer (64b359f)

単純な置き換えもできたので、あとはTypeScriptのトランスパイル処理を行うように書き換えると、動くようになります。
しかし、ここではViteに見習ってrollup.jsのプラグインが直接利用できるように実装をしていきます。

rollup.jsのプラグイン

rollupではプラグインがバンドル時に介入できるタイミングを「Build Hooks」と呼びます。
このBuild Hooksは、主にresolveIdloadtransformが存在し、これらは順番に呼ばれます。

より詳細はrollup.jsのドキュメントのBuild Hooksをご覧ください。

ここではTypeScriptからの変換はrollup-plugin-esbuildを利用します。micro-viteディレクトリでnpm i rollup-plugin-esbuildnpm i -D rollupを実行します。
Viteではesbuildをつかったプラグインが独自で実装されています。

主な登場人物は以下の三つです。

最初にpluginを実装していきます。

micro-vite/src/plugins.tsに使うプラグイン一覧を取得できる関数を用意しておきます。

import { resolve } from './resolvePlugin'
import esbuild from 'rollup-plugin-esbuild'

export const getPlugins = () => [
  resolve(),
  esbuild({
    target: 'esnext',
    minify: false
  })
]

ここで新しく出てきたresolvePlugin(micro-vite/src/resolvePlugin.ts)ですがこれはこのように基本的なファイル解決を行うように実装します。

import type { Plugin } from 'rollup'
import * as path from 'node:path'
import * as fs from 'node:fs/promises'

const root = process.cwd()

export const resolve = (): Plugin => {
  return {
    name: 'micro-vite:resolve',
    async resolveId(id: string) {
      // idのファイルが存在すれば絶対パスのidを返します
      const absolutePath = path.resolve(root, `.${id}`)
      try {
        const stat = await fs.stat(absolutePath)
        if (stat.isFile()) {
          return absolutePath
        }
      } catch {}
      return null
    },
    async load(id: string) {
      // パスのファイルを読み出します
      try {
        const res = await fs.readFile(id, 'utf-8')
        return res
      } catch {}
      return null
    }
  }
}

そうしたら、今度はこれらのpluginを実行するPluginContainer(micro-vite/src/pluginContainer.ts)の実装をします。

import type { Plugin, LoadResult, PartialResolvedId, SourceDescription } from 'rollup'

// 本来は他にもhookが存在しますがここではこの三つのみ対応します
export type PluginContainer = {
  resolveId(id: string): Promise<PartialResolvedId | null>
  load(id: string): Promise<LoadResult | null>
  transform(code: string, id: string): Promise<SourceDescription | null>
}

export const createPluginContainer = (plugins: Plugin[]): PluginContainer => {
  return {
    async resolveId(id) {
      for (const plugin of plugins) {
        if (plugin.resolveId) {
          // @ts-expect-error do not support rollup context
          const newId = await plugin.resolveId(id, undefined, undefined)
          if (newId) {
            id = typeof newId === 'string' ? newId : newId.id
            return { id }
          }
        }
      }
      return null
    },
    async load(id) {
      for (const plugin of plugins) {
        if (plugin.load) {
          // @ts-expect-error do not support rollup context
          const result = await plugin.load(id)
          if (result) {
            return result
          }
        }
      }
      return null
    },
    async transform(code, id) {
      for (const plugin of plugins) {
        if (plugin.transform) {
          // @ts-expect-error do not support rollup context
          const result = await plugin.transform(code, id)
          if (!result) continue
          if (typeof result === 'string') {
            code = result
          } else if (result.code) {
            code = result.code
          }
        }
      }
      return { code }
    }
  }
}

micro-vite/src/transformMiddleware.tstransformRequestPluginContainerを利用するように以下のように書き換えます。rollup.jsでpluginが呼ばれるのを模したような実装になっています。

const transformRequest = async (pathname: string): Promise<{ mime?: string, content: string } | null> => {
  const idResult = await pluginContainer.resolveId(pathname) || { id: pathname }

  const loadResult = await pluginContainer.load(idResult.id)
  if (!loadResult) {
    return null
  }

  const code = typeof loadResult === 'string' ? loadResult : loadResult.code
  const transformResult = await pluginContainer.transform(code, idResult.id)
  if (!transformResult) {
    return null
  }

  return {
      mime: /\.[jt]s$/.test(idResult.id) ? 'application/javascript' : undefined,
    content: transformResult.code
  }
}

transformMiddlewareの返り値の関数も少し書き換える必要があります。

-        res.setHeader('Content-Type', result.mime)
+        if (result.mime) {
+          res.setHeader('Content-Type', result.mime)
+        }

あとはmicro-vite/src/dev.tsでこれらを組み合わせるようにすれば完了です。

/* 前略 */
import { createPluginContainer } from './pluginContainer' // 追加
import { getPlugins } from './plugins' // 追加
import { transformMiddleware } from './transformMiddleware'

export const startDev = () => {
  const server = connect()
  server.listen(3000, 'localhost')

  const plugins = getPlugins() // 追加
  const pluginContainer = createPluginContainer(plugins) // 追加

  server.use(transformMiddleware(pluginContainer)) // 変更

/* 後略 */

これでplaygroundディレクトリでnpm run devを実行して、localhost:3000/src/main.tsにアクセスするとJavaScriptに変換されていることがわかります。

拡張子の省略への対応 (6f0fb4f)

先ほどの状態だとlocalhost:3000にアクセスをしても、/src/contentが読み込めておらず画面が真っ白のままです。
これはsrc/contentではなくsrc/content.tsを読みにいくようにする必要があるためです。

micro-vite/src/resolvePlugin.tsで拡張子がない場合の処理を追加しましょう。

const root = process.cwd()

const extensions = ['', '.ts', '.js'] // 追加

export const resolve = (): Plugin => {
  return {
    name: 'micro-vite:resolve',
    async resolveId(id: string) {
      for (const ext of extensions) { // 拡張子をつけてファイルがあるかチェックするように変更
        const absolutePath = path.resolve(root, `.${id}${ext}`)
        try {
          const stat = await fs.stat(absolutePath)
          if (stat.isFile()) {
            return absolutePath
          }
        } catch {}
      }
      return null
    },

これでplaygroundディレクトリでnpm run devを実行して、localhost:3000にアクセスすると無事ちゃんとした画面が表示されるようになります!

ファイル変更時のリロード

今度はファイルを変更した際にブラウザが自動でリロードする機能を実装していきます。
Viteでは開発サーバーからWebSocketを通じてメッセージを送信することでこれを実現しています。ここでも同じ方法で実装していきます。

リロード用スクリプトの挿入 (cf8ec08)

配信するHTMLにscriptタグを挿入してそのscriptタグで指定したURLでリロード用のスクリプトを配信するようにします。

HTMLの編集を行うためにここではnode-html-parserを使うことにします。micro-viteディレクトリでnpm i node-html-parserを実行します。

先に/でアクセスした際も/index.htmlにアクセスしたときと同じ動作がされるようにmicro-vite/src/resolvePlugin.tsを以下のように変更しておきます。

// 追加
const fileExists = async (p: string) => {
  try {
    const stat = await fs.stat(p)
    if (stat.isFile()) {
      return true
    }
  } catch {}
  return false
}

export const resolve = (): Plugin => {
  return {
    name: 'micro-vite:resolve',
    async resolveId(id: string) {
      for (const ext of extensions) {
        const absolutePath = path.resolve(root, `.${id}${ext}`)
        if (await fileExists(absolutePath)) { // fileExists関数への切り出し
          return absolutePath
        }
      }

      // 追加: /で終わっていたら/index.htmlがあるかチェックしてidを変更する
      if (id.endsWith('/')) {
        const absolutePath = path.resolve(root, `.${id}index.html`)
        if (await fileExists(absolutePath)) {
          return absolutePath
        }
      }

      return null
    },
/* 以下略 */

micro-vite/src/reloadPlugin.tsにHTMLへのscriptタグの挿入とそのスクリプトの配信をします。ここでは前述したバーチャルファイルという方法をとります。

import type { Plugin } from 'rollup'
import { parse } from 'node-html-parser'

const virtualScriptId = '/@micro-vite:reload/script.js'
const virtualScript = `
  console.log('bar')
` // とりあえずbarとコンソールに出力することにする

export const reload = (): Plugin => {
  return {
    name: 'micro-vite:reload',
    async resolveId(id: string) {
      // virtualScriptIdのものはそのまま解決できると指定する
      if (id === virtualScriptId) return virtualScriptId
      return null
    },
    async load(id: string) {
      // virtualScriptIdの内容としてvirtualScriptを返す
      if (id === virtualScriptId) {
        return virtualScript
      }
      return null
    },
    async transform(code, id) {
      if (!id.endsWith('.html')) return null

      // HTMLのheadタグの末尾にvirtualScriptIdへのリンクのあるscriptタグを挿入する
      const doc = parse(code)
      doc
        .querySelector('head')
        ?.insertAdjacentHTML('beforeend', `<script type="module" src="${virtualScriptId}">`)

      return doc.toString()
    }
  }
}

そうしたら、micro-vite/src/plugins.tsreloadPluginを追加します。

import { resolve } from './resolvePlugin'
import { reload } from './reloadPlugin' // 追加
import esbuild from 'rollup-plugin-esbuild'

export const getPlugins = () => [
  resolve(),
  reload(), // 追加
  esbuild({
    target: 'esnext',
    minify: false
  })
]

これでplaygroundディレクトリでnpm run devを実行して、localhost:3000にアクセスすると開発者ツールのコンソールにbarと出力されるようになりました。

WebSocketでの通信の実装 (4e4bf0d)

開発サーバーとの通信を実装していきます。

ここではViteでも利用されているwsを利用します。micro-viteディレクトリでnpm i wsnpm i -D @types/wsを実行します。

micro-vite/src/reloadPlugin.tsに送受信処理を実装します。JSONでシリアライズしてメッセージを送る単純な実装です。

import type { Plugin } from 'rollup'
import { parse } from 'node-html-parser'
import WebSocket, { WebSocketServer } from 'ws'; // 追加

const port = 24678
const virtualScriptId = '/@micro-vite:reload/script.js'
const virtualScript = `
  const ws = new WebSocket('ws://localhost:${port}/')
  ws.addEventListener('message', ({ data }) => {
    const msg = JSON.parse(data)
    if (msg.type === 'reload') {
      location.reload() // リロードというメッセージが来たらページをリロードする
    }
  })
` // 変更

export const reload = (): Plugin => {
  /* 省略 */
}

interface Data { // 追加
  type: string
}

export const setupReloadServer = () => { // 追加
  const wss = new WebSocketServer({
    port,
    host: 'localhost'
  })

  let ws: WebSocket
  wss.on('connection', connectedWs => {
    ws = connectedWs
  })

  return {
    send(data: Data) {
      if (!ws) return
      ws.send(JSON.stringify(data))
    }
  }
}

あとはこれをmicro-vite/src/dev.tsで利用するようにします。

import sirv from 'sirv'
import { createPluginContainer } from './pluginContainer'
import { getPlugins } from './plugins'
import { setupReloadServer as setupWsServer } from './reloadPlugin' // 追加
import { transformMiddleware } from './transformMiddleware'

export const startDev = () => {
  const server = connect()
  server.listen(3000, 'localhost')
  const ws = setupWsServer() // 追加

  /* 中略 */

  console.log('dev server running at http://localhost:3000')

  setTimeout(() => { // 追加
    console.log('reload!')
    ws.send({ type: 'reload' })
  }, 1000 * 5) // とりあえず5秒後にリロードするようにする
}

これでplaygroundディレクトリでnpm run devを実行して、localhost:3000にアクセスすると、コマンド実行後の5秒後にブラウザがリロードされるようになりました。

ファイルの監視 (98b34dd)

さて、ファイルの監視を行ってファイルの変更があったときにブラウザのリロードを行うようにしましょう。

ここではViteでも利用されているchokidarを利用します。micro-viteディレクトリでnpm i chokidarを実行します。

micro-vite/src/fileWatcher.tsにファイル監視処理を実装します。

import chokidar from 'chokidar'

export const createFileWatcher = (onChange: (eventName: string, path: string) => void) => {
  const watcher = chokidar.watch('**/*', {
    ignored: ['node_modules', '.git'],
    ignoreInitial: true // リッスン開始時にイベントを発火しないようにする
  })
  watcher.on('all', (eventName, path) => {
    onChange(eventName, path) // 何か変更が起きたらonChangeを呼び出す
  })
}

そうしたらmicro-vite/src/dev.tsでそれを利用するようにします。

import connect from 'connect'
import historyApiFallback from 'connect-history-api-fallback'
import sirv from 'sirv'
import { createFileWatcher } from './fileWatcher' // 追加
import { createPluginContainer } from './pluginContainer'
import { getPlugins } from './plugins'

/* 中略 */

  console.log('dev server running at http://localhost:3000')

  createFileWatcher((eventName, path) => { // setTimeoutから変更
    console.log(`Detected file change (${eventName}) reloading!: ${path}`)
    ws.send({ type: 'reload' })
  })
}

これでファイルを変更した際に自動でブラウザがリロードされるようになりました!

バンドル

開発サーバーは起動するようになったので、今度はバンドルの実装をしていきます。
これに関してはほとんどrollup任せなので、実装量は少ないです。

index.htmlの加工 (1108f8b)

まず、バンドルして生成されたファイルをindex.htmlに挿入する処理を実装します。

micro-vite/src/build.tsに実装していきます。

import * as path from 'node:path'
import * as fs from 'node:fs/promises'
import parse from 'node-html-parser'

const root = process.cwd()
const dist = path.resolve(root, './dist')

export const startBuild = async () => {
  await fs.rm(dist, { recursive: true, force: true }).catch(() => {})
  await fs.mkdir(dist)

  const indexHtmlPath = path.resolve(root, './index.html')
  const distIndexHtmlPath = path.resolve(dist, './index.html')
  // index.htmlを加工してdist/index.htmlに出力する
  await processHtml(indexHtmlPath, distIndexHtmlPath, async src => {
    // スクリプトタグのsrc属性を変更する
    return src + '?processed'
  })
}

const processHtml = async (
  htmlPath: string,
  distHtmlPath: string,
  bundleEntrypoint: (path: string) => Promise<string>
) => {
  const htmlContent = await fs.readFile(htmlPath, 'utf-8')
  const doc = parse(htmlContent)
  const scriptTag = doc.querySelector('script') // only expect one entrypoint
  if (scriptTag) {
    const src = scriptTag.getAttribute('src')
    if (src) {
      const newSrc = await bundleEntrypoint(src)
      scriptTag.setAttribute('src', newSrc) // scriptタグのsrc属性を変更する
    }
  }
  await fs.writeFile(distHtmlPath, doc.toString(), 'utf-8')
}

micro-vite/src/index.tsから先ほどのstartBuild関数を呼ぶようにします。

import cac from 'cac'
import { startDev } from './dev'
import { startBuild } from './build' // 追加

const cli = cac()

cli.command('dev')
  .action(() => {
    startDev()
  })

cli.command('build')
  .action(() => {
    startBuild() // 変更
  })

cli.help()
cli.parse()

これでplaygroundディレクトリでnpm run buildを実行すると、index.htmlのscriptタグのsrc属性に?processedを追加したファイルがdist/index.htmlに出力されます。

バンドルの実行

rollupを型だけでなく実行に利用するので、devDependenciesからdependenciesに移すためにnpm i -P rollupを実行します。

開発時とバンドル時でプラグインの設定が変えられるようにmicro-vite/src/plugins.tsgetPluginsを以下のように変更します。

export const getPlugins = (isDev: boolean) => [
  ...(isDev ? [resolve(), reload()] : []),
  esbuild({
    target: isDev ? 'esnext' : 'es2019',
    minify: !isDev
  })
]

あわせて、micro-vite/src/dev.tsconst plugins = getPlugins()const plugins = getPlugins(true)に変更します。

さて、micro-vite/src/build.tsにバンドルの実行を実装していきます。

import * as path from 'node:path'
import * as fs from 'node:fs/promises'
import parse from 'node-html-parser'
import { rollup } from 'rollup' // 追加
import { getPlugins } from './plugins' // 追加

const root = process.cwd()
const dist = path.resolve(root, './dist')

export const startBuild = async () => {
  const plugins = getPlugins(false) // 追加

  await fs.rm(dist, { recursive: true, force: true }).catch(() => {})
  await fs.mkdir(dist)

  const indexHtmlPath = path.resolve(root, './index.html')
  const distIndexHtmlPath = path.resolve(dist, './index.html')
  await processHtml(indexHtmlPath, distIndexHtmlPath, async src => {
    // この関数の中を変更
    
    // rollupでバンドルする
    const bundle = await rollup({
      input: path.resolve(root, `.${src}`),
      plugins
    })
    const { output } = await bundle.write({
      dir: dist,
      format: 'es',
      entryFileNames: 'assets/[name].[hash].js',
      chunkFileNames: 'assets/[name].[hash].js'
    })
    await bundle.close()
    return `/${output[0].fileName}` // 出力されたファイル名をsrc属性にセットする
  })
}

/* 以下略 */

これでplaygroundディレクトリでnpm run buildを実行すると、index.html以外にバンドルされたJavaScriptファイルがdistディレクトリに出力されます!

バンドル結果でのプレビュー

さてバンドルはできましたが、バンドル結果が正常に動作しているか確認できないので、プレビュー環境を簡単に用意します。

playgroundディレクトリでnpm i -D sirv-cliを実行します。
package.jsonscriptsに以下のようにpreviewを追加します。

  "scripts": {
    "dev": "micro-vite dev",
    "build": "micro-vite build"
    "build": "micro-vite build",
    "preview": "sirv dist"
  },

これでplaygroundディレクトリでnpm run previewを実行することでプレビューが表示できるようになりました!
バンドルした結果も正常に動作していそうです。

最後に

だいぶ長くなってしまいましたが、Viteの仕組みがなんとなく伝わりましたでしょうか?

実際につくってみた感想としては、ライブラリを利用しつつ、機能をかなり絞れば意外と少ない行数で作れるんだなぁってなりました。
参考までに最終的な各ファイルの行数と合計の行数は以下の通りです。

$ wc -l $(find src -type f)
  50 src/build.ts
  39 src/dev.ts
  11 src/fileWatcher.ts
  19 src/index.ts
  52 src/pluginContainer.ts
  11 src/plugins.ts
  64 src/reloadPlugin.ts
  47 src/resolvePlugin.ts
  54 src/transformMiddleware.ts
 347 total

明日の担当者は@logicaです。お楽しみに!

sappi_red icon
この記事を書いた人
sappi_red

19B/22M。SysAd班。 JavaScript書いたりTypeScript書いたりGo書いたりRust書いたり…

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2023年12月11日
DIGI-CON HACKATHON 2023『Mikage』
toshi00 icon toshi00
2022年4月7日
traPグラフィック班の活動紹介
annin icon annin
2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2024年4月14日
Spotifyのクライアントを自作しよう
d_etteiu8383 icon d_etteiu8383
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記