Анализ принципа Vite

исходный код Vue.js Vite

Оригинальная статья впервые опубликована в моемблог, Добро пожаловать в гости.

Прошло много времени с тех пор, как я вел блог. В этой статье не говорится о Vue3.0, я полагаю, что об этом уже написано много статей. А некоторое время назад особенно с открытым исходным кодомViteЭто то, что мне больше нравится, его общая идея очень хороша, а стоимость изучения раннего исходного кода относительно невелика, поэтому я воспользовался отпуском, чтобы поучиться.

Эта статья была написана в версии Vite-0.9.1.

Что такое Вите

Заимствуя оригинальные слова автора:

Vite, сервер разработки, основанный на импорте ES из браузера. Используйте браузер для анализа импорта, компиляции и возврата по запросу на стороне сервера, полностью пропуская концепцию упаковки, и сервер можно использовать по мере необходимости. При этом он не только поддерживает файлы Vue, но и обрабатывает горячие обновления, причем скорость горячих обновлений не будет замедляться по мере увеличения количества модулей. Для производственных сред тот же код можно упаковать с помощью свертки. Несмотря на то, что пока это относительно грубо, я думаю, что у этого направления есть потенциал, и если оно будет сделано хорошо, оно может полностью решить проблему полдня горячих обновлений, таких как изменение строки кода.

Обратите внимание на две вещи:

  • Во-первых, основным соответствующим сценарием Vite является режим разработки, Принцип состоит в том, чтобы перехватить запрос на импорт ES, выданный браузером, и соответствующим образом обработать его. (Производственный режим упакован с накопительным пакетом)
  • Во-первых, Vite не нужно упаковывать в режиме разработки, ему нужно только скомпилировать файл, соответствующий HTTP-запросу, отправленному браузером, поэтому скорость горячего обновления очень высока.

Поэтому для достижения вышеперечисленных целей необходимо требовать в проекте только нативные ES-импорты, в случае использования require он будет невалидным, поэтому использовать его для полной замены Webpack нереально. Как упоминалось выше, упаковка в производственном режиме не предоставляется самим Vite, поэтому по-прежнему можно использовать Webpack для упаковки в производственном режиме. С этой точки зрения Vite может быть скорее заменой webpack-dev-server.

модуль модулей

Реализация Vite неотделима от нативной поддержки современных браузеров.Функция модуля. следующее:

<script type="module">
import { a } from './a.js'
</script>

при объявленииscriptТип этикеткиmodule, браузер будетimportцитирование инициированоHTTPЗапрос на получение содержимого модуля. Например, выше, браузер инициируетHOST/a.jsHTTP-запрос выполняется после получения содержимого.

Vite перехватывает эти запросы и выполняет соответствующую обработку на сервере (например, разбивает файл Vue наtemplate,style,scriptтри части) перед возвратом в браузер.

Поскольку браузер будет инициировать HTTP-запросы только к используемым модулям, Vite не нужно сначала упаковывать все файлы в проекте, а затем возвращать их, а только компилировать модули, которые браузер инициирует HTTP-запросы. Это что-то вроде загрузки по требованию?

Разница между компиляцией и упаковкой

Увидев это, у некоторых друзей могут возникнуть вопросы, а в чем разница между компиляцией и упаковкой? Почему Vite утверждает, что «скорость горячего обновления не снижается по мере увеличения количества модулей»?

В качестве простого примера есть три файлаa.js,b.js,c.js

// a.js
const a = () => { ... }
export { a }

// b.js
const b = () => { ... }
export { b }
// c.js
import { a } from './a'
import { b } from './b'

const c = () => {
  return a() + b()
}

export { c }

Если в качестве записи используется файл c, упаковка будет выглядеть следующим образом (результат упрощается): (Предположим, что имя файла пакета равноbundle.js)

// bundle.js
const a = () => { ... }
const b = () => { ... }
const c = () => {
  return a() + b()
}

export { c }

Стоит отметить, что упаковка также требует этапа компиляции.

Принцип горячего обновления Webpack заключается в том, что при возникновении зависимости (например,a.js) изменить, поместите эту зависимость вmoduleобновить и вставить новыйmoduleОтправьте его в браузер для повторного выполнения. Поскольку мы попали только в одинbundle.js, так что если будет горячее обновление, то тоже перепечатает этоbundle.js. Представьте, что если зависимостей становится все больше и больше, даже если модифицируется только один файл, теоретически скорость горячего обновления будет становиться все медленнее и медленнее.

А что, если это похоже на Vite, который только компилируется, а не упаковывается?

Если вы просто скомпилируете, окончательный вывод по-прежнемуa.js,b.js,c.jsТри файла, требующие только времени для компиляции. Так как вход естьc.js, браузер разрешаетimport { a } from './a', будет сделан HTTP-запросa.js(То же самое и для b), даже если вам не нужно его паковать, вы можете загрузить нужный код, тем самым сэкономив время на слияние кода.

Во время горячего обновления, еслиaизменилось, просто нужно обновитьaи использоватьaизc. так какbНичего не изменилось, поэтому Vite не нужно перекомпилировать.b, вы можете получить скомпилированный результат прямо из кеша. Таким образом, измените файлa, будет только перекомпилировать этот файлaи браузер в данный момент использует этот файлaфайлы, а остальные файлы перекомпилировать не нужно. Так что теоретически скорость горячего обновления не будет замедляться по мере увеличения файла.

Конечно, есть ли какие-либо недостатки в этом? Да, если браузер запрашивает слишком много модулей во время инициализации, это также приведет к проблемам с производительностью инициализации. Однако, если вы можете столкнуться с проблемой медленной инициализации, я считаю, что скорость горячего обновления многое компенсирует. Конечно, я верю, что You Da решит эту проблему в будущем.

Реализация работающего веб-приложения Vite

Упомянутое выше предзнаменование может быть недостаточно интуитивным, поэтому мы можем сначала запустить проект Vite, чтобы увидеть его.

По инструкции на официальном сайте можно ввести следующую команду (<project-name>Вы можете использовать имя каталога, которое вы хотите)

$ npx create-vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev

Если все хорошо, вы будете вlocalhost:3000(Порт с сервера Vite) См. этот интерфейс:

И получить следующую структуру кода:

.
├── App.vue // 页面的主要逻辑
├── index.html // 默认打开的页面以及 Vue 组件挂载
├── node_modules
└── package.json

Перехватывать HTTP-запросы

Далее поговорим о ядре реализации Vite — перехвате запросов браузера на модули и возврате обработанных результатов.

Мы знаем это, потому чтоlocalhost:3000Откройте веб-страницу, поэтому первый запрос, инициированный браузером, естественно, является запросомlocalhost:3000/, после того как этот запрос будет отправлен на серверную часть Vite, он будет обработан сервером статических ресурсов, а затем будет запрошен/index.html, Vite начинает перехватывать и обрабатывать этот запрос.

Во-первых,index.htmlИсходный код выглядит следующим образом:

<div id="app"></div>
<script type="module">
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')
</script>

Но в браузере это выглядит так:

Заметили разницу? Да,import { createApp } from 'vue'заменяетсяimport { createApp } from '/@modules/vue.

Здесь я должен сказать, что браузер правimportНекоторые ограничения, когда модуль инициирует запрос, обычно пишем код, если не модуль ссылается на относительный путь, а на ссылкуnode_modulesмодули, непосредственноimport xxx from 'xxx', и такие инструменты, как Webpack, помогают нам найти конкретный путь к этому модулю. Но браузер не знает, что ваш проектnode_modules, он может найти модули только по относительному пути.

Поэтому в перехваченном запросе Vite напрямую ссылается наnode_modulesМодули были заменены путем, заменены на/@modules/и вернуться. Затем, после того, как браузер получит его, он инициирует ответ на/@modules/xxxЗатем запрос снова перехватывается Vite, и Vite получает внутренний доступ к реальному модулю и возвращает полученный контент в браузер после повторного выполнения той же обработки.

импорт заменяет

Обычная замена импорта JS

Упомянутая выше замена происходит отsrc/node/serverPluginModuleRewrite.ts:

// 只取关键代码:
// Vite 使用 Koa 作为内置的服务器
// 如果请求的路径是 /index.html
if (ctx.path === '/index.html') {
  // ...
  const html = await readBody(ctx.body)
  ctx.body = html.replace(
    /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm, // 正则匹配
    (_, openTag, script) => {
      // also inject __DEV__ flag
      const devFlag = hasInjectedDevFlag ? `` : devInjectionCode
      hasInjectedDevFlag = true
       // 替换 html 的 import 路径
      return `${devFlag}${openTag}${rewriteImports(
        script,
        '/index.html',
        resolver
      )}</script>`
    }
  )
  // ...
}

если не вscriptПишите прямо внутри этикеткиimport, но используйтеsrcФорма цитируется следующим образом:

<script type="module" src="/main.js"></script>

Затем браузер инициируетmain.jsОбработка по запросу:

// 只取关键代码:
if (
  ctx.response.is('js') &&
  // ...
) {
  // ...
  const content = await readBody(ctx.body)
  await initLexer
  // 重写 js 文件里的 import
  ctx.body = rewriteImports(
    content,
    ctx.url.replace(/(&|\?)t=\d+/, ''),
    resolver,
    ctx.query.t
  )
  // 写入缓存,之后可以从缓存中直接读取
  rewriteCache.set(content, ctx.body)
}

логика заменыrewriteImportsНе расширяется, используетсяes-module-lexerграмматический анализ для полученияimportsмассив, а затем выполните замену.

Замена файлов *.vue

еслиimportда.vueфайл, который будет производить дальнейшие замены:

оригинальныйApp.vueФайл выглядит так:

<template>
  <h1>Hello Vite + Vue 3!</h1>
  <p>Edit ./App.vue to test hot module replacement (HMR).</p>
  <p>
    <span>Count is: {{ count }}</span>
    <button @click="count++">increment</button>
  </p>
</template>

<script>
export default {
  data: () => ({ count: 0 }),
}
</script>

<style scoped>
h1 {
  color: #4fc08d;
}

h1, p {
  font-family: Arial, Helvetica, sans-serif;
}
</style>

После замены выглядит так:

// localhost:3000/App.vue
import { updateStyle } from "/@hmr"

// 抽出 script 逻辑
const __script = {
  data: () => ({ count: 0 }),
}

// 将 style 拆分成 /App.vue?type=style 请求,由浏览器继续发起请求获取样式
updateStyle("c44b8200-0", "/App.vue?type=style&index=0&t=1588490870523")
__script.__scopeId = "data-v-c44b8200" // 样式的 scopeId

// 将 template 拆分成 /App.vue?type=template 请求,由浏览器继续发起请求获取 render function
import { render as __render } from "/App.vue?type=template&t=1588490870523&t=1588490870523"
__script.render = __render // render 方法挂载,用于 createApp 时渲染
__script.__hmrId = "/App.vue" // 记录 HMR 的 id,用于热更新
__script.__file = "/XXX/web/vite-test/App.vue" // 记录文件的原始的路径,后续热更新能用到
export default __script

Таким образом, исходный.vueФайл разбит на три запроса (соответствующихscript,styleиtemplate), браузер сначала получитscriptлогическийApp.vue, который затем анализируется наtemplateиstyleПосле установки пути снова будет инициирован HTTP-запрос для запроса соответствующего ресурса, В это время Vite перехватит его, снова обработает и вернет соответствующий контент.

следующее:

Я должен сказать, что эта идея очень умна.

Разделение на этом шаге происходит отsrc/node/serverPluginVue.ts, основная логика заключается в выполнении различной обработки в соответствии с параметром запроса URL-адреса (упрощенный анализ выглядит следующим образом):

// 如果没有 query 的 type,比如直接请求的 /App.vue
if (!query.type) {
  ctx.type = 'js'
  ctx.body = compileSFCMain(descriptor, filePath, publicPath) // 编译 App.vue,编译成上面说的带有 script 内容,以及 template 和 style 链接的形式。
  return etagCacheCheck(ctx) // ETAG 缓存检测相关逻辑
}

// 如果 query 的 type 是 template,比如 /App.vue?type=template&xxx
if (query.type === 'template') {
  ctx.type = 'js'
  ctx.body = compileSFCTemplate( // 编译 template 生成 render function
    // ...
  )
  return etagCacheCheck(ctx)
}

// 如果 query 的 type 是 style,比如 /App.vue?type=style&xxx
if (query.type === 'style') {
  const index = Number(query.index)
  const styleBlock = descriptor.styles[index]
  const result = await compileSFCStyle( // 编译 style
    // ...
  )
  if (query.module != null) { // 如果是 css module
    ctx.type = 'js'
    ctx.body = `export default ${JSON.stringify(result.modules)}`
  } else { // 正常 css
    ctx.type = 'css'
    ctx.body = result.code
  }
}

@modules/* разрешение пути

Вышеизложенное включает только логику замены, а логика синтаксического анализа исходит изsrc/node/serverPluginModuleResolve.ts. Этот шаг относительно прост, основная логика состоит в том, чтобы перейти кnode_modulesЕсли есть какой-либо соответствующий модуль, верните его, если нет, сообщите 404: (Опущено много логики, например, правильныйweb_modulesобработка, обработка кэша и т.д.)

// ...
try {
  const file = resolve(root, id) // id 是模块的名字,比如 axios
  return serve(id, file, 'node_modules') // 从 node_modules 中找到真正的模块内容并返回
} catch (e) {
  console.error(
    chalk.red(`[vite] Error while resolving node_modules with id "${id}":`)
  )
  console.error(e)
  ctx.status = 404 // 如果没找到就 404
}

Внедрение горячего обновления Vite

Выше было сказано, как Vite запускает веб-приложение, в том числе как перехватывать запросы, заменять контент и возвращать обработанные результаты. Далее поговорим о реализации горячего обновления Vite, тоже очень умной.

Мы знаем, что если должно быть реализовано горячее обновление, то браузер и сервер должны установить какой-то механизм связи, чтобы браузер мог получать уведомление о горячем обновлении. Вите закончилсяWebSocketдля достижения горячей связи обновления.

клиент

Код клиента находится вsrc/client/client.ts, в основном для созданияWebSocketКлиентская сторона прослушивает push-сообщения HMR со стороны сервера.

Клиент Vite WS в настоящее время прослушивает следующие типы сообщений:

  • connected: соединение WebSocket установлено успешно
  • vue-reload: перезагрузка компонента Vue (при изменении содержимого в скрипте)
  • vue-rerender: повторный рендеринг компонента Vue (при изменении содержимого в шаблоне)
  • style-update: обновление стиля
  • style-remove: стиль удален
  • js-update: обновление js-файла
  • full-reload: резервный механизм, обновление веб-страницы

Некоторые обновления для самого компонента Vue можно вызывать напрямуюHMRRuntimeПредлагаемый метод очень удобен. Остальная логика обновления в основном используетсяtimestampОбновите кеш и повторно выполните метод для достижения цели обновления.

Основная логика выглядит следующим образом, я чувствую себя очень ясно:

import { HMRRuntime } from 'vue' // 来自 Vue3.0 的 HMRRuntime

console.log('[vite] connecting...')

declare var __VUE_HMR_RUNTIME__: HMRRuntime

const socket = new WebSocket(`ws://${location.host}`)

// Listen for messages
socket.addEventListener('message', ({ data }) => {
  const { type, path, id, index, timestamp, customData } = JSON.parse(data)
  switch (type) {
    case 'connected':
      console.log(`[vite] connected.`)
      break
    case 'vue-reload':
      import(`${path}?t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.reload(path, m.default)
        console.log(`[vite] ${path} reloaded.`) // 调用 HMRRUNTIME 的方法更新
      })
      break
    case 'vue-rerender':
      import(`${path}?type=template&t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.rerender(path, m.render)
        console.log(`[vite] ${path} template updated.`) // 调用 HMRRUNTIME 的方法更新
      })
      break
    case 'style-update':
      updateStyle(id, `${path}?type=style&index=${index}&t=${timestamp}`) // 重新加载 style 的 URL
      console.log(
        `[vite] ${path} style${index > 0 ? `#${index}` : ``} updated.`
      )
      break
    case 'style-remove':
      const link = document.getElementById(`vite-css-${id}`)
      if (link) {
        document.head.removeChild(link) // 删除 style
      }
      break
    case 'js-update':
      const update = jsUpdateMap.get(path)
      if (update) {
        update(timestamp) // 用新的时间戳加载并执行 js,达到更新的目的
        console.log(`[vite]: js module reloaded: `, path)
      } else {
        console.error(
          `[vite] got js update notification but no client callback was registered. Something is wrong.`
        )
      }
      break
    case 'custom':
      const cbs = customUpdateMap.get(id)
      if (cbs) {
        cbs.forEach((cb) => cb(customData))
      }
      break
    case 'full-reload':
      location.reload()
  }
})

Сервер

Реализация сервера находится наsrc/node/serverPluginHmr.ts. Суть в том, чтобы отслеживать изменения в файлах проекта, а затем по разным типам файлов (на данный момент толькоvueиjs) для выполнения различной обработки:

watcher.on('change', async (file) => {
  const timestamp = Date.now() // 更新时间戳
  if (file.endsWith('.vue')) {
    handleVueReload(file, timestamp)
  } else if (file.endsWith('.js')) {
    handleJSReload(file, timestamp)
  }
})

заVueЧто касается горячего обновления файлов, то это, в основном, перекомпиляцияVueфайл, проверитьtemplate,script,styleЕсли есть какие-либо изменения, соответствующий запрос на горячее обновление будет инициирован через сервер WS.

Простой анализ исходного кода выглядит следующим образом:

async function handleVueReload(
    file: string,
    timestamp: number = Date.now(),
    content?: string
) {
  const publicPath = resolver.fileToRequest(file) // 获取文件的路径
  const cacheEntry = vueCache.get(file) // 获取缓存里的内容

  debugHmr(`busting Vue cache for ${file}`)
  vueCache.del(file) // 发生变动了因此之前的缓存可以删除

  const descriptor = await parseSFC(root, file, content) // 编译 Vue 文件

  const prevDescriptor = cacheEntry && cacheEntry.descriptor // 获取前一次的缓存

  if (!prevDescriptor) {
    // 这个文件之前从未被访问过(本次是第一次访问),也就没必要热更新
    return
  }

  // 设置两个标志位,用于判断是需要 reload 还是 rerender
  let needReload = false
  let needRerender = false

  // 如果 script 部分不同则需要 reload
  if (!isEqual(descriptor.script, prevDescriptor.script)) {
    needReload = true
  }

  // 如果 template 部分不同则需要 rerender
  if (!isEqual(descriptor.template, prevDescriptor.template)) {
    needRerender = true
  }

  const styleId = hash_sum(publicPath)
  // 获取之前的 style 以及下一次(或者说热更新)的 style
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []

  // 如果不需要 reload,则查看是否需要更新 style
  if (!needReload) {
    nextStyles.forEach((_, i) => {
      if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
        send({
          type: 'style-update',
          path: publicPath,
          index: i,
          id: `${styleId}-${i}`,
          timestamp
        })
      }
    })
  }

  // 如果 style 标签及内容删掉了,则需要发送 `style-remove` 的通知
  prevStyles.slice(nextStyles.length).forEach((_, i) => {
    send({
      type: 'style-remove',
      path: publicPath,
      id: `${styleId}-${i + nextStyles.length}`,
      timestamp
    })
  })

  // 如果需要 reload 发送 `vue-reload` 通知
  if (needReload) {
    send({
      type: 'vue-reload',
      path: publicPath,
      timestamp
    })
  } else if (needRerender) {
    // 否则发送 `vue-rerender` 通知
    send({
      type: 'vue-rerender',
      path: publicPath,
      timestamp
    })
  }
}

Для горячих обновленийjsДля файлов он будет рекурсивно находить ссылки на этот файлimporter. напримерVueДокумент ссылается на этоjs, будет найдено. Если в конце концов обнаружится, что реферер не может быть найден, он вернетhasDeadEnd: true.

const vueImporters = new Set<string>() // 查找并存放需要热更新的 Vue 文件
const jsHotImporters = new Set<string>() // 查找并存放需要热更新的 js 文件
const hasDeadEnd = walkImportChain(
  publicPath,
  importers,
  vueImporters,
  jsHotImporters
)

еслиhasDeadEndзаtrue, затем отправить напрямуюfull-reload. еслиvueImportersилиjsHotImportersЕсли в файле будет обнаружен файл, требующий горячего обновления, будет выдано уведомление о горячем обновлении:

if (hasDeadEnd) {
  send({
    type: 'full-reload',
    timestamp
  })
} else {
  vueImporters.forEach((vueImporter) => {
    send({
      type: 'vue-reload',
      path: vueImporter,
      timestamp
    })
  })
  jsHotImporters.forEach((jsImporter) => {
    send({
      type: 'js-update',
      path: jsImporter,
      timestamp
    })
  })
}

Внедрение клиентской логики

Написав здесь, есть еще одна проблема, которую мы не ввели в наш код.HRMизclientкод, как Vite ставитclientКак насчет внедрения кода?

Возвращаясь к картинке выше, Вите переписываетApp.vueСодержимое файла и при возврате:

Обратите внимание на первое предложение области кода на этой картинке.import { updateStyle } from '/@hmr', а в списке запросов слева тоже есть пара@hmrзапрос файла. Что это за просьба?

Можно обнаружить, что этот запрос является клиентской логикой, упомянутой выше.client.tsСодержание.

существуетsrc/node/serverPluginHmr.ts, Есть@hmrОбработка парсинга файлов:

export const hmrClientFilePath = path.resolve(__dirname, './client.js')
export const hmrClientId = '@hmr'
export const hmrClientPublicPath = `/${hmrClientId}`

app.use(async (ctx, next) => {
  if (ctx.path !== hmrClientPublicPath) { // 请求路径如果不是 @hmr 就跳过
    return next()
  }
  debugHmr('serving hmr client')
  ctx.type = 'js'
  await cachedRead(ctx, hmrClientFilePath) // 返回 client.js 的内容
})

До сих пор был проанализирован общий процесс горячего обновления.

резюме

В последнее время этот проект итерируется с невероятной скоростью, поэтому, если я скоро вернусь к этой статье, код и реализация могут устареть. Тем не менее, общая идея Vite очень хороша, когда на ранней стадии не так много исходного кода, это хороший выигрыш, чтобы иметь возможность узнать что-то более близкое к первоначальной идее автора. Я надеюсь, что эта статья поможет вам в изучении Vite, и вы можете указать на любые ошибки.