越努力越幸运

努力多一点,遗憾就会少一点

nodejs 可以使用两种方式加载模块,分别是 CommonJS(cjs)ECMAScript(esm) 模块。
本文将分别介绍如何通过清除缓存进行热更新

CommonJS 模块热更新

CommonJS 使用 require 导入模块,使用 module.exports 导出模块。

默认情况:

模块只有在第一次导入时才会加载文件,再次导入只能获取到之前的缓存
例如:

index.js
require("./log.js"); // 控制台打印信息require("./log.js"); // 没有打印信息
12
log.js
console.log("导入模块");
1

可以看到,第二次导入没有重新加载模块,而是直接读取缓存

删除缓存:

CommonJS 使用 require.cache 保存缓存,所以只需要删除对应模块的缓存就能重新加载。

const path = require("path"); require("./log.js"); // 控制台打印信息delete require.cache[path.resolve("./log.js")]; require("./log.js"); // 再次打印信息
1234

注意:缓存使用绝对路径

ECMAScript 模块热更新

ECMAScript 使用 import 导入模块,使用 export 导出模块。
要让 nodejs 启用 esm 模块加载器,需要在 package.json 加上 "type": "module"

默认情况:

模块只有在第一次导入时才会加载文件,再次导入只能获取到之前的缓存
例如:

index.js
import "./log.js"; // 控制台打印信息await import("./log.js"); // 没有打印信息
12
log.js
console.log("导入模块");
1

删除缓存:

esm 模块的缓存默认不暴露出来,需要在启动 node 时,使用 --expose-internals 标志。
注意:请使用最新版本node

参考信息:#49442

import path from "node:path"; import url from "node:url"; import module from "internal/process/esm_loader"; import "./log.js"; // 控制台打印信息const file = url.pathToFileURL(path.resolve("./log.js")).href; module.esmLoader.loadCache.delete(file); await import("./log.js"); // 再次打印信息
1234567

注意:缓存使用 file:/// 路径

实际应用

在开发 nodejs 应用的时候,为了方便可能会使用 nodemon 监听文件改变,然后重启应用。
可是这样每次修改文件都是完全重启,效率实在太低。
所以使用模块热更新可以更精准的达到马上生效的效果。

CommonJS 热更新接口

服务器接口为 koa,通过 chokidar 监听文件改变,然后热更新接口。

utils.js
const chokidar = require("chokidar");const path = require("path");const tree = new Map();const loadCache = require.cache;exports.watch = function watch(dir, callback) {  const cwd = path.resolve(dir);  const root = path.resolve(cwd, "index.js");  chokidar    .watch(["**/*.js"], { cwd, ignoreInitial: true })    .on("all", (_type, filename) => {      delCache(path.resolve(cwd, filename));      try {        callback();      } catch {}      buildTree(root);    });  buildTree(root);};function buildTree(path) {  tree.set(path, [path]);  const module = loadCache[path];  if (module) {    (function read(current) {      for (const child of current.children) {        if (!/node_modules/.test(child.filename)) {          let parents = tree.get(child.filename);          if (!parents) tree.set(child.filename, (parents = []));          parents.push(current.filename);          if (child.children) read(child);        }      }    })(module);  }}function delCache(path) {  const parents = tree.get(path);  if (parents) {    tree.delete(path);    delete loadCache[path];    console.log("删除缓存", path);    if (parents[0] != path) {      for (const path of parents) {        delCache(path);      }    }  }}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849

监听 api 文件夹,每次修改都会重新导入所有 api 接口:

index.js
const http = require("http");const { watch } = require("./utils.js");let api = require("./api/index.js");http  .createServer((req, res) => {    api(req, res);  })  .listen(80);watch("./api", () => {  api = require("./api/index.js");});
12345678910111213

在 api 文件夹下面包含多个路由文件:

├─index.js├─package.json├─utils.js├─api|  ├─a.js|  ├─b.js|  ├─index.js|  └─router.js
api/index.js
const Koa = require("koa");const Router = require("koa-router");const app = new Koa();const desktop = new Router();const router = require("./router.js");// app.use(...middleware);for (const key in router) {  desktop.use("/" + key, router[key].routes(), router[key].allowedMethods());}app.use(desktop.routes());app.use(desktop.allowedMethods());module.exports = app.callback();
12345678910111213141516

路由文件:

api/router.js
exports.a = require("./a.js");exports.b = require("./b.js");
12
api/a.js
const Router = require("koa-router");const router = new Router();router.get("/", (ctx) => {  ctx.body = "a.js";});module.exports = router;
12345678
api/b.js
const Router = require("koa-router");const router = new Router();router.get("/", (ctx) => {  ctx.body = "b.js";});module.exports = router;
12345678

修改 api 文件夹下面的文件,就会让 index.jsapi 变量更新。
从而达到了热更新的目的。

ECMAScript 热更新接口

esm 模块的缓存默认不暴露出来,需要在启动 node 时,使用 --expose-internals 标志。
注意:请使用最新版本node,需要在 package.json 加上 "type": "module"
跟 CommonJS 的差异:
require.cache 相当于 esmLoader.loadCache,类型从 Object 变成 Map
module.filename 相当于 module.url,id 从 绝对路径 变成 file:/// 路径
module.children 相当于 module.linked,类型从 Array 变成 Promise<ArrayLike>

utils.js
import chokidar from "chokidar";import path from "node:path";import url from "node:url";import module from "internal/process/esm_loader";const tree = new Map();const loadCache = module.esmLoader.loadCache;export function watch(dir, callback) {  const cwd = path.resolve(dir);  const root = url.pathToFileURL(path.resolve(cwd, "index.js")).href;  chokidar    .watch(["**/*.js"], { cwd, ignoreInitial: true })    .on("all", async (_type, filename) => {      delCache(url.pathToFileURL(path.resolve(cwd, filename)).href);      try {        await callback();      } catch {}      buildTree(root);    });  buildTree(root);}function buildTree(path) {  tree.set(path, [path]);  const module = loadCache.get(path);  if (module) {    (async function read(current) {      const children = await current.linked;      for (let i = 0; i < children.length; i++) {        if (!/node_modules/.test(children[i].url)) {          let parents = tree.get(children[i].url);          if (!parents) tree.set(children[i].url, (parents = []));          parents.push(current.url);          read(children[i]);        }      }    })(module);  }}function delCache(path) {  const parents = tree.get(path);  if (parents) {    tree.delete(path);    loadCache.delete(path);    console.log("删除缓存", path);    if (parents[0] != path) {      for (const path of parents) {        delCache(path);      }    }  }}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152

监听 api 文件夹,每次修改都会重新导入所有 api 接口:

index.js
import http from "node:http";import { watch } from "./utils.js";import { app } from "./api/index.js";let api = app.callback();http  .createServer((req, res) => {    api(req, res);  })  .listen(80);watch("./api", async () => {  const { app } = await import("./api/index.js");  api = app.callback();});
123456789101112131415

在 api 文件夹下面包含多个路由文件:

├─index.js├─package.json├─utils.js├─api|  ├─a.js|  ├─b.js|  ├─index.js|  └─router.js
api/index.js
import Koa from "koa";import Router from "koa-router";import * as router from "./router.js";export const app = new Koa();const desktop = new Router();// app.use(...middleware);for (const key in router) {  desktop.use("/" + key, router[key].routes(), router[key].allowedMethods());}app.use(desktop.routes());app.use(desktop.allowedMethods());
1234567891011121314

路由文件:

api/router.js
export * from "./a.js";export * from "./b.js";
12
api/a.js
import Router from "koa-router";export const a = new Router();a.get("/", (ctx) => {  ctx.body = "a.js";});
123456
api/b.js
import Router from "koa-router";export const b = new Router();b.get("/", (ctx) => {  ctx.body = "b.js";});
123456

修改 api 文件夹下面的文件,就会让 index.jsapi 变量更新。
从而达到了热更新的目的。

参考:
Hot Module Replacement for Node.js
Invalidate cache when using import #49442

Electron 可以通过 webContents.openDevTools() 打开开发者工具
有时候通过元素面板右键 存储为全局变量 进行调试,
忽然有一天发现右键菜单不见了😨

寻找源头

搜索 issues 发现 #38589,原来是 electron 依赖的 chromium,默认布局改变了
devtools
出现在electron的 v25.0.0 版本

#38790
codebytere commented 6/15 18:24
Tracked to v25.0.0-nightly.20230323...v25.0.0-nightly.20230324

解决问题

布局错误导致菜单渲染在窗口的下方
解决办法是在 main.js禁用 WidgetLayering

main.js
import { app } from 'electron';app.commandLine.appendSwitch('disable-features', 'WidgetLayering');

#38790
codebytere commented 6/15 20:01
Looks like it's this: https://chromium-review.googlesource.com/c/chromium/src/+/4356117
For now, this can be worked around by adding:

app.commandLine.appendSwitch('disable-features', 'WidgetLayering');

to your code.

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 正常

defaultLocale
Replace default locale, you can find locale list here.

已经查看过类似问题 #151

问题描述

vite.config.js
import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'export default defineConfig({  plugins: [vue(), ElementPlus({    defaultLocale: 'zh-cn'  })],})
12345678

以上配置无法替换默认语言,还必须加上以下导入:

main.js
import { createApp } from 'vue'import './style.css'import App from './App.vue'import 'element-plus/es/hooks/use-locale/index'createApp(App).mount('#app')
123456

通过查看代码才发现的😨

unplugin-element-plus/src/core/default-locale.ts
export const getLocaleRE = (options: Options) =>  new RegExp(    `${escapeStringRegexp(`${options.lib}/`)}(es|lib)${escapeStringRegexp(      '/hooks/use-locale/index'    )}`  )
5678910

复现步骤

  1. 通过模板创建 vite 项目npm create vite test -- --template vue
  2. 安装依赖cd test && npm install element-plus unplugin-element-plus
  3. 修改文件

vite.config.js 添加插件

vite.config.js
import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'import ElementPlus from 'unplugin-element-plus/vite'// https://vitejs.dev/config/export default defineConfig({  plugins: [vue()],  plugins: [vue(), ElementPlus({ defaultLocale: 'zh-cn' })],})
123456778

src/App.vue 使用组件 el-popconfirm

App.vue
<script setup>import { ElPopconfirm } from 'element-plus'</script><template>  <el-popconfirm>    <template #reference>      <button>click</button>    </template>  </el-popconfirm></template>
1234567891011
  1. 启动开发服务器npm run dev
  2. 点击按钮发现是 No / Yes,预期是 取消 / 确定
    el-popconfirm
  3. 修改文件

src/main.js 添加导入 hooks/use-locale/index

main.js
import { createApp } from 'vue'import './style.css'import App from './App.vue'import 'element-plus/es/hooks/use-locale/index'createApp(App).mount('#app')
123456
  1. 回到页面,点击按钮发现是 取消 / 确定

✌️✌️️️️️️️️️️️️️️️✌️️️️️️️️️️️️️️️️️️️️️️️️️️️️️
️️️️️️
README 描述太少,定位问题浪费了时间😢

0%