【踩坑日志】vite 模板预处理hmr更新错误

vite:它基于 原生 ES 模块 提供了 丰富的内建功能,如速度快到惊人的 模块热更新(HMR)
SFC单文件组件:代码块可以使用 lang 这个 attribute 来声明预处理器语言

Vue 单文件组件分为三部分:<template><script><style>
使用的语言分别是:HTMLJavaScriptCSS
设置 lang="pug",能够用 pug 更简洁的编写 HTML
例如:

<script setup>const msg = 'Hello world!';</script><template lang="pug">.example {{ msg }}</template><style>.example {  color: red;}</style>
12345678910111213

问题描述

运行 npm run serve 启动开发服务器,然后修改 *.vue 文件内容。
<template lang="pug"> 模板添加预处理器:
可以发现,如果只修改:<script><template> 其中一个代码片段,vite 服务器可以热更新。
但是如果同时修改 <script><template> 代码片段,控制台就会出现以下错误

[Vue warn]: Property "xxx" was accessed during render but is not defined on instance.

而且页面显示异常,热更新失败

通过使用 set DEBUG=vite:transform && npm run dev 启动开发服务器
可以看到更新的顺序:(js文件在vue文件之前)

vite:transform 13.20ms /src/main.ts +0msvite:transform 1.38ms /src/style.css +4msvite:transform 4.53ms /node_modules/.vite/deps/vue.js?v=77658bce +5msvite:transform 11.46ms /src/App.vue +12msvite:transform 133.41ms plugin-vue:export-helper +133msvite:transform 133.89ms /src/App.vue?vue&type=style&index=0&lang.css +0msvite:transform 135.03ms /src/App.vue?vue&type=template&lang.js +2msvite:transform 11.17ms /@vite/client +12msvite:transform 0.33ms /node_modules/vite/dist/client/env.mjs +4ms[vite] hmr update /src/App.vue, /src/App.vue?vue&type=template&lang.jsvite:transform 4.72ms /src/App.vue?vue&type=template&lang.js +17svite:transform 2.01ms /src/App.vue +2msvite:transform 0.73ms /src/App.vue?vue&type=style&index=0&lang.css +1ms

hmr 正常情况:

只修改 <script> 代码片段更新顺序就是正常的

[vite] hmr update /src/App.vuevite:transform 2.56ms /src/App.vue +9svite:transform 4.70ms /src/App.vue?vue&type=style&index=0&lang.css +5msvite:transform 5.12ms /src/App.vue?vue&type=template&lang.js +0ms

lang="pug" 去掉:

<script setup>const msg = 'Hello world!';</script><template lang="pug"><template>.example {{ msg }}</template><style>.example {  color: red;}</style>
123455678910111213

同时修改 <script><template> 代码片段,hmr 正常

[vite] hmr update /src/App.vuevite:transform 0.00ms /src/App.vue?vue&type=template&lang.jsvite:transform 3.26ms /src/App.vue +22svite:transform 0.75ms /src/App.vue?vue&type=style&index=0&lang.css +1ms

可以看到少了一行 &type=template&lang.js<template> 代码块内联到 render 函数里了

hmr 异常情况:

在使用 pug 预处理器的情况下,同时修改 <script><template> 代码片段:

[Vue warn]: Property "xxx" was accessed during render but is not defined on instance.

寻找源头

调试-DEBUG:

通过开发工具的 网络 面板查看 hmr 的 websocket 可以看到顺序是对的

{  "type": "update",  "updates": [    {      "type": "js-update",      "timestamp": 1689409360384,      "path": "/src/App.vue",      "explicitImportRequired": false,      "acceptedPath": "/src/App.vue"    },    {      "type": "js-update",      "timestamp": 1689409360384,      "path": "/src/App.vue?vue&type=template&lang.js",      "explicitImportRequired": false,      "acceptedPath": "/src/App.vue?vue&type=template&lang.js"    }  ]}
12345678910111213141516171819

开发工具查看 http 请求的时间:
net
发现:虽然 &type=template&lang.js 文件在 .vue 文件之后发送请求,但是不会等待 .vue 文件处理完成,而是提前返回了,所以获取不到 <script> 里面声明的变量

通过查看源码,可以发现编译 <template> 代码块时,会去获取 <script> 代码块信息:

@vitejs/plugin-vue/dist/index.mjs
function resolveTemplateCompilerOptions(descriptor, options, ssr) {  const block = descriptor.template;  if (!block) {    return;  }  const resolvedScript = getResolvedScript(descriptor, ssr);  const hasScoped = descriptor.styles.some((s) => s.scoped);  const { id, filename, cssVars } = descriptor;
184185186187188189190191

然后获取代码块的声明,传递给编译器。

  return {    ...options.template,    id,    filename,    scoped: hasScoped,    slotted: descriptor.slotted,    isProd: options.isProduction,    inMap: block.src ? void 0 : block.map,    ssr,    ssrCssVars: cssVars,    transformAssetUrls,    preprocessLang: block.lang,    preprocessOptions,    compilerOptions: {      ...options.template?.compilerOptions,      scopeId: hasScoped ? `data-v-${id}` : void 0,      bindingMetadata: resolvedScript ? resolvedScript.bindings : void 0,      expressionPlugins,      sourceMap: options.sourceMap    }  };}
230231232233234235236237238239240241242243244245246247248249250251

getResolvedScript() 会获取 <script> 代码片段编译的缓存

@vitejs/plugin-vue/dist/index.mjs
function getResolvedScript(descriptor, ssr) {  return (ssr ? ssrCache : clientCache).get(descriptor);}
263264265

编译 .vue 文件 => transformMain => genScriptCode => resolveScript
会设置 <script> 代码片段缓存

@vitejs/plugin-vue/dist/index.mjs
function resolveScript(descriptor, options, ssr) {  if (!descriptor.script && !descriptor.scriptSetup) {    return null;  }  const cacheToUse = ssr ? ssrCache : clientCache;  const cached = cacheToUse.get(descriptor);  if (cached) {    return cached;  }  let resolved = null;  resolved = options.compiler.compileScript(descriptor, {    ...options.script,    id: descriptor.id,    isProd: options.isProduction,    inlineTemplate: isUseInlineTemplate(descriptor, !options.devServer),    reactivityTransform: options.reactivityTransform !== false,    templateOptions: resolveTemplateCompilerOptions(descriptor, options, ssr),    sourceMap: options.sourceMap,    genDefaultAs: canInlineMain(descriptor, options) ? scriptIdentifier : void 0  });  if (!options.isProduction && resolved?.deps) {    for (const [key, sfcs] of typeDepToSFCMap) {      if (sfcs.has(descriptor.filename) && !resolved.deps.includes(key)) {        sfcs.delete(descriptor.filename);      }    }    for (const dep of resolved.deps) {      const existingSet = typeDepToSFCMap.get(dep);      if (!existingSet) {        typeDepToSFCMap.set(dep, /* @__PURE__ */ new Set([descriptor.filename]));      } else {        existingSet.add(descriptor.filename);      }    }  }  cacheToUse.set(descriptor, resolved);  return resolved;}
273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310

按照上面的逻辑进行分析:

  1. 文件改变后,浏览器同时获取 .vue 文件与 &type=template&lang.js 文件。
  2. vite 服务器先收到 .vue 文件请求,开始编译 .vue 文件。
  3. 还没编译完成 .vue 文件,又收到 &type=template&lang.js 文件请求。
  4. 编译 &type=template&lang.js 文件获取不到 .vue 文件编译的 <script> 代码片段缓存。
  5. 得不到 bindingMetadata,所以模板渲染无法绑定对应的变量,请求完成。
  6. 浏览器渲染模板,模板变量访问失败

解决问题

通过搜索 issues 发现 #76,找到了一个 pull#106
辛苦调试半天原来有人解决了/(ㄒoㄒ)/~~,google大法不如直接搜github…
这个 pull 还没有合并发布新版本,只能自己改源码了:

@vitejs/plugin-vue/dist/index.mjs
function compile(code, descriptor, options, pluginContext, ssr) {  const filename = descriptor.filename;  resolveScript(descriptor, options, ssr);  const result = options.compiler.compileTemplate({    ...resolveTemplateCompilerOptions(descriptor, options, ssr),    source: code  });
161162163164165166167

重新运行 set DEBUG=vite:transform && npm run dev
同时修改 <template><script> 代码片段后:

[vite] hmr update /src/App.vue, /src/App.vue?vue&type=template&lang.jsvite:transform 5.56ms /src/App.vue?vue&type=template&lang.js +4svite:transform 1.27ms /src/App.vue +2msvite:transform 0.58ms /src/App.vue?vue&type=style&index=0&lang.css +1ms

虽然顺序没变,还是先完成 &type=template&lang.js 请求。
但是在编译模板时会先编译 <script> 代码片段,hmr 热更新正常。

增加本地包:

因为 pull#106 请求还没有正式合并发布,
所以修改源代码,避免被包管理重新覆盖需要增加本地包:
复制 node_modules 下面的 @vitejs/plugin-vue 文件夹,然后加入补丁
再修改 package.json 文件(文件路径为 file:./ 协议):

package.json
{  "name": "hmrtest",  "private": true,  "version": "0.0.0",  "type": "module",  "scripts": {    "dev": "vite",    "build": "vue-tsc && vite build",    "preview": "vite preview"  },  "dependencies": {    "pug": "^3.0.2",    "vue": "^3.3.4"  },  "devDependencies": {    "@vitejs/plugin-vue": "^4.2.3",    "@vitejs/plugin-vue": "file:./packages/plugin-vue",    "typescript": "^5.0.2",    "vite": "^4.4.0",    "vue-tsc": "^1.8.3"  }}
12345678910111213141516161718192021

运行 npm install,重新测试,hmr 正常