Vite: как реализовать горячее обновление

JavaScript
Vite: как реализовать горячее обновление

предисловие

В предыдущей статье говорилосьVite: как разрабатывать приложения без веб-пакета, согласно договору, следующее, что нужно сказать热更新Эта проблема,Viteпройти мимо себяWebSocketРеализуйте связь между браузером и сервером, чтобы достичь热更新цель.

Но сначала нам нужно знать热更新Что это значит, в чем разница между этим и прямым обновлением страницы и как это реализовано по-другому? Следующее содержание в основном для справкиИнтенсивная лекция по фронтенд-инжинирингу

Live Reload

Обычно мы используем базовый метод для локальной отладки приложения.webpack-dev-server, а его основная функция — запуск локальной службы.Основная конфигурация кода показана на следующем рисунке:

// webpack.config.js
module.exports = {
   //...
   devServer: {
    contentBase: './dist', //为./dist目录中的静态页面文件提供本地服务渲染
    open: true          //启动服务后自动打开浏览器网页
  }
};

// package.json
"scripts": {
  "dev:reload": "webpack-dev-server"
}

воплощать в жизньdev:reloadПосле этого запускается сервер, и браузер автоматически обновляется для перезагрузки при изменении кода.

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

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

Hot Module Replacement

Hot Module ReplacementУказывает на то, что модуль подвергается горячей замене, о чем все говорили.热更新, чтобы решить проблему потери состояния, вызванную обновлением страницы, упомянутой выше, webpack предлагает концепцию горячей замены модуля. существуетwebpack-dev-server, есть конфигурацияhot, если установленоtrue, он будет автоматически добавлен напрямуюwebpack.HotModuleReplacementPlugin, давайте создадим простой пример, чтобы увидеть конкретный вариант горячего обновления:

// src/index.js
import './index.css'

// src/index.css
div {
  background:  red;
}

// dist/index.html
...
 <script src="./main.js"></script>
...

// webpack.config.js
const path = require("path");

module.exports = {
  entry: "./src/index.js",
  devServer: {
    contentBase: "./dist",
    open: true,
    hot: true // 开启热更新
  },
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

// package.json
"scripts": {
  "dev:reload": "webpack-dev-server"
}

Вот новая конфигурация модуля, которая использует загрузчик стилей и загрузчик css для анализа импортированных файлов CSS. Загрузчик css отвечает за преобразование импортированного файла CSS в модуль для последующей обработки загрузчиком, а загрузчик стилей отвечает за добавление содержимого модуля CSS в тег стиля страницы во время выполнения. После запуска вы можете увидеть:

В это время измените код css, а затем вернитесь в браузер, вы обнаружите, что в центре сетевой панели были созданы два новых запроса:

Однако, если вы измените содержимое файла JS, обновление все равно будет, что касается причины, давайте сначала посмотрим на процесс.style-loaderОбработанный файл примет следующий код:

var api = __webpack_require__(/*! ../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js */ "./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var content = __webpack_require__(/*! !../node_modules/css-loader/dist/cjs.js!./index.css */ "./node_modules/css-loader/dist/cjs.js!./src/index.css");

content = content.__esModule ? content.default : content;

if (typeof content === 'string') {
  content = [[module.i, content, '']];
}

var options = {};

options.insert = "head";
options.singleton = false;

var update = api(content, options);

var oldLocals = content.locals;

    module.hot.accept(
       // 依赖模块
       "./node_modules/css-loader/dist/cjs.js!./src/index.css",
       // 回调方法
       function() {
        ...
        // 修改 style 标签里的内容
        update(content);
      }
    )
  }

  module.hot.dispose(function() {
    // 移除 style 标签
    update();
  });
}

module.exports = content.locals || {};

Плагин горячей замены модуля

Приведенный выше модуль module.hot на самом деле является базовым плагином из веб-пакета.HotModuleReplacementPlugin, плагин используется как базовый плагин для функции горячей замены, а его методы API экспортируются в свойства module.hot .

  • hot.accept: Когда зависимый модуль изменяется, выполняется вставленный метод обратного вызова. Вышеупомянутое предназначено для обновления содержимого тега стиля.
  • hot.dispose: при удалении модуля контекста кода будет выполнен его метод обратного вызова. Вышеупомянутое, чтобы удалить тег стиля

Поэтому, поскольку код для этого плагина не добавлен в js, а парсинг кода не настраивает дополнительный загрузчик, который может вызывать API горячей замены для конкретных кодов, интерфейс будет обновляться сразу после модификации js , Добавьте код, подобный следующему:

./text.js
export const text = 'Hello World'
./index.js
import {text} from './text.js'

const div = document.createElement('div')
document.body.appendChild(div)
function render() {
  div.innerHTML = text;
}
render()
if (module.hot) {
  // 再 text.js 代码更新后不会重新刷新页面
  module.hot.accept('./text.js', function() {
    render()
  })
}

Реализация Vite Hot обновление

Благодаря вышеизложенному пониманию вы, вероятно, знаете логику и принцип горячего обновления.ViteСпособ достижения горячего обновления также аналогичен, в основном путем созданияWebSocketУстановите браузер для установления связи с сервером и отправки сообщений клиенту, отслеживая изменения файлов, и клиент может обновлять разные файлы, соответствующие различным операциям.

См. следующие частиАнализ принципа Vite

Сервер

Перейти к кодуserver/serverPluginHmr.tsтак же какserver/serverPluginVue.ts

watcher.on('change', (file) => {
  if (!(file.endsWith('.vue') || isCSSRequest(file))) {
    handleJSReload(file)
  }
})
watcher.on('change', (file) => {
  if (file.endsWith('.vue')) {
    handleVueReload(file)
  }
})

handleVueReload

async function handleVueReload(
    file: string,
    timestamp: number = Date.now(),
    content?: string
) {
  ...
  const cacheEntry = vueCache.get(file) // 获取缓存里的内容
  // @vue/compiler-sfc 编译 Vue 文件
  const descriptor = await parseSFC(root, file, content)
  const prevDescriptor = cacheEntry && cacheEntry.descriptor // 获取前一次的缓存
  if (!prevDescriptor) {
    // 这个文件之前从未被访问过(本次是第一次访问),也就没必要热更新
    return
  }
  // 判断是否需要 rerender 
  let needRerender = false
  // 向客户端发出 reload 消息的方法
  const sendReload = () => {
    send({
      type: 'vue-reload',
      path: publicPath,
      changeSrcPath: publicPath,
      timestamp
    })
  }
  // script 不同则直接 reload
  if (
    !isEqualBlock(descriptor.script, prevDescriptor.script) ||
    !isEqualBlock(descriptor.scriptSetup, prevDescriptor.scriptSetup)
  ) {
    return sendReload()
  }
  // 如果 template 部分不同则需要 rerender
  if (!isEqual(descriptor.template, prevDescriptor.template)) {
    needRerender = true
  }
  
  // 获取之前的 style 以及下一次(或者说热更新)的 style
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []
  // css module | vars injection | scopes 改变则直接 reload
  if (
    prevStyles.some((s) => s.module != null) ||
    nextStyles.some((s) => s.module != null)
  ) {
    return sendReload()
  }
  if (
    prevStyles.some((s, i) => {
      const next = nextStyles[i]
      if (s.attrs.vars && (!next || next.attrs.vars !== s.attrs.vars)) {
        return true
      }
    })
  ) {
    return sendReload()
  }
  if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
    return sendReload()
  }
  
  // 如果以上情况都不是,则 style 改变时 rerender
  nextStyles.forEach((_, i) => {
    if (!prevStyles[i] || !isEqualBlock(prevStyles[i], nextStyles[i])) {
      didUpdateStyle = true
      const path = `${publicPath}?type=style&index=${i}`
      send({
        type: 'style-update',
        path,
        changeSrcPath: path,
        timestamp
      })
    }
  })
  
  // 如果 style 标签及内容删掉了,则需要发送 `style-remove` 的通知
  prevStyles.slice(nextStyles.length).forEach((_, i) => {
    didUpdateStyle = true
    send({
      type: 'style-remove',
      path: publicPath,
      id: `${styleId}-${i + nextStyles.length}`
    })
  })
  // 如果需要 reredner 则发送 vue-rerender
  if (needRerender) {
    send({
      type: 'vue-rerender',
      path: publicPath,
      changeSrcPath: publicPath,
      timestamp
    })
  }
}

handleJSReload

Что касается перегрузки файла js, то он вызывается напрямую рекурсивноwalkImportChainчтобы узнать, кто его цитирует (импортер), и если вы не можете найти реферера, тоhasDeadEndвернутьtrue;

  const hmrBoundaries = new Set<string>() // 引用者如果是 vue 文件则存放在这
  const dirtyFiles = new Set<string>() // 引用者如果是 js 文件则存放在这
  
  const hasDeadEnd = walkImportChain(
    publicPath,
    importers || new Set(),
    hmrBoundaries,
    dirtyFiles
  )

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

if (hasDeadEnd) {
  send({
    type: 'full-reload',
    path: publicPath
  })
} else {
  const boundaries = [...hmrBoundaries]
  const file =
    boundaries.length === 1 ? boundaries[0] : `${boundaries.length} files`
  send({
    type: 'multi',
    updates: boundaries.map((boundary) => {
      return {
        type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',
        path: boundary,
        changeSrcPath: publicPath,
        timestamp
      }
    })
  })
}

клиент

Как упоминалось выше, сервер опубликует сообщение клиенту после отслеживания изменения, и код перейдет кsrc/client/client.ts, здесь в основном для созданияWebSocketКлиент, а затем прослушайте сообщение, отправленное сервером, чтобы обновить операцию

Основные сильные новости и соответствующие措施в основном включает:

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

Обновления здесь в основном черезtimestampОбновите и повторно запросите, чтобы получить обновленный контент, а затем передайте файл vueHMRRuntimeобновление реализации

import { HMRRuntime } from 'vue'
declare var __VUE_HMR_RUNTIME__: HMRRuntime

const socket = new WebSocket(socketUrl, 'vite-hmr')
socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
  if (payload.type === 'multi') {
    payload.updates.forEach(handleMessage)
  } else {
    handleMessage(payload)
  }
})

async function handleMessage(payload: HMRPayload) {
  const { path, changeSrcPath, timestamp } = payload as UpdatePayload
  switch (payload.type) {
    case 'connected':
      console.log(`[vite] connected.`)
      break
    case 'vue-reload':
      // 加入到更新队列,之后一起更新
      queueUpdate(
        // 重新请求
        import(`${path}?t=${timestamp}`)
          .catch((err) => warnFailedFetch(err, path))
          .then((m) => () => {
            // 调用 HMRRUNTIME 的方法更新
            __VUE_HMR_RUNTIME__.reload(path, m.default)
            console.log(`[vite] ${path} reloaded.`)
          })
      )
      break
    case 'vue-rerender':
      const templatePath = `${path}?type=template`
      import(`${templatePath}&t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.rerender(path, m.render)
        console.log(`[vite] ${path} template updated.`)
      })
      break
    case 'style-update':
      // check if this is referenced in html via <link>
      const el = document.querySelector(`link[href*='${path}']`)
      if (el) {
        el.setAttribute(
          'href',
          `${path}${path.includes('?') ? '&' : '?'}t=${timestamp}`
        )
        break
      }
      // imported CSS
      const importQuery = path.includes('?') ? '&import' : '?import'
      await import(`${path}${importQuery}&t=${timestamp}`)
      console.log(`[vite] ${path} updated.`)
      break
    case 'style-remove':
      removeStyle(payload.id)
      break
    case 'js-update':
      queueUpdate(updateModule(path, changeSrcPath, timestamp))
      break
    case 'full-reload':
      // 直接刷新页面
      if (path.endsWith('.html')) {
        // if html file is edited, only reload the page if the browser is
        // currently on that page.
        const pagePath = location.pathname
        if (
          pagePath === path ||
          (pagePath.endsWith('/') && pagePath + 'index.html' === path)
        ) {
          location.reload()
        }
        return
      } else {
        location.reload()
      }
  }
}

Вот что нужно сказать, когда мы просимtype=cssилиcssфайла, вверху будет специальный запрос на импорт:

import { updateStyle } from "/vite/client" // <--- 这里

const css = "\nimg {\n    height: 100px;\n}\n"
// updateStyle 方法用来更新 css 内容,所以对于 css 文件只需要重新请求就可以更新
updateStyle("7ac74a55-0", css)
export default css

а такжеurl:/vite/clientсуществуетserverPluginClientКонфигурация статических файлов выполняется, при возвратеclient/client.jsсодержания, что также обычно называют客户端代码注入

export const clientFilePath = path.resolve(__dirname, '../../client/client.js')

export const clientPlugin: ServerPlugin = ({ app, config }) => {
  const clientCode = fs
    .readFileSync(clientFilePath, 'utf-8')
    ...
  app.use(async (ctx, next) => {
    if (ctx.path === clientPublicPath) {
      ctx.type = 'js'
      ctx.status = 200
      // 返回 client.js 下的内容
      ctx.body = clientCode.replace(`__PORT__`, ctx.port.toString())
    }
    ...
  })
}

рекламировать

Из-за ограничения на количество слов нельзя использовать специальные темы. Пожалуйста, обратите внимание на официальный аккаунт для получения оригинального текста.学前端来唠嗑, чтобы поделиться взглядами на жизнь поколения после 95-х годов