阅读视图

发现新文章,点击刷新页面。

前端工程化实践:打包工具的选择与思考

从静态页面到模块化开发,前端工程化经历了怎样的演进?Webpack、Vite、Rspack 这些打包工具各自解决了什么问题,在实际项目中又该如何选择?

前端工程化的出现与发展

前端工程化的发展经历了几个重要阶段。在 1990 年代到 2000 年代初期的静态页面时代,开发者主要使用 Photoshop 切图和 Dreamweaver 等工具制作页面,代码通常直接写在 HTML 中,大量使用内联样式和事件处理器,所有逻辑集中在一个文件中,导致代码组织混乱、全局变量污染严重、维护困难。到了 2000 年代中期到 2010 年代初期,jQuery 等类库的出现解决了浏览器兼容性问题,简化了 DOM 操作,但代码组织问题依然存在,文件体积持续增长,模块间的依赖关系仍需人工管理。2010 年代初期进入模块化探索阶段,出现了多种模块化方案:CommonJS 主要用于 Node.js 环境,AMD 专注于浏览器端的异步模块加载,CMD 强调按需加载,但这些方案标准不统一,工具链不兼容,给项目协作带来困难。2010 年代中期至今,随着 ES6 模块标准的确定,import / export 语法成为主流,Webpack 等打包工具统一了模块化处理流程,Vite 等新一代工具利用原生 ESM 提升了开发体验,同时 Babel 提供了语法转换能力,TypeScript 提供了类型系统支持,ESLint 和 Prettier 规范了代码风格,前端工程化体系逐步完善,前端开发进入了工具链驱动的时代。

前端工程化的出现主要源于项目规模扩大、团队协作需求增加、业务复杂度提升以及人工管理代码的局限性。首先,传统开发方式中代码组织混乱,所有脚本文件需要在 HTML 中按正确顺序引入,依赖关系必须人工维护,删除或调整文件顺序容易导致错误。工程化后通过 ES Modules 等模块化方案,依赖关系由工具自动管理,开发者只需关注业务逻辑。其次,浏览器兼容性问题突出,使用 Promiseasync/await 等新特性时,旧版本浏览器无法识别,需要通过 Babel 将高级语法转换为兼容的 ES5 代码,并通过 polyfill 为旧浏览器补充缺失的 API 支持。第三,开发效率低下,传统方式需要手动刷新页面、重复操作才能看到修改效果,构建过程需要手动进行代码压缩、合并、添加文件哈希等操作,工程化后通过热模块替换实现代码修改后自动更新,通过构建工具自动化完成压缩、文件指纹、代码分包等流程。第四,性能优化缺乏系统性方案,未打包的项目会产生大量 HTTP 请求,未进行代码分割会导致用户需要加载全部代码才能使用部分功能,工程化后通过代码压缩、Tree-shaking 消除无用代码、按需加载、资源预加载等技术手段系统性地优化性能。第五,团队协作缺乏统一规范,代码风格不统一、类型使用不规范、注释缺失等问题影响代码质量和可维护性,工程化后通过 ESLint 进行代码质量检查,通过 Prettier 统一代码格式,通过 TypeScript 在编译期进行类型检查,从而提升代码质量和团队协作效率。

前端工程化与打包工具

打包工具的核心作用

在前端工程化的工具链中,打包工具承担着核心的构建职责。它的主要功能包括模块依赖解析、代码转换、资源优化以及开发体验提升。

模块依赖解析

项目开发中会使用大量的 importrequire 语句来组织代码,打包工具从入口文件开始,递归分析模块间的依赖关系,构建完整的依赖图,最终将分散的模块文件合并成浏览器可以直接加载的 JavaScript、CSS 等资源文件。这个过程解决了传统开发中需要手动维护脚本加载顺序的问题。

代码转换与预处理

现代前端开发中广泛使用的 TypeScript、JSX、Vue 单文件组件、Less/SCSS 等语法和格式,浏览器本身并不支持。打包工具通过配置相应的 Loader 或 Plugin,将这些代码转换为浏览器能够理解的 JavaScript 和 CSS。例如,Babel 可以将 ES6+ 语法转换为 ES5,TypeScript 编译器可以将 TypeScript 代码转换为 JavaScript。

构建优化

打包工具在构建过程中会自动执行多种优化操作。代码压缩可以减小文件体积,Tree-shaking 可以移除未使用的代码,代码分割可以将大型应用拆分为多个按需加载的代码块,文件指纹 hash 可以实现长期缓存,资源压缩可以优化图片、字体等静态资源。这些优化如果手动完成,不仅工作量大,而且容易出错。

开发体验

打包工具通常还提供开发服务器功能,支持 HMR,当代码修改后可以自动更新页面,无需手动刷新。同时提供清晰的错误提示和 Source Map 支持,方便开发者调试代码。

目前主流的打包工具包括 Webpack、Vite、Rollup、Rspack 等,它们在解决上述问题的同时,由于设计理念和时代背景的不同,在实现方式和性能表现上各有特点。

主流打包工具全景

Webpack

Webpack 是前端打包工具领域的成熟方案,自 2012 年发布以来,在前端工程化中占据重要地位。它支持多种模块格式,包括 CommonJS、AMD 和 ES Modules,能够处理各种类型的静态资源,如样式文件、图片、字体等,只要编写相应的 Loader,几乎可以将任何资源类型纳入打包流程。Webpack 的插件生态非常丰富,涵盖了 HTML 模板生成、体积分析、代码压缩混淆、国际化等各个方面,为开发者提供了大量现成的解决方案。

Webpack 的构建流程可以概括为:读取配置文件,确定入口文件,递归分析模块依赖关系,通过 Loader 对各类资源进行转换处理,将处理后的模块组织成 Chunk,最终输出优化后的资源文件。在整个构建过程中,Webpack 会触发各种生命周期钩子,Plugin 通过监听这些钩子来扩展功能,实现自定义的构建逻辑。

Webpack 适合在以下场景使用:项目规模较大、结构复杂,需要对打包过程进行精细控制;已有项目基于 Webpack 构建,迁移成本较高;需要处理多种特殊资源类型,对丰富的插件生态有较强依赖。

Webpack 的不足之处在于:配置文件较为复杂,学习曲线较陡,新成员上手需要一定时间;开发环境需要先进行打包,冷启动速度相对较慢;对于大型项目,如果不进行针对性优化,构建时间可能较长。

Vite

Vite 的设计目标是提供极速的开发体验,实现开发服务器的快速启动和代码修改的即时反馈。Vite 的核心思路与 Webpack 不同:在开发环境中,Vite 不进行全量打包,而是启动一个轻量级的开发服务器,当浏览器请求某个模块时,Vite 才对该模块进行实时编译和转换。对于第三方依赖,Vite 使用 esbuild 进行预构建,将 CommonJS 或 UMD 格式的依赖转换为 ESM 格式,通常缓存在 node_modules/.vite/deps 目录下,后续直接预构建缓存的结果,大幅提升开发启动速度。在生产环境中,Vite 使用 Rollup 进行构建(Vite 5 及之后版本也可以选择基于 Rust 的 Rolldown),执行 Tree-shaking、代码分割、压缩等优化操作,生成生产环境所需的资源文件。

Vite 的开发体验优势明显:新项目配置简单,几乎可以开箱即用;开发服务器启动速度快,HMR 更新迅速;对 Vue、React 等主流框架提供了良好的内置支持。Vite 的局限性在于:对旧版本浏览器的支持需要额外的插件和配置;对于已经深度定制 Webpack 的复杂项目,迁移到 Vite 的成本可能较高。

Rspack

Rspack 是由字节跳动开发的基于 Rust 实现的打包工具,于 2023 年发布。Rspack 与 Webpack 保持高度兼容,API 兼容度达到 95% 以上,这意味着大多数 Webpack 配置可以直接迁移到 Rspack。在官方基准测试和实际项目实践中,Rspack 的构建速度通常比同等配置的 Webpack 快 5 到 10 倍。

Rspack 的核心实现使用 Rust 重写了 Webpack 的核心逻辑,同时保持了 Webpack 的配置方式和插件生态系统。Rspack 内置了 SWC 编译器和 Lightning CSS,无需额外配置 Babel 即可处理 TypeScript、JSX 等语法,在性能上相比传统方案有显著提升。

Rspack 与 Webpack 高度兼容,迁移成本较低,现有 Webpack 项目可以相对平滑地切换到 Rspack;构建速度快,中大型项目的冷启动时间通常在 1 到 3 秒之间;支持大部分 Webpack Loader 和 Plugin,生态兼容性好;内置 SWC 编译器,无需额外配置 Babel;提供文件系统缓存机制,二次构建速度更快。

测试项目打包速度对比结果:同一套项目代码(大概 400 个小文件) + 配置;webpack 采用了 babel,rspack 采用了内置的 SWC(仅是测试代码测试,仅做参考) compare

打包原理浅析

Webpack 打包原理速通

整体架构

Webpack 的核心是一个模块打包器,它将项目中的所有资源(JavaScript、CSS、图片等)视为模块,通过构建依赖关系图将它们组织起来。Webpack 的架构基于几个核心概念:Compiler 是编译器实例,负责整个编译过程的生命周期管理;Compilation 代表单次编译过程,包含模块、chunk、资源等编译信息;Module 是模块,可以是 JavaScript、CSS、图片等任何类型的文件;Chunk 是代码块,由多个模块按照一定规则组织而成;Asset 是资源文件,即最终输出的文件。

构建流程(生产环境)

Webpack 的构建流程可以分为初始化、编译、输出和完成四个主要阶段。

在初始化阶段,Webpack 读取配置文件(webpack.config.js),创建 Compiler 实例,然后注册所有配置的插件。插件通过 apply 方法注册到 Compiler 的钩子上,以便在构建的不同阶段执行自定义逻辑。

const compiler = webpack(config);
plugins.forEach((plugin) => plugin.apply(compiler));

编译阶段是 Webpack 的核心处理过程。首先确定入口文件,从配置的 entry 开始,找到所有入口文件。然后从入口文件开始,递归解析所有依赖关系,使用 AST 解析 import 和 require 语句,构建完整的模块依赖图。接下来对每个模块执行对应的 Loader 进行转换,例如使用 babel-loader 处理 JavaScript 文件,使用 css-loader 和 style-loader 处理 CSS 文件。最后根据入口和代码分割规则,将模块组织成 Chunk。

entry: {
  main'./src/index.js',
  vendor: './src/vendor.js'
}

module: {
  rules: [
    { test: /.js$/, use: 'babel-loader' },
    { test: /.css$/, use: ['style-loader''css-loader'] }
  ]
}

在输出阶段,Webpack 首先生成运行时代码,注入模块加载的运行时函数(如 __webpack_require__)。然后执行代码分割和优化,通过 SplitChunksPlugin 根据配置规则将代码分割成多个 chunk,例如将 node_modules 中的第三方库单独打包。最后将 Chunk 转换为最终的 JavaScript 文件,执行压缩、混淆等优化操作。

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendors',
        priority: 10
      }
    }
  }
}

最后,根据 output 配置将处理后的文件写入文件系统,生成最终的构建产物。

output: {
  path: path.resolve(__dirname, 'dist'),
  filename: '[name].[contenthash].js'
}

开发环境(webpack-dev-server)

开发环境与生产环境的主要区别在于:开发环境采用内存编译,文件不写入磁盘,而是保存在内存中,这样可以提升编译速度;支持热模块替换(HMR),只更新变更的模块,无需刷新整个页面;生成 Source Map 便于调试,可以定位到原始源码位置;文件变更后能够快速重新编译,提供即时的反馈。

HMR 的工作流程如下:当启动 webpack-dev-server 时,会在页面中注入 HMR 客户端脚本,然后建立 WebSocket 连接,实现客户端与开发服务器之间的双向通信。当开发者修改源码文件时,Webpack 会监听到文件变更事件,然后重新编译受影响的模块,生成新的 hash 值和热更新清单文件(如 main.[hash].hot-update.json)。服务器通过 WebSocket 将新的 hash 值通知给客户端,客户端收到通知后,会对比本地的 lastHash 和服务端返回的 hash 值。如果检测到有更新,客户端会发起请求获取热更新清单文件和更新后的模块代码(hot-update.jsonhot-update.js),HMR runtime 会加载并执行更新后的模块,然后调用 module.hot.accept 等回调函数,只替换发生变更的模块,尽量保持页面状态不变。如果遇到异常情况或模块不支持 HMR,系统会回退到整页刷新的方式,确保功能正常。

test

test

模块解析机制

Webpack 的模块解析机制包括路径解析、别名处理和 node_modules 查找。当遇到相对路径导入时,Webpack 会根据 resolve.extensions 配置尝试不同的文件扩展名,例如 ./utils 会依次尝试 ./utils.js./utils.json./utils/index.js 等。对于使用别名的导入,Webpack 根据 resolve.alias 配置将别名解析为实际路径,例如 @/components/Component 会被解析为配置的实际路径。对于第三方模块,Webpack 会从 node_modules 目录中查找对应的包。

Vite 打包原理速通

整体架构

Vite 采用双模式架构,在开发环境和生产环境中采用不同的策略。开发环境基于浏览器原生 ESM,无需进行打包操作,而是通过 HTTP 服务器按需编译模块。生产环境则回归传统打包思路,使用 Rollup 进行代码打包和优化。这种设计使得开发体验和生产构建各取所长,既保证了开发时的极速启动,又确保了生产环境的代码优化。

开发环境原理

Vite 开发环境的核心思想是 No Bundle,即不进行预先打包。当执行 vite dev 命令时,Vite 首先启动一个 HTTP 服务器,默认监听 5173 端口。这个服务器会拦截浏览器的模块请求,并根据请求的模块类型进行实时编译和返回。整个过程是动态的,只有在浏览器真正需要某个模块时,Vite 才会对其进行编译处理。

在第一次启动时,Vite 会执行依赖预构建流程。它会扫描项目的 package.json 文件,识别出所有的第三方依赖,然后使用 esbuild 对这些依赖进行预构建处理。预构建的结果会被缓存到 node_modules/.vite/deps 目录中,后续启动时可以直接使用缓存,大幅提升启动速度。预构建的主要目的包括三个方面:首先是将 CommonJS 或 UMD 格式的依赖转换为浏览器可识别的 ESM 格式,因为浏览器原生不支持 CommonJS;其次是合并多个小文件,减少 HTTP 请求数量,例如 lodash 这样的库包含数百个文件,预构建可以将其合并为单个文件;最后是优化依赖结构,扁平化某些包内部的复杂路径引用。

当浏览器发起模块请求时,例如请求 http://localhost:5173/src/main.js,Vite 服务器会首先检查该模块是否是已经预构建的第三方依赖。如果是预构建的依赖,直接返回缓存的结果。如果是项目源码文件,Vite 会进行实时编译,将文件转换为浏览器可执行的 ESM 代码并返回。以 Vue 单文件组件为例,当浏览器请求 .vue 文件时,Vite 会实时编译 Vue SFC,将其转换为 JavaScript 代码返回给浏览器。

Vite 的热模块替换机制同样基于 ESM 实现。当文件发生变更时,Vite 能够精确地知道哪些模块需要更新,因为每个模块都是独立的 ESM 模块。Vite 通过 WebSocket 连接通知客户端哪些模块发生了变化,客户端接收到通知后,可以直接替换对应的 ESM 模块,利用浏览器原生的模块系统能力,无需重新加载整个页面。这种基于 ESM 的 HMR 机制相比传统的打包工具更加精确和高效。

生产环境原理

在生产环境中,Vite 会执行完整的打包流程。默认情况下,Vite 使用 Rollup 作为打包核心引擎。在 Vite 5 及之后的版本中,可以在部分配置或实验特性下切换为 Rolldown,但整体构建阶段和思路与 Rollup 基本一致。构建流程首先从入口文件开始,构建完整的依赖图,分析所有模块之间的依赖关系,确定需要打包的模块范围。随后使用 esbuild 对依赖进行预构建,这一步在官方基准测试中通常能比传统方式快一个数量级,具体提升幅度在 10 到 100 倍之间,实际效果取决于项目规模和配置。预构建完成后,Rollup 会执行代码分析、转换、优化和打包等步骤,最终输出优化后的生产代码。

预构建

预构建是 Vite 架构中的关键环节,其必要性主要体现在三个方面。首先是兼容性问题,许多 npm 包仍然采用 CommonJS 格式发布,而浏览器原生不支持 CommonJS 模块系统,因此需要将这些依赖转换为浏览器可识别的 ESM 格式。其次是性能问题,某些大型库如 lodash 包含数百个独立的文件,如果直接使用会产生大量的 HTTP 请求,严重影响加载性能,预构建可以将这些文件合并为单个文件,大幅减少请求数量。最后是路径问题,某些包内部使用复杂的相对路径引用,预构建可以扁平化这种依赖结构,简化模块解析过程。

开发 vs 生产对比

开发环境和生产环境在多个维度上存在显著差异。在打包方式上,开发环境采用不打包、按需编译的策略,而生产环境则执行完整的 Rollup 打包流程。启动速度方面,开发环境通常能在 1 秒内完成启动,而生产环境的构建时间通常在 5 到 10 秒之间。编译方式上,开发环境是实时编译,只有被请求的模块才会被编译,生产环境则是全量编译,所有模块都会被处理。输出格式上,开发环境统一输出 ESM 格式,生产环境则可以根据配置输出多种格式。代码优化方面,开发环境不进行代码优化以保持编译速度,生产环境则会执行压缩、混淆、tree-shaking 等优化操作。Source Map 的生成策略也不同,开发环境采用快速生成策略,生产环境则生成完整的 Source Map 以便调试。

核心差异对比

性能差异

这里可以先按一个中等偏上的单页应用来想象一下,大概的时间量级会是下面这样(只是经验值,不是严谨 benchmark,具体还是要看你项目的体量和机器性能):

场景 Webpack Vite 原因
冷启动 10 ~ 30s < 1s Vite 不打包,按需编译
HMR 1 ~ 3s < 100ms Vite 基于 ESM,更精确
生产构建 30 ~ 60s 10 ~ 20s Vite 使用 esbuild 预构建

深度对比分析

性能对比

从开发者最关心的几个维度来看,三个工具在性能表现上存在明显差异。需要说明的是,以下对比更多是基于日常项目实践的经验总结,而非严谨的基准测试,具体表现会因项目规模和机器性能而有所不同。

在开发启动速度方面,Webpack 在大型项目中首次启动时需要进行完整的打包过程,开发者能够明显感受到冷启动的等待时间,十几秒甚至几十秒的情况很常见。Vite 则基本实现秒开,启动瓶颈更多在于浏览器打开速度,而非打包过程本身。Rspack 相比 Webpack 有明显提升,特别是在多入口和大体积项目中,启动速度的提升更为显著。

在热模块替换的反馈速度上,Webpack 在处理简单样式修改时表现尚可,但在进行大型组件改动时,通常需要等待 1 到 2 秒才能看到更新效果。Vite 在大部分场景下能够实现保存即刷新,几乎感觉不到延迟。Rspack 配合开发服务器使用时,HMR 体验也比传统 Webpack 更加流畅。

在生产构建时间方面,Webpack 在配置合理的情况下,中型项目的构建时间通常在几十秒,对于超大项目则需要重点进行代码拆分和缓存优化。Vite 使用 Rollup 进行生产构建时,在中小型项目上通常优于 Webpack,随着项目规模增大,这种优势会更加明显。Rspack 在官方对比和社区实践中,普遍比等价配置的 Webpack 快一截,特别适合那些希望保持 Webpack 配置方式但需要提升构建速度的场景。

配置复杂度

Webpack 的配置相对复杂,需要开发者理解一整套概念体系,包括入口配置 entry、输出配置 output、模块处理规则 module.rules、各种 loader、插件系统 plugin、优化配置 optimization、路径解析 resolve 等。一个典型的 Webpack 配置文件需要明确指定入口文件路径、输出目录和文件名模式、各种文件类型的处理规则、使用的 loader 和插件、代码拆分策略等。这种配置方式虽然灵活,但也意味着学习曲线较为陡峭,新手需要花费一定时间才能掌握。

相比之下,Vite 的配置要简洁得多。Vite 的默认配置已经能够运行绝大多数单页应用,开发者通常只需要添加框架插件、配置路径别名、按需调整一些构建选项即可。一个典型的 Vite 配置文件可能只需要几行代码,指定使用的框架插件和少量构建选项。这种设计大大降低了配置门槛,使得开发者能够快速上手,将更多精力投入到业务开发中。

生态支持

在生态支持方面,三个工具呈现出不同的成熟度。Webpack 作为最早发布的工具,自 2012 年发布以来已经积累了最完善的生态系统,拥有最多的插件和 loader,社区支持也是最好的。Vite 虽然发布时间较晚,自 2020 年发布以来生态成熟度已经达到较高水平,社区支持快速增长,插件数量也相当丰富。Rspack 作为最新的工具,2023 年才发布,生态成熟度相对较低,但社区支持也在快速增长,插件数量也在逐步增加。总体而言,Webpack 拥有最成熟的生态,Vite 的生态已经相当完善,Rspack 作为新工具正在快速发展中。

总结

前端打包工具的选择没有标准答案,需要根据项目特点、团队情况、技术栈等因素综合考虑:

未来展望

前端工程化工具正在向更快、更简单、更智能的方向发展:

  • 性能:Rust/Go 实现的工具将成为主流
  • 兼容性:新工具会保持与现有生态的兼容
  • 开发体验:零配置、智能提示、更好的错误信息
  • 标准化:ESM 成为标准,工具链统一

参考资源:

使用 pnpm + Workspaces 构建 Monorepo 的完整指南

一、核心概念:pnpm Workspace

pnpm 内置了对 Monorepo(单一代码仓库)的原生支持,通过 Workspace(工作区) 机制实现。Workspace 允许你在一个仓库中管理多个相互关联但独立的项目(包),并智能地处理它们之间的依赖关系。

二、将普通仓库转变为 Monorepo 的步骤

步骤 1:初始化项目结构

# 创建项目根目录
mkdir my-monorepo
cd my-monorepo

# 初始化根目录 package.json
pnpm init

步骤 2:配置根目录 package.json

修改根目录的 package.json,关键配置如下:

{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,  // 必须设置为 true,避免误发布到 npm
  "scripts": {
    "dev": "pnpm -r run dev",      // -r 表示递归执行所有子包
    "build": "pnpm -r run build",
    "test": "pnpm -r run test"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

重要说明

  • "private": true是必须的,确保整个 Monorepo 不会被意外发布
  • 可以移除 maintest等字段,因为根目录通常不包含业务代码

步骤 3:创建 pnpm-workspace.yaml 配置文件

在根目录创建 pnpm-workspace.yaml文件,这是 pnpm Workspace 的核心配置文件:

# pnpm-workspace.yaml
packages:
  # packages 目录下的所有直接子目录
  - 'packages/*'
  
  # apps 目录下的所有直接子目录
  - 'apps/*'
  
  # components 目录下的所有层级子目录
  - 'components/**'
  
  # 排除包含 test 的目录
  - '!**/test/**'

配置说明

  • packages/*:匹配 packages目录下的所有一级子目录
  • apps/*:匹配 apps目录下的所有一级子目录
  • components/**:匹配 components目录下的所有层级子目录
  • !**/test/**:排除所有包含 test的目录

pnpm 中的两种配置方式

方式一:使用 package.jsonworkspaces字段

// package.json
{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ],
  "scripts": {
    "dev": "pnpm -r run dev"
  }
}

方式二:使用 pnpm-workspace.yaml文件

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - '!**/test/**'  # 排除包含 test 的目录

两种方式的优先级与兼容性

  1. pnpm 的读取顺序

    • 优先读取 pnpm-workspace.yaml
    • 如果不存在,则读取 package.json中的 workspaces字段
    • 如果两者都存在,pnpm-workspace.yaml优先级更高
  2. 推荐使用 pnpm-workspace.yaml的原因

    • 更丰富的配置选项:支持排除模式(!**/test/**
    • 更好的可读性:YAML 格式更适合复杂配置
    • 工具兼容性:明确标识为 pnpm 工作区
    • 未来扩展性:pnpm 的新功能会优先在 YAML 配置中支持

步骤 4:创建子项目结构

典型的 Monorepo 目录结构如下:

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── pnpm-lock.yaml
├── packages/
│   ├── shared-utils/     # 共享工具库
│   │   ├── package.json
│   │   └── src/
│   ├── ui-components/    # UI 组件库
│   │   ├── package.json
│   │   └── src/
│   └── core-lib/         # 核心库
│       ├── package.json
│       └── src/
├── apps/
│   ├── web-app/          # 前端应用
│   │   ├── package.json
│   │   └── src/
│   └── mobile-app/       # 移动应用
│       ├── package.json
│       └── src/
└── docs/                 # 文档

步骤 5:配置子项目的 package.json

每个子项目都需要有自己的 package.json,关键配置如下: 示例:共享工具库 (packages/shared-utils/package.json)

{
  "name": "@my-monorepo/shared-utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "lodash": "^4.17.21"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

示例:前端应用 (apps/web-app/package.json)

{
  "name": "web-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@my-monorepo/shared-utils": "workspace:*",  // 关键:引用本地包
    "@my-monorepo/ui-components": "workspace:*",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "vite": "^5.0.0"
  }
}

三、关键配置详解

1. workspace:* 协议

这是 pnpm Workspace 的核心特性,用于声明对本地其他包的依赖:

{
  "dependencies": {
    "@my-monorepo/shared-utils": "workspace:*",      // 使用最新版本
    "@my-monorepo/ui-components": "workspace:^1.0.0", // 指定版本范围
    "@my-monorepo/core-lib": "workspace:../packages/core-lib" // 相对路径
  }
}

作用

  • 建立本地包之间的软链接,无需发布到 npm
  • 修改本地包时,依赖它的项目能立即看到变化
  • 确保所有包使用同一份依赖,避免重复安装

2. 依赖安装与管理

在根目录安装全局依赖(所有包共享)

# 安装到根目录,所有包共享
pnpm add typescript -w
# 或
pnpm add typescript --workspace-root

为特定包安装依赖

# 为 web-app 安装 react
pnpm add react --filter web-app
# 或
pnpm add react -F web-app

# 为多个包安装依赖
pnpm add axios --filter "web-app" --filter "mobile-app"

安装本地包依赖

# 在 web-app 中安装 shared-utils
pnpm add @my-monorepo/shared-utils --filter web-app

3. 脚本执行

在所有包中执行相同脚本

# 递归执行所有包的 build 脚本
pnpm -r run build

# 递归执行所有包的 test 脚本
pnpm -r run test

在特定包中执行脚本

# 仅在 web-app 中执行 dev 脚本
pnpm --filter web-app run dev

# 使用包名(package.json 中的 name)
pnpm -F @my-monorepo/shared-utils run build

四、完整示例:Vue 项目 Monorepo

项目结构

vue-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── packages/
│   ├── ui-lib/          # UI 组件库
│   │   ├── package.json
│   │   ├── src/
│   │   └── vite.config.ts
│   └── utils/           # 工具函数库
│       ├── package.json
│       └── src/
└── apps/
    ├── admin/           # 后台管理系统
    │   ├── package.json
    │   └── src/
    └── portal/          # 门户网站
        ├── package.json
        └── src/

pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'apps/*'

根目录 package.json

{
  "name": "vue-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "pnpm -r run dev",
    "build": "pnpm -r run build",
    "lint": "pnpm -r run lint"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

包间依赖示例 (apps/admin/package.json)

{
  "name": "admin",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "vue": "^3.3.0",
    "@vue-monorepo/ui-lib": "workspace:*",
    "@vue-monorepo/utils": "workspace:*"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.0.0",
    "vite": "^5.0.0"
  }
}

五、最佳实践与注意事项

1. 命名规范

  • 根项目:使用项目总名称,如 my-monorepo
  • 子包:使用作用域名称,如 @my-monorepo/ui-components
  • 应用:使用描述性名称,如 web-appadmin-console

2. 依赖管理

  • 公共依赖(如 TypeScript、ESLint)安装在根目录
  • 业务依赖安装在各自包中
  • 使用 pnpm-lock.yaml确保依赖一致性

3. 版本控制

  • 提交 pnpm-lock.yaml到版本控制系统
  • 考虑使用 Changesets 或 Lerna 进行版本管理和发布

4. 性能优化

  • pnpm 使用硬链接和符号链接,节省磁盘空间
  • 所有包共享同一份依赖,安装速度快
  • 支持过滤命令,只构建需要的包

5. CI/CD 集成

# GitHub Actions 示例
name: CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm -r run build
      - run: pnpm -r run test

六、常见问题解决

1. 幽灵依赖问题

确保所有依赖都在 package.json中明确声明,避免直接引用 node_modules中的未声明包。

2. 循环依赖检测

使用 pnpm why <package-name>检查依赖关系,避免包之间的循环依赖。

3. 包找不到错误

如果出现 no matches found错误,检查:

  • 包名是否正确(包括作用域)
  • 包是否在 pnpm-workspace.yaml配置的目录中
  • 包是否有正确的 name字段

4. 跨包类型引用

对于 TypeScript 项目,配置 tsconfig.json中的 paths

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@my-monorepo/*": ["packages/*/src"]
    }
  }
}

总结

通过 pnpm + Workspaces 构建 Monorepo 的主要优势包括:

  1. 依赖共享:所有包共享同一份依赖,节省磁盘空间和安装时间
  2. 原子提交:跨包变更可以一次性提交,保持一致性
  3. 本地链接:使用 workspace:*协议,本地开发无需发布到 npm
  4. 精细控制:支持按包过滤的命令执行
  5. 统一管理:集中的 CI/CD 和代码质量检查

这种架构特别适合中大型项目、微前端架构、组件库开发等场景,能显著提升开发效率和代码复用性。

JavaScript 变量的江湖恩怨:一篇文章彻底讲清楚

很久很久以前,在 JavaScript 世界里,有一个叫变量镇的地方。这里住着三兄弟:var、let、const。他们都能“装东西”,但性格、规矩、出身背景完全不一样。也正因为这三兄弟的性格差异,程序员江湖里每天都在上演各种事故现场。

今天,我就带你走进变量镇,看看他们各自是怎么“翻车”的,又该如何正确相处。

老大哥 var:江湖老油条

1、var 关键字:最早的居民

var 是 JavaScript 最早的变量声明方式,堪称上古神器

在 ES6 之前,整个变量镇几乎被 var 一统江湖。但问题也正是从这里开始的。

2、 var 声明作用域:没有块级概念

在变量镇里,var 有个非常“随意”的性格:它只认函数,不认花括号。

很多新手第一次看到这个结果时,内心都是崩溃的:

“不是写在 if 里面吗?怎么跑出来了?”

在 var 眼里,if、for、while 都不算地盘,只有函数才算真正的边界。

总结一句话:

var 只有函数作用域,没有块级作用域

3、 var 声明提升:先上车后补票

var 还有一个让人又爱又恨的能力——声明提升

在 JavaScript 引擎眼里,上面的代码其实是这样执行的:

变量声明被“偷偷”提到了最前面,但赋值还在原地。这就像你先报了名字进群,但个人资料还没填完。

风险点:

  • 不报错
  • 但结果是 undefined
  • 很容易掩盖 bug

二哥 let:讲规矩的现代青年

后来,ES6 来了,变量镇迎来了新居民:let

1、let 声明:有边界感的人

let 的出现,第一件事就是:给变量加上边界感。

2、 暂时性死区:我没到,你别碰

let 有一个非常重要的概念:暂时性死区(TDZ)

直接报错:

在 let 看来:“你可以提前知道我会来,但在我真正登场之前,你不能碰我。”

这和 var 的“先声明后赋值”完全不同。

3、 let 的块级作用域

这一次,变量终于老老实实待在自己的花括号里

这也是 let 最大的价值之一:减少变量污染,降低心智负担。

4、 全局声明:不会挂到 window 上

而如果是 var:

这意味着:

  • var 会污染全局对象
  • let 不会

在大型项目里,这一点尤为重要。

5、 for 循环中的 let:为循环而生

这是 let 最让人拍手叫好的地方。

输出结果:

如果换成 var:

结果是:

原因很简单:

  • var 只有一个 i
  • let 每次循环都会创建一个新的 i

一句话总结: 只要是循环变量,优先 let

三弟 const:外冷内热的守护者

1、const 声明:不可变的承诺

const 的核心思想是:一旦绑定,就不能重新绑定。

但很多人会误解这一点。

2、const 并不等于“值不可变”

真正不能做的是:

也就是说:

  • const 锁的是引用
  • 不是对象内部的属性

三兄弟性格对比表

声明风格及最佳实践

讲完故事,我们回到现实世界。

1、 不使用 var

这是现代 JavaScript 项目的共识。原因很简单:

  • 容易产生隐式 bug
  • 作用域不直观
  • 不符合现代工程化需求

除非你在维护非常古老的代码,否则:请忘记 var 的存在。

2、 const 优先,let 次之

这是我在团队里反复强调的一条规则。推荐顺序:

  1. 能用 const 就用 const
  2. 需要重新赋值时,用 let
  3. 永远不要用 var

这样做的好处是:

  • 一眼就能看出哪些变量不会变
  • 降低认知成本
  • 代码更安全、更自解释

总结:变量,其实是人的影子

写到这里,我越来越觉得:

JavaScript 的变量声明,其实特别像人。

var 像没边界感的老前辈,热心但容易添乱

let 像讲规则的现代职场人,清晰、可靠

const 像立下承诺的人,一旦决定就不会轻易改变

当你开始认真选择变量声明方式的时候,说明你已经不再只是“写能跑的代码”,而是在写长期可维护的工程

END

如果你觉得这篇文章对你有帮助,欢迎点赞、在看、转发,让更多人认识变量镇的三兄弟。

我是小米,一个喜欢分享技术的31岁程序员。如果你喜欢我的文章,欢迎关注我的微信公众号“软件求生”,获取更多技术干货!

🎯 Rect 中鼠标移动拾取元素可行性架构分析

🧠 一、背景:什么是拾取(Picking)?

“拾取”是指从用户交互的二维输入(鼠标、触控)映射到图形对象的过程。

想象你在一个可视化编辑器中移动鼠标指针——当光标悬浮在某个矩形(Rect)上,它高亮、变色、提示“我被选中了”,这就是 拾取系统 在背后施展魔法。✨

在二维场景中,拾取方案通常分三类:

模式 原理 优点 缺点
几何拾取 在逻辑层判断 (x,y) 与各对象边界关系 精确、轻量 大量元素时耗性能
GPU颜色拾取(Color Picking) 每个对象渲染唯一颜色,对鼠标点取色 准确、独立于形状复杂度 需额外渲染缓冲区
空间索引拾取(QuadTree / BVH) 建立空间数据结构加速命中查找 高效、可扩展 实现复杂度高

🔬 二、可行性架构核心要点

(1)数据层:结构化管理 Rect 元素

首先要有“可索引、可快速命中”的数据结构。
假设每个矩形元素都有如下属性:

class Rect {
  constructor(id, x, y, width, height) {
    this.id = id;
    this.bounds = { x, y, width, height };
    this.state = "normal"; // normal, hover, selected
  }

  contains(px, py) {
    return (
      px >= this.bounds.x &&
      px <= this.bounds.x + this.bounds.width &&
      py >= this.bounds.y &&
      py <= this.bounds.y + this.bounds.height
    );
  }
}

在小规模下,我们可直接线性扫描,但在数千个Rect场景中,这会让GPU哭泣 😭。


(2)空间层:区域加速结构设计

为解决性能问题,可引入 QuadTree(四叉树) 进行空间划分。

原理

  • 将画布平面递归划分为四个象限,每个象限存储矩形对象集合。
  • 鼠标移动时,只查询当前象限节点。
  • 空间复杂度:O(n),查询复杂度:平均 O(log n)。

伪代码演示:

class QuadTree {
  constructor(boundary, capacity) {
    this.boundary = boundary;  // 当前节点矩形区域
    this.capacity = capacity;  // 一个节点最多的元素数
    this.rects = [];
    this.divided = false;
  }

  insert(rect) { /* ...根据边界分裂插入逻辑... */ }

  query(point) {
    if (!this.boundary.contains(point)) return [];
    let found = [];
    for (let r of this.rects) {
      if (r.contains(point.x, point.y)) found.push(r);
    }
    if (this.divided) {
      found.push(...this.northwest.query(point));
      found.push(...this.northeast.query(point));
      found.push(...this.southwest.query(point));
      found.push(...this.southeast.query(point));
    }
    return found;
  }
}

(3)事件层:鼠标移动拾取流逻辑

核心逻辑架构如下:

mousemove
   ↓
拾取管理器 (PickManager)
   ↓
空间查询 (QuadTree.query)
   ↓
检测命中对象 (Rect.contains)
   ↓
状态同步 (hover / leave / select)
   ↓
渲染层重新绘制目标区域

完整简化实现:

canvas.addEventListener("mousemove", (e) => {
  const mouse = { x: e.offsetX, y: e.offsetY };
  const hitRects = quadTree.query(mouse);

  const newHover = hitRects[0] || null;
  if (currentHover !== newHover) {
    if (currentHover) currentHover.state = "normal";
    if (newHover) newHover.state = "hover";
    currentHover = newHover;
    render(); // 局部或全局重绘
  }
});

这样一来,我们实现了:

  • 实时拾取
  • 高效区域查询
  • 状态分离与渲染解耦

(4)渲染层:Canvas / WebGL 混合优化

对于 Canvas 2D 场景,通常直接 redraw。
对于复杂 WebGL 场景,可采用双通道:

  1. 主通道绘制可视内容;
  2. 拾取通道(off-screen buffer)绘制唯一颜色 ID;
  3. 鼠标事件时,通过 gl.readPixels 获取点击像素颜色 -> 反查对象 ID。

这种方案在大型交互式图形系统(如 Mapbox、Three.js)中用途极广。🌍


🧩 三、性能可行性分析

指标 几何拾取 四叉树加速 GPU颜色拾取
查询复杂度 O(n) O(log n) O(1)
渲染性能 高CPU占用 中等 高GPU开销
延迟 极低 取决于颜色缓冲同步
适用场景 少量对象 大量矩形元素 复杂场景(3D/WebGL)

结论:

✅ 对于 Web 端二维 Rect 编辑器或设计工具,QuadTree + 几何拾取混合方案 是可行且优雅的架构选择。


⚙️ 四、工程优化建议 🧪

  1. 事件节流(throttle)
    避免每次 mousemove 都触发完整查询,可在 16ms(约60Hz)节流执行。
  2. 局部重绘(Dirty Rect)
    只重绘状态变化区域,提升渲染性能。
  3. 命中缓存(Hover Cache)
    若连续多帧命中同一元素,不重复查询,降低开销。
  4. 交互优先级分层
    针对UI层、图形层分别拾取,按z-index排序命中结果。

🪶 五、哲学层小结:拾取,不只是拾取

“拾取”看似一个计算几何问题,实则是一次人与空间的映射关系重建

当你从一块平面中精准捕捉一个矩形,背后是:

  • 一颗QuadTree在飞速判断区块;
  • 一次GPU在色彩世界编码ID;
  • 一份人机交互的默契在闪光。

工程的浪漫,藏在每一次 mousemove 中。💫


🧭 总结

模块 关键技术 工程要点
数据层 Rect 对象结构化 定义边界与状态
空间层 QuadTree or 网格划分 加速查询性能
事件层 MouseMove + 状态机 处理 hover/leave
渲染层 局部刷新 / GPU解码 平衡性能和准确性

【高斯泼溅】Mapmost分区训练,让大场景3DGS建模从此高效且高质

**3D Gaussian Splatting(3DGS)**在单物体或中小规模场景(面积小于0.1 km²)中已展现出优异性能:

  • 通常在30-60分钟内收敛;
  • 支持1080p分辨率下30 fps实时渲染;
  • 采用显式的高斯点云表示,便于后续编辑与调整。

然而,在智慧城市、高精地图等大规模应用中,当场景扩展至1 km²以上、输入图像超过2000张时,原生3DGS面临显存爆炸、训练滞缓等严峻挑战。本文将系统分析大场景建模的核心瓶颈,并探讨可扩展的解决方案。

原生3DGS难以支持大场景

显存溢出与训练缓慢:大场景3DGS的现实困境

当我们尝试将3DGS扩展至大规模实际应用时,算力与效率成为绕不开的核心挑战:

01显存压力剧增

更大的场景往往需要更多图像覆盖,进而生成海量高斯椭球。实验表明,每平方公里约产生5000万个高斯点,训练过程中的显存峰值可能突破50GB,超出单张消费级或专业级GPU的承载能力。

02训练效率显著下降

为保证重建质量,每张图像需要参与足够轮次的优化。图像数量增加不仅拉长了总训练轮数,而且每次迭代的计算开销与高斯点数量呈正比——点越多,单次迭代越慢。实测估算,单卡训练1 km²场景耗时可能超过1天,难以满足实际工程需求。

大场景3DGS的现实困境

因此,采用**“分而治之”**策略——将大场景划分为多个子区域,分别进行建模与优化——成为当前兼顾效果与效率的合理工程路径。

大场景分区-->单区块训练-->各区块合并的“分而治之”策略

主流分区方案回顾:VastGS & CityGS

近年来,以CityGSVastGS为代表的分区方案在大场景3DGS重建中取得了显著进展。二者均采用**“分而治之”**策略,将整体场景划分为多个子区域并行训练,最终融合为完整模型,但在分区逻辑与数据组织方式上各有侧重。

其中,VastGS的分区流程可简要概括为以下步骤:

  • 基于相机位置的区块划分:将所有训练相机中心投影至地平面, 并据此划分网格,力求每个网格内获得近似等量相机,并适当往外扩展边界;
  • 空域可见性补充:计算区块包围盒在图像中的投影面积占比,若超过 25%,则将该相机加入当前区块,并进一步筛选其可观测的高斯点;
  • 并行训练与融合:各区块独立训练完成后,裁剪边界外的高斯椭球,并拼接所有区块生成全局一致的模型。

VastGS分区示意图

相比之下,CityGS采用以点云空间分布为核心的划分策略:

  • 基于点云的均匀划分:将重建场景沿X、Y轴方向等距划分为规则网格,形成若干空间连续的子块;
  • **贡献度驱动的相机筛选:**筛选位于块内或对块内区域贡献超过阈值的相机,剔除无关视图,提升训练效率;对于相机分布稀疏的区域,则动态扩展其空间边界;
  • **并行训练与融合:**与VastGS类似,各子块独立训练后裁剪边界并合并,输出完整场景模型。

CityGS分区示意图

上述两项工作均验证了**“分区+独立训练+拼接”**的可行性,但留下两个共性问题:

  1. 资源分配缺乏自适应性:分区多采用固定尺寸或仅依赖相机分布,未考虑场景复杂度,导致高细节区域优化不足,空旷区域资源浪费。
  2. 边界外点冗余训练: 各区块初始化时载入大量最终被裁剪的边界外高斯点,这些点全程参与训练却无建模贡献,实测显存开销增加20–40%,拖累效率与质量。

Mapmost的大场景分区方案

基于上述观察,我们在Mapmost高斯泼溅建模平台里实现了一套**“场景复杂度感知 + 掩膜训练”的端到端流程,从根源上解决资源分配与训练冗余**问题:

一、采取基于点云的自适应分区策略:

根据场景内容复杂度动态调整区块大小,在保障重建质量的同时提升资源利用效率;

二、在训练阶段智能屏蔽冗余点:

避免边界外无效高斯点参与训练,使有限算力更集中于有效区域,提升整体建模质量与收敛速度。

该方案不仅支撑了平方公里级场景的高效重建,还在同等硬件条件下显著提升了模型视觉质量与渲染一致性。

同等配置下优化前后建模效果对比

如今,用户只需一键上传图像数据,Mapmost高斯泼溅建模平台便能自动完成**“分区--训练--合并”**的全流程处理,无需调参或人工干预,高效生成高质量的大场景3DGS模型。

Mapmost高斯泼溅建模平台分区重建示意图

快来试试吧!

理论上的突破,终需落于工程实践。你只需专注数据与创意,剩下的交给Mapmost高斯泼溅建模平台——从智能分区、并行训练到无缝合并,全程自动处理,助你高效获得高质量、全局一致的3DGS模型。

申请试用,请至Mapmost官网联系客服

Mapmost 3DGS Builder在线体验版已上线~

欢迎体验: studio.mapmost.com/3dgs

NuxtHub部署nuxt项目就是方便

大家好~我是Pub

最近一直在开发面向海外的应用,基本都是用Nuxt进行全栈开发的。从开发到部署都用了 Nuxthub,我自己用起来觉得非常方便。(我发现掘金好像还没有nuxthub的文章 )

前几天Nuxthub也更新到了0.10版本。

如果你是 Nuxt 开发者,在开发面向海外的应用,想开发全栈应用(带数据库、文件上传等),但又不想折腾复杂的后端部署、Docker 或云服务配置,NuxtHub 就是为你准备的。

1. NuxtHub 是什么?

NuxtHub 是一个 Nuxt 模块@nuxthub/core),也是一个部署和管理平台。

它的核心理念是 "Zero Configuration"(零配置)。它将数据库 (SQL)文件存储 (Blob)键值存储 (KV)服务端缓存 等后端能力,直接集成到了 Nuxt 的运行时中。

简单来说,它让你在写 Nuxt 代码时,可以直接 import 一个数据库对象并开始查询,而无需关心底层的连接、认证或服务器维护。它底层主要利用了 Cloudflare 的边缘计算能力(Workers, D1, R2, KV),让你的应用不仅全栈,而且运行在全球边缘节点上。

2. 有什么方便的?

NuxtHub 最大的卖点就是 开发者体验 (DX) 的极致简化:

  • 开箱即用:不需要安装 MySQL/PostgreSQL 软件,不需要配置 S3 存储桶。在本地开发时,NuxtHub 会自动在本地模拟这些服务(使用 SQLite 等技术),数据保存在项目根目录的 .data 文件夹中。
  • 像写前端一样写后端:你不需要学习复杂的后端框架。通过简单的导入语法即可操作数据库。
  • 零配置部署:使用 npx nuxthub deploy 命令,系统会自动识别你的配置,在 Cloudflare 上为你创建对应的数据库、存储桶和 KV 空间,并发布应用。
  • 可视化管理:直接在 Nuxt DevTools(开发工具栏)里就能看到数据库里的表、查看上传的文件、管理缓存,非常直观。

3. 有什么好处?

  1. 全栈一体化: 前端和后端逻辑在同一个项目中,共享类型(TypeScript),维护成本极低。
  2. 高性能 (Edge Computing): 你的应用不是运行在某个单一地区的服务器上,而是运行在 Cloudflare 的全球边缘网络上,用户访问速度极快。
  3. 功能丰富
    • NuxtHub Database (SQL): 基于 SQLite (Cloudflare D1),支持 Drizzle ORM,自动处理数据库迁移。
    • NuxtHub Blob: 轻松上传和存储图片、视频 (Cloudflare R2)。
    • NuxtHub KV: 高速键值对存储,适合存配置、Session 等。
    • NuxtHub Cache: 强大的服务端/API 缓存能力。
  4. 无厂商锁定风险: 虽然它目前深度集成 Cloudflare,但它是作为一个开源模块设计的,未来有潜力适配更多平台。而且本地开发完全离线可用。

4. 怎么用?

第一步:创建项目(最快上手)

直接使用官方模板初始化:

# 初始化一个 NuxtHub 项目
npx nuxi init -t hub my-hub-app
cd my-hub-app
npm install
npm run dev

第二步:在现有项目中使用

安装核心模块:

npx nuxi module add @nuxthub/core

修改 nuxt.config.ts 开启你需要的功能:

export default defineNuxtConfig({
  modules: ['@nuxthub/core'],
  hub: {
    db: true,    // 开启 SQL 数据库
    blob: true,  // 开启文件存储
    kv: true,    // 开启键值对存储
    cache: true, // 开启缓存
  }
})

第三步:写代码(示例)

1. 数据库操作 (server/api/todos.ts)

import { db } from 'hub:db'

export default eventHandler(async () => {
  // 直接查询数据库,拥有完整的类型提示
  const todos = await db.query.todos.findMany()
  return todos
})

2. 键值对存储 (KV)

import { kv } from 'hub:kv'

// 存储数据
await kv.set('user:1:settings', { theme: 'dark' })
// 获取数据
const settings = await kv.get('user:1:settings')

3. 文件上传 (Blob)

import { blob } from 'hub:blob'

export default eventHandler(async (event) => {
  // 获取上传的文件
  const form = await readFormData(event)
  const file = form.get('image') as File
  
  // 保存到 Blob 存储
  return await blob.put(file.name, file, {
    addRandomSuffix: true
  })
})

第四步:部署上线

无需去 Cloudflare 后台手动点点点,只需运行:

npx nuxthub deploy

它会自动登录、构建、并在云端配置好所有需要的数据库和存储资源。


总结:NuxtHub 是目前 Nuxt 生态中开发全栈应用最现代、最轻量的方式,特别适合个人开发者、独立开发者。

最近写了个开源项目也是用nuxthub开发的。

如果这个项目对你有帮助:

  • Star 一下支持作者
  • 🐛 发现 Bug 欢迎提 Issue
  • 📢 分享给有需要的朋友
  • 💬 有问题可以评论区交流

🔗 GitHub: github.com/PBHAHAHA/Nu…

也可以加我微信交流:w314709923x

如果觉得有帮助,别忘了点赞 👍 收藏 ⭐ 关注 ➕ 三连哦~

让代码学会“等外卖”:JavaScript异步编程趣谈

欢迎使用我的小程序👇👇👇👇

small.png


大家好!今天我们来聊聊JavaScript中一个既重要又有趣的话题——异步编程。如果你曾经遇到过网页“卡死”的情况,或者好奇为什么有些操作不会阻塞页面交互,那么这篇文章就是为你准备的!

同步 vs 异步:点外卖的智慧

想象一下你要准备一顿晚餐:

同步方式(不推荐):

  1. 走进厨房开始煮饭
  2. 站在灶台前盯着锅,什么都不做,直到饭煮好(20分钟)
  3. 饭好了才开始洗菜切菜
  4. 再花30分钟炒菜
  5. 总共耗时:50分钟

异步方式(聪明做法):

  1. 开始煮饭(设置定时器)
  2. 在饭煮的同时洗菜切菜
  3. 饭好了的提示音响起时处理饭
  4. 继续炒菜
  5. 总共耗时:35分钟

JavaScript的异步编程就像是那个聪明的厨师,让多个任务可以同时进行!

回调函数:JavaScript的“电话通知”

最早的异步处理方式是回调函数:

// 点外卖的比喻:下单后留下电话号码,外卖到了打电话通知你
orderFood('pizza', function(pizza) {
    console.log(`我的${pizza}到了!可以开动了!`);
});

console.log('在等外卖的时候,我可以继续刷剧...');

但问题来了——如果你需要按顺序做多件事呢?

// “回调地狱”出现了!
orderFood('pizza', function(pizza) {
    getDrink('cola', function(cola) {
        buyNapkins(function(napkins) {
            console.log(`准备好享用${pizza}+${cola}了,还有${napkins}张餐巾纸`);
            // 更多嵌套...
        });
    });
});

这就像是:等外卖→外卖到了买饮料→饮料到了买纸巾……效率太低了!

Promise:外卖订单追踪系统

ES6带来了Promise,就像外卖平台的应用,可以追踪订单状态:

// 创建一个Promise就像下一个外卖订单
const foodOrder = new Promise((resolve, reject) => {
    // 模拟烹饪时间
    setTimeout(() => {
        const success = Math.random() > 0.1; // 90%的成功率
        success ? resolve('香喷喷的披萨') : reject('抱歉,烤箱坏了');
    }, 2000);
});

// 追踪订单状态
foodOrder
    .then(food => {
        console.log(`🎉 ${food}送达!`);
        return '吃完了,该收拾了'; // 可以继续返回新的Promise
    })
    .then(message => {
        console.log(message);
    })
    .catch(error => {
        console.log(`😢 ${error}`);
    })
    .finally(() => {
        console.log('这次订餐体验结束了');
    });

console.log('订单已下,我可以继续工作...');

async/await:像写同步代码一样写异步

ES7的async/await让异步代码看起来像同步代码一样直观:

async function enjoyDinner() {
    try {
        console.log('开始准备晚餐...');
        
        // 看起来是同步的,但实际上是异步的!
        const pizza = await orderPizza();
        console.log(`${pizza}准备好了`);
        
        const drink = await orderDrink();
        console.log(`${drink}也到了`);
        
        // 这两个可以同时进行!
        const [napkins, movie] = await Promise.all([
            buyNapkins(),
            loadMovie()
        ]);
        
        console.log(`完美!有${pizza}${drink}${napkins}和电影${movie}`);
        
    } catch (error) {
        console.log(`晚餐计划失败:${error}`);
    }
}

// 模拟的异步函数
function orderPizza() {
    return new Promise(resolve => {
        setTimeout(() => resolve('🍕意大利香肠披萨'), 2000);
    });
}

事件循环:JavaScript的“时间管理大师”

JavaScript是单线程的,但它有一个神奇的事件循环机制:

console.log('1. 开始做饭');

setTimeout(() => {
    console.log('4. 定时器到时间了(饭煮好了)');
}, 0);

Promise.resolve()
    .then(() => {
        console.log('3. Promise微任务(尝一下味道)');
    });

console.log('2. 继续切菜');

// 输出顺序:
// 1. 开始做饭
// 2. 继续切菜  
// 3. Promise微任务(尝一下味道)
// 4. 定时器到时间了(饭煮好了)

简单来说:

  1. 同步任务立即执行
  2. 微任务(Promise)在当前任务结束后立即执行
  3. 宏任务(setTimeout)在微任务之后执行

实际应用:有趣的例子

// 模拟一个加载进度指示器
async function loadContentWithProgress() {
    const tasks = [
        '加载用户数据',
        '获取朋友圈',
        '下载图片',
        '初始化聊天'
    ];
    
    for (let i = 0; i < tasks.length; i++) {
        // 模拟每个任务的耗时
        await new Promise(resolve => 
            setTimeout(resolve, Math.random() * 1000 + 500)
        );
        
        const progress = ((i + 1) / tasks.length) * 100;
        console.log(`🔄 ${tasks[i]}... 进度:${progress.toFixed(0)}%`);
    }
    
    console.log('✅ 所有内容加载完成!');
}

// 使用Promise.race实现超时控制
function fetchWithTimeout(url, timeout = 3000) {
    const fetchPromise = fetch(url);
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error('请求超时')), timeout);
    });
    
    return Promise.race([fetchPromise, timeoutPromise]);
}

异步编程的黄金法则

  1. 避免阻塞:长时间运行的任务要异步化
  2. 错误处理:不要忘记.catch()或try-catch
  3. 适度使用:不是所有东西都需要异步
  4. 保持简单:能用async/await就不用复杂的Promise链

总结

JavaScript的异步编程就像生活中的多任务处理:我们不会等水烧开时傻站着,而是同时准备其他食材。从回调函数到Promise再到async/await,JavaScript为我们提供了越来越优雅的方式来处理异步操作。

记住,好的异步代码就像一场精心编排的交响乐——每个部分在正确的时间奏响,整体和谐而不混乱。

希望这篇文章让你对JavaScript异步编程有了更直观的理解!现在,试着把你的下一个耗时操作改成异步吧,让你的应用变得更加流畅!

小挑战:你能用async/await写一个模拟“同时煮面、煎蛋、烤面包”的早餐制作程序吗?在评论区分享你的实现吧!🍳

如何实现倒计时工具

最开始查到这个是因为在做版本更新弹窗的定时器轮询,然后查看到一篇文章关于前端倒计时存在误差,从而衍生出高并发秒杀场景下,应该如何实现倒计时工具:

最开始想到的办法是setTimeout。

但因为setTimeout本身因为JavaScript是单线程,setTimeout 的回调函数会被放入事件队列,浏览器资源分配中前面任务阻塞延迟的问题会存在一定性的误差,但是误差较小,大部分场景都可以忽略。

但是如果切换Tab页或者最小化之后再切换到前台,因为浏览器会识别当前页面需要资源减少,降低定时器的执行频率,直到切回来后才会恢复原本的轮询间隔,所以这样切回来的那一次计算时间会有误差。

对此因为项目会在第一次挂载的时候立刻弹窗以及切换到后台再切回前台的时候再次立刻弹窗,所以只需要在切换回来的时候立刻执行一次versionListener,并删除定时器,重新创建一个新的定时器就能恢复原本的时限误差

如果没有以上的策略,坚持原本的定时器,并且不希望出现误差的话,可以使用web worker单独处理定时器。

Web Worker 是独立于主线程的后台线程,不受前台 / 后台标签的节流规则限制,其事件循环和定时器执行逻辑完全独立,因此切后台后仍能保持接近预期的执行频率

面试官:前端倒计时有误差怎么解决前言 去年遇到的一个问题,也是非常经典的面试题了。能聊的东西还蛮多的 倒计时为啥不准 一 - 掘金 (juejin.cn)

看了一下这个里面的一个评论:

setTimeout 的延时不应当被依赖用来进行倒计时,因为它有非常多的不稳定因素(参见MDN)。
最佳实践是将倒计时结束的时间戳计算出来,再用 setTimeout 或者 requestAnimationFrame 进行更新(计算结束和当前时间戳的时间差,舍入到秒数然后更新到页面上),这种方式误差最小且最节能。至于需要和服务器实时同步的场景则考虑sse授时。

具体为:

  1. 定时器的本质setTimeout 的 delay 是「事件循环中任务的最小等待时间」,如果队列中有其他任务,实际执行时间会更长;
  2. requestAnimationFrame 优势:浏览器会在重绘前执行回调,前台 60fps(约 16ms / 次),后台自动暂停,比 setInterval 节能 90%+;
  3. 时间戳选择performance.now() 是高精度时间戳(微秒级),且不受系统时间修改影响,优先于 Date.now()

由此衍生两种核心方法:

  1. 计算出倒计时结束时间,轮询是否到达结尾时间,进行报时,performanceAPI在Node环境下无法使用,只支持浏览器环境
function preciseCountdown(endTime:number, onUpdate:(seconds:number)=>void, onComplete:()=>void) {
  // 高精度时间戳(优先用 performance.now,无兼容问题时用 Date.now)
  const getNow = () => performance.now() + performance.timeOrigin;

  const update = () => {
    const now = getNow();
    const remaining = Math.max(endTime - now, 0); // 剩余时间(ms)
    const remainingSeconds = Math.floor(remaining / 1000); // 舍入到秒

    // 更新页面(只传最终要显示的秒数,避免浮点误差),在高频报数场景下每秒报数发生变化由此实现倒计时
    //实际上是10->10->...(省略n个10)->10 ->9->9->...->9->8....
    onUpdate(remainingSeconds);

    if (remaining <= 0) {
      onComplete();
      return;
    }

    // 优先用 requestAnimationFrame(更节能,适配屏幕刷新率)
    // 降级用 setTimeout(兼容老环境,延时设为 16ms 接近 60fps)
    if (requestAnimationFrame) {
      //进行高频报数展示
      requestAnimationFrame(update);
    } else {
      setTimeout(update, 16);
    }
  };

  // 立即执行一次更新,避免首屏延迟
  update();
}

// 用法示例:倒计时 10 秒
const endTime = Date.now() + 10 * 1000;
preciseCountdown(
  endTime,
  (seconds) => {
    console.log('剩余秒数:', seconds);
    // 更新 DOM:document.getElementById('countdown').textContent = seconds;
  },
  () => {
    console.log('倒计时结束');
  }
);
  1. 使用后端sse进行处理,相比于普通的接口请求,sse具备高度的实时性
对比维度 普通后端接口(HTTP/REST) SSE(Server-Sent Events)
通信方向 双向(客户端请求 → 服务端响应),但「请求 - 响应」是单次单向 单向(服务端 → 客户端),服务端主动推送
连接模式 短连接:请求发起 → 响应返回 → TCP 连接关闭 长连接:一次 TCP 握手后,连接持续保持,服务端按需推数据
触发方式 客户端主动触发(点击、定时轮询、页面加载) 服务端主动触发(数据更新 / 事件发生时推送)
数据传输形式 单次完整数据(JSON/Form/ 二进制),响应结束即终止 流式文本数据(UTF-8),分块传输(一行 / 多行数据)
实时性 低(轮询依赖间隔,有延迟) 高(数据更新立即推送,无轮询延迟)
网络开销 高(轮询场景下多次 TCP 握手、重复请求头) 低(一次连接持续传输,仅首次握手开销)
// SSE:客户端建立连接,监听服务端推送
const sse = new EventSource('/api/sse/countdown');
sse.onmessage = (e) => {
  const data = JSON.parse(e.data);
  console.log('服务端推送的实时数据:', data); // 服务端有更新就会触发
};
  • 普通接口:默认是「短连接」——TCP 三次握手后传输数据,响应完成后四次挥手关闭连接;若要实时性,需用「定时轮询」(如每 1 秒请求一次),但会产生大量重复的 TCP 握手 / 挥手开销,且有轮询间隔的延迟。
  • SSE:是「HTTP 长连接」—— 一次 TCP 握手后,连接保持打开状态,服务端通过「分块传输编码(Chunked Transfer Encoding)」向客户端流式推送数据,直到连接被主动关闭(客户端 close() 或服务端断开)。优势:无轮询的重复开销,实时性接近 “即时”
  • WebSocket 是双向通信(客户端↔服务端)、基于独立的 WebSocket 协议、功能更强但复杂度更高;常用于双向实时交互(如聊天、在线游戏)场景。

目前的电商秒杀倒计时用的主流方案应该是performance计算最终时间-起始时间,而不是sse,因为sse在电商这类高并发场景下需要大量的后端开销去建立链接窗口。

在进行秒杀时间校准的场景下,如何防止用户手动修改本地时间,主要依赖三个参数:

  1. 后端在秒杀开始前会传给前端接口秒杀开始的准确时间戳,比如xxxxxxxxxx
  2. 前端通过performance.timeOrigin获取绝对起始时间,这个时间一旦定下为YYYYYYY,无论中途用户修改多少次本地时间都不会发生变化
  3. 前端在建立performance.timeOrigin的时候会绑定建立performance.now(),为绝对流逝时间,也就是相对performance.timeOrigin绝对起始时间变化的时间,不受本地时间影响

最终xxxxxxxxxx-(performance.timeOrigin+performance.now())就是需要倒计时的时间

超越随机:JavaScript中真正可靠的唯一标识符生成策略

超越随机:JavaScript中真正可靠的唯一标识符生成策略

为什么唯一ID是开发中的隐形地雷

在软件开发世界里,唯一标识符(ID)如同数字世界的DNA,看似微不足道却支撑着系统核心功能。从数据库记录到API请求追踪,从缓存管理到分布式事务处理,ID的唯一性直接决定了系统稳定性。然而,许多开发者在实现这一"简单"需求时,往往埋下隐患,直到深夜报警电话响起才意识到问题严重性。

关键洞察:当前最简洁高效的方案是crypto.randomUUID(),它解决了传统方法的固有缺陷。如果你仍在使用Date.now() + Math.random()或简单计数器,请继续阅读,这可能是你今天最重要的技术决策。

常见ID生成陷阱剖析

陷阱一:时间戳+随机数的表象安全

// 广泛流传但隐患重重的实现
const flawedIdGenerator = () => 
  `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;

这种方法的三大致命伤:

  1. 毫秒精度的幻觉:现代CPU每毫秒可执行数千条指令。高流量应用中,同一毫秒内产生多个ID极为常见,导致前缀完全一致。
  2. 伪随机的本质Math.random()基于算法生成确定性序列,非真随机。研究表明,特定环境下,其随机性周期可能短至2⁴⁸,远低于安全需求。
  3. 上下文隔离失效:浏览器刷新、多标签页打开或分布式服务节点间,该方案毫无防冲突机制。

这种方案的危险性在于"低频使用时表现良好",让用户产生虚假安全感,直到系统规模扩大时灾难性故障爆发。

陷阱二:自增计数器的局限性

// 看似可靠的简单实现
let idCounter = 0;
const getNextId = () => (++idCounter).toString(36);

该方案在Web环境中面临根本性挑战:

  • 状态易失性:页面刷新即重置,无法维持全局唯一
  • 并行执行冲突:同一用户打开多个标签页,各维护独立计数器,立刻产生重复
  • 分布式系统不适用:无中心协调的服务集群中,各节点独立计数必然冲突

计数器仅适用于单进程、有状态且持久化的服务端场景,前端环境几乎无法安全使用。

陷阱三:UUID v1的隐私隐患

早期UUID标准(v1)将MAC地址嵌入ID,看似科学实则暴露用户设备信息。现代网络安全实践明确反对在客户端生成包含硬件标识的ID,可能违反GDPR等隐私法规。

现代解决方案:Web Crypto API

什么是crypto.randomUUID()?

// 一行代码解决所有问题
const robustId = crypto.randomUUID(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"

这一原生API是浏览器和Node.js(14.17+)提供的密码学安全ID生成器,符合RFC 4122规范的UUID v4标准。

为何它是当前最佳选择?

  1. 数学级别的唯一性保证:122位熵空间意味着,即使每秒生成10亿ID,也需超过85年才有0.000000001%的碰撞概率。实际应用中可视为绝对唯一。
  2. 密码学强度随机源:不同于Math.random(),它使用操作系统提供的CSPRNG(Cryptographically Secure Pseudorandom Number Generator),杜绝可预测性。
  3. 跨平台标准兼容:所有主流数据库系统对UUID有原生优化支持,序列化/反序列化高效可靠。
  4. 零依赖与安全审计:作为浏览器内置API,无需第三方库,减少供应链攻击面。代码库经全球安全专家持续审查。
  5. 惊人的执行效率:基准测试显示,现代浏览器每秒可生成10万+ UUID,远超业务需求。

兼容性现状

运行环境 支持版本 市场覆盖率
Chrome 92+ (2021.07) 97.2%+
Firefox 90+ (2021.06) 95.8%+
Safari 15.4+ (2022.03) 93.1%+
Edge 92+ (2021.07) 97.5%+
Node.js 14.17.0+ (2021.05) 92.4%+

对新项目,无条件选择此方案。仅当必须支持IE等古董浏览器时,才考虑polyfill方案。

各场景下的ID方案对比与选型

方案特性矩阵

方案 唯一性保证 安全等级 长度(字符) 依赖项 时间排序 适用场景
时戳+随机 15-25 有限 避免使用
自增计数器 低(多实例) 1-15 单进程服务端
UUID v1 36 避免(隐私问题)
crypto.randomUUID() 极高 极高 36 通用首选
NanoID 21(默认) 需安装 URL短链
ULID 26 需安装 时序日志系统
雪花算法 高(需配置) 19 需实现 大型分布式系统

场景化选型指南

  1. 现代Web应用:直接使用crypto.randomUUID(),平衡了安全性、性能与标准兼容性
  2. 需支持旧浏览器的项目:引入uuid npm包(约6KB),提供相同API接口
  3. URL友好的短ID需求:考虑NanoID,它使用更紧凑的字符集,在保持唯一性的同时缩短长度
  4. 需要ID自带时序信息:选择ULID(128位+时间戳),兼顾排序性与唯一性
  5. 超大规模分布式系统:雪花算法变种,但需谨慎处理时钟回拨问题

深度实践:进阶技巧与陷阱规避

前端存储场景的最佳实践

// 持久化本地ID,避免重复生成
const getOrCreateDeviceId = () => {
  let deviceId = localStorage.getItem('app_device_id');
  if (!deviceId) {
    deviceId = crypto.randomUUID();
    localStorage.setItem('app_device_id', deviceId);
  }
  return deviceId;
};

这种模式结合了随机生成与持久存储,既保证唯一性又维持会话连续性。

服务端-客户端ID协同策略

在前后端分离架构中,最佳实践是:

  • 服务端生成权威ID(使用相同crypto API)
  • 客户端通过API获取而非自行生成
  • 仅在离线场景允许客户端预生成,后续与服务端ID映射

优化UUID存储与展示

// 去除连字符可节省25%存储空间
const compactId = crypto.randomUUID().replace(/-/g, ''); // 32字符

// Base62编码进一步压缩(需额外库)
import { encode } from 'base62';
const ultraCompact = encode(Buffer.from(crypto.randomUUID().replace(/-/g, ''), 'hex'));

权衡:紧凑格式提高存储效率,但丧失标准UUID的可读性与工具链支持。

专家级建议:超越技术实现

  1. 唯一性验证设计:即使使用最佳ID方案,关键系统仍应实现重复ID检测和自动恢复机制
  2. ID生命周期管理:明确ID用途 - 临时会话ID与永久资源标识符的安全要求截然不同
  3. 性能监控:在高流量应用中,定期采样ID生成性能,确保无性能退化
  4. 合规性考量:在欧盟等严格隐私区域,避免包含个人信息的ID生成方案,即使技术上可行

结语:唯一ID是系统架构的基石

在软件工程中,最危险的技术债往往隐藏在"简单实现"之下。一个看似微不足道的ID生成函数,可能成为系统扩展的瓶颈或安全漏洞的源头。

专业开发者准则:不要重新发明轮子,除非你完全理解轮胎的工作原理。在唯一ID生成领域,crypto.randomUUID()是经过数十年密码学和分布式系统研究的结晶。

立即行动:审查代码库中所有Math.random()Date.now()组合和计数器实现,用一行crypto.randomUUID()替换它们。这个微小改动可能避免未来数万美元的故障修复成本和无法估量的声誉损失。

在数字世界的混沌中,可靠的身份标识是秩序的起点。选择正确工具,让您的系统从根基上坚不可摧。

原文:mp.weixin.qq.com/s/CWdfIW4wR…

macOS ⇄ Android 局域网无线传输 APK 终极方案

针对 macOS 自带 Python 服务在传输大文件(如 APK)时容易断连报错(Broken Pipe)的问题,改用前端业界标准工具 http-server。此方案零安装、更稳定、自动显示 IP,是开发者调试最推荐的“随用随走”方案。

核心步骤

1. 准备工作

  • 确保 Mac 和 安卓手机连接同一 Wi-Fi。
  • Mac 需已安装 Node.js(React Native 开发环境通常自带)。
  1. Mac 端开启服务

打开终端 (Terminal),执行以下两步:

# 1. 进入 APK 文件所在的目录
cd ~/Downloads

# 2. 使用 npx 临时启动高性能文件服务器
# (首次运行输入 y 确认下载即可,不会污染全局环境)
npx http-server

3. 安卓端下载

  • 查看地址:终端运行后会自动列出地址,找到 Available on: 下方 192.168.x.x 开头的地址。
  • 下载安装:手机浏览器输入该地址(如 192.168.1.5:8080),点击 APK 文件即可满速下载,支持断点续传。

方案重点 (Highlights)

  • ⚡️ 更稳定:相比 Python 的单线程服务,http-server 完美支持多线程下载,彻底解决 APK 下载到一半报错中断的问题。
  • 🧹 更洁癖:使用 npx 运行,无需全局安装,用完即走,不占用系统空间。
  • 🧠 更智能:启动后自动打印本机局域网 IP,无需再手动输入 ipconfig 查询 IP 地址。

【重磅福利】学生认证可免费领取 Gemini 3 Pro 一年

背景

谷歌正式发布最新一代推理模型 Gemini 3.0 Pro,并同步开放 API 接口及在 谷歌 AI Studio 推出预览版。这款模型一经发布,立刻横扫各大评测榜单,以“一夜封神”的姿态震撼了全球 AI 社群。

值得玩味的是,OpenAI 首席执行官奥特曼也隔空向谷歌发来“贺电”,在社交媒体上评价“Gemini 3 看起来很不错”,谷歌首席执行官皮查伊(Sundar Pichai)则以一个轻松的表情包回应,尽显王者风范。

Gemini 3.0 Pro 的强大并非空穴来风,其在关键的基准测试中实现了全面“碾压式”的领先:

数学能力登顶全球: 在被誉为“地狱难度”的数学竞赛基准 MathArena 中,当 GPT-5.1 等其他顶级大模型仍在 1% 左右徘徊时,Gemini 3.0 Pro 的得分一举达到 23.4% ,毫无争议地成为当前全球数学能力最强的 AI。

重磅福利

现在一年会员只要 19.9 元,帮您解决以下 4 大问题。

⚠️注:优惠截止日期 2026年1月31日

如有问题可以联系V:zm_david。

1. 出了点问题(Something went wrong)

2. 此账号无法订阅 Google AI 方案

3. 无法享受此优惠

4. 没有 Visa 卡绑定

Webpack vs Vite 根本设计原理深度解析:为什么两者差异这么大?

Webpack vs Vite 根本设计原理深度解析:为什么两者差异这么大?

之前的对比更多聚焦于“表面差异”,但两者的核心区别源于 底层设计哲学和技术选型的根本不同——简单说:

  • Webpack 的设计核心是「全场景兼容的提前打包哲学」:无论环境、无论模块类型,都通过“提前解析所有依赖、打包为 bundle”实现统一处理;
  • Vite 的设计核心是「现代浏览器优先的即时构建哲学」:利用现代浏览器原生能力(ES 模块),拆分“开发时”和“生产时”流程,按需处理模块,放弃对老场景的过度兼容。

本文将从「设计初衷、核心原理、底层实现」三个维度,扒透两者的根本差异,让你明白“为什么 Vite 更快”“为什么 Webpack 更全能”不是偶然,而是设计之初就定好的方向。

一、先统一认知:什么是“构建工具的根本原理”?

构建工具的核心使命是:将分散的源码模块(JS/TS/CSS/图片等),通过“依赖解析、转译、优化、合并”,最终生成浏览器/Node.js 可执行的产物

而“根本设计原理”,就是解决以下 3 个核心问题的“决策逻辑”:

  1. 何时处理模块?(提前一次性处理 vs 按需实时处理)
  2. 如何解析依赖?(递归构建依赖图 vs 依赖浏览器原生解析)
  3. 如何扩展功能?(分层架构 vs 单一插件体系)

Webpack 和 Vite 的所有差异,都源于对这 3 个问题的不同回答。

二、Webpack 根本设计原理:全场景兼容的“提前打包”哲学

1. 设计初衷:解决“前端模块混乱”的历史痛点

Webpack 诞生于 2012 年,当时前端还没有统一的模块标准(CommonJS/AMD/UMD 并存),依赖管理混乱,没有工具能将各种类型的文件(JS、CSS、图片)统一处理为浏览器可识别的资源。

Webpack 的核心设计目标是:提供一个“万能打包器”,兼容所有模块类型、所有模块标准、所有运行环境,让开发者无需关心底层兼容,专注业务代码

2. 核心原理:“依赖图 + 全量打包 + 分层扩展”

Webpack 的根本逻辑是「提前把所有事做完」,无论开发还是生产,都遵循“全量处理”流程,核心分为 3 层:

(1)第一层:“一切皆模块” + 递归构建依赖图

Webpack 认为“所有文件都是模块”(JS、CSS、图片、字体等),核心步骤:

  1. 从入口文件(如 src/index.js)开始,通过 AST 语法解析(抽象语法树),找出所有 import/require 依赖;
  2. 递归解析每个依赖的依赖,直到所有模块都被找到,最终构建出一个「完整的依赖关系图」(Dependency Graph);
  3. 所有模块都被纳入这个图中,后续的转译、优化、合并都基于这个图进行。

底层技术支撑:使用 acorn 库解析 JS 生成 AST,遍历 AST 找到依赖声明,实现递归解析。

(2)第二层:“全量打包”流程(开发/生产一致)

无论开发还是生产,Webpack 都会完整执行以下流程,没有“按需处理”:

初始化(读取配置)→ 编译(构建依赖图+Loader 转译)→ 优化(Chunk 合并+Tree-shaking)→ 输出(写入磁盘)
  • 开发时没有“特殊待遇”:即使是开发环境,也需要先构建完依赖图、转译所有模块,才能启动开发服务器(这就是 Webpack 冷启动慢的根本原因);
  • 热更新(HMR)的本质:修改文件后,Webpack 会重新解析该模块及其依赖链,更新依赖图,重新转译并替换对应的 Chunk,而非仅处理修改的文件。
(3)第三层:“Loader + Plugin”分层扩展架构

为了实现“全场景兼容”,Webpack 设计了分层扩展机制,避免核心逻辑臃肿:

  • Loader 层:负责“模块转译”(解决“不同类型模块如何转为 JS 模块”);
    • 核心逻辑:非 JS 模块无法直接进入依赖图,必须通过 Loader 转译为 JS 模块(如 CSS→JS 字符串、图片→Base64/路径);
    • 设计思路:单一职责,每个 Loader 只做一件事(如 css-loader 解析 CSS 依赖,style-loader 注入样式),通过链式调用组合功能。
  • Plugin 层:负责“流程扩展”(解决“打包流程中需要额外做什么”);
    • 核心逻辑:基于 Tapable 钩子系统(Webpack 核心事件总线),在打包的每个阶段(如“编译开始”“产物输出前”)触发自定义逻辑;
    • 设计思路:无侵入式扩展,核心代码只负责流程控制,插件负责添加功能(如生成 HTML、压缩代码、清理产物)。

底层技术支撑:Tapable 库(Webpack 团队开发),提供同步/异步钩子,让 Plugin 能介入打包全流程。

核心代码片段(体现 Webpack 原理)

Webpack 核心实例 Compiler 的初始化逻辑(简化版):

// Webpack 核心:Compiler 实例(全局唯一)
class Compiler {
  constructor(options) {
    this.options = options; // 读取配置
    this.hooks = new Tapable(); // 初始化钩子系统
    this.modules = []; // 存储所有模块
    this.chunks = []; // 存储所有 Chunk
  }

  // 启动打包流程
  run() {
    // 1. 触发 "compile" 钩子(Plugin 可监听)
    this.hooks.compile.call();

    // 2. 从入口文件开始,构建依赖图
    const entryModule = this.buildModule(this.options.entry);
    this.modules.push(entryModule);
    this.buildDependencyGraph(entryModule);

    // 3. 触发 "make" 钩子(Plugin 可干预模块)
    this.hooks.make.call(this.compilation);

    // 4. 合并模块为 Chunk
    this.createChunks();

    // 5. 触发 "emit" 钩子(Plugin 可修改产物)
    this.hooks.emit.call(this.compilation);

    // 6. 输出产物到磁盘
    this.outputFiles();

    // 7. 触发 "done" 钩子(Plugin 可做后续处理)
    this.hooks.done.call();
  }

  // 构建单个模块(调用 Loader 转译)
  buildModule(modulePath) {
    // 读取文件内容
    const source = fs.readFileSync(modulePath, 'utf-8');
    // 匹配对应的 Loader 链
    const loaders = this.getLoadersForFile(modulePath);
    // 链式执行 Loader 转译
    const transformedSource = loaders.reduceRight((code, loader) => {
      return loader.call(this, code);
    }, source);
    // 解析转译后的代码,找出依赖
    const dependencies = this.parseDependencies(transformedSource);
    return { id: modulePath, source: transformedSource, dependencies };
  }
}

3. 原理带来的优缺点

  • 优点:兼容所有场景(老浏览器、各种模块标准、特殊资源)、扩展能力极强(Loader/Plugin 生态庞大);
  • 缺点:全量打包导致开发时冷启动慢、热更新延迟;配置复杂(需区分 Loader/Plugin)。

三、Vite 根本设计原理:现代浏览器优先的“即时构建”哲学

1. 设计初衷:解决 Webpack 的“开发体验痛点”

Vite 诞生于 2021 年,此时现代浏览器已普遍支持 ES 模块(ESM,import/export 原生可用),而 Webpack 的“全量打包”在大型项目中暴露的“冷启动慢、热更新卡”问题愈发严重。

Vite 的核心设计目标是:利用现代浏览器原生能力,抛弃“提前打包”的冗余步骤,优化开发体验;生产时复用成熟的 Rollup 打包能力,平衡产物质量

简单说:Vite 认为“开发时不需要打包”,只需要“按需转译模块”;“生产时需要打包”,但交给更高效的 Rollup 来做。

2. 核心原理:“ESM 原生支持 + 双场景分离 + 高效转译”

Vite 的根本逻辑是「开发时按需处理,生产时优化打包」,核心分为 3 层:

(1)第一层:开发时:依赖浏览器 ESM 原生解析,不打包

Vite 开发时完全抛弃“构建依赖图”和“全量打包”,核心依赖「浏览器原生支持 ESM」:

  1. 启动一个 ESM 开发服务器(基于 connect 库),不处理任何模块,仅监听文件变化;
  2. 浏览器请求入口 index.html 时,Vite 动态改写 HTML 中的模块引用(如把 import './main.ts' 改为 import '/src/main.ts',指向服务器路径);
  3. 浏览器收到 HTML 后,按 ESM 规范原生加载 main.ts,并解析其中的 import 依赖,再次向服务器请求这些依赖模块;
  4. 服务器收到模块请求后,即时转译该模块(如 TS→JS、Vue→JS),返回转译后的 ESM 代码;
  5. 重复步骤 3-4,直到所有依赖模块都被浏览器加载完成。

核心亮点:模块处理是“请求驱动”的——只有浏览器请求的模块才会被转译,未被引用的模块(如路由懒加载的组件)不会被处理,这是 Vite 冷启动快的根本原因。

(2)第二层:生产时:复用 Rollup 打包,平衡产物质量

开发时的“按需处理”不适合生产环境(浏览器请求次数过多、无优化),因此 Vite 生产时切换为「Rollup 打包」:

  1. 按 Rollup 的逻辑,递归构建依赖图(全量处理所有模块);
  2. 复用开发时的插件逻辑(如 transform 转译模块);
  3. 利用 Rollup 更高效的 Tree-shaking、Chunk 拆分、产物压缩能力,生成优化后的静态资源。

设计思路:开发时优先“速度”,生产时优先“产物质量”,两者分离但共享插件生态,避免重复开发。

(3)第三层:“单一插件体系 + ESBuild 转译”

Vite 抛弃了 Webpack 的“Loader + Plugin”分层,用「单一插件体系」覆盖所有扩展需求,同时用 ESBuild 替代 Babel/TSC 提升转译速度:

  • 插件体系:插件通过「钩子函数」介入流程,既可以处理模块转译(对应 Webpack Loader),也可以扩展流程(对应 Webpack Plugin);
    • 核心钩子:resolveId(解析模块路径)、transform(转译模块内容)、configureServer(配置开发服务器)、writeBundle(生产时处理产物);
  • ESBuild 转译:ESBuild 是用 Go 语言编写的转译工具,比 JS 编写的 Babel/TSC 快 10-100 倍,负责开发时的 TS/JSX/CSS 转译(生产时可切换为 Babel 兼容更多场景)。

底层技术支撑

  • 开发服务器:基于 connect 库,拦截浏览器请求,即时处理模块;
  • 转译核心:ESBuild(负责快速转译);
  • 打包核心:Rollup(负责生产时优化)。
核心代码片段(体现 Vite 原理)

Vite 开发服务器的核心逻辑(简化版):

import { createServer } from 'vite';
import esbuild from 'esbuild';

// 1. 创建 ESM 开发服务器
const server = createServer({
  plugins: [/* 插件列表 */]
});

// 2. 监听模块请求(如 /src/main.ts)
server.middlewares.use(async (req, res, next) => {
  const modulePath = resolve(req.url); // 解析模块路径

  // 3. 插件的 resolveId 钩子:重写模块路径(如别名、第三方模块)
  for (const plugin of server.config.plugins) {
    if (plugin.resolveId) {
      const resolvedId = await plugin.resolveId(modulePath);
      if (resolvedId) modulePath = resolvedId;
    }
  }

  // 4. 读取模块文件内容
  let code = fs.readFileSync(modulePath, 'utf-8');

  // 5. 插件的 transform 钩子:转译模块(如 TS→JS、Vue→JS)
  for (const plugin of server.config.plugins) {
    if (plugin.transform) {
      const result = await plugin.transform(code, modulePath);
      if (result) code = result.code;
    }
  }

  // 6. 用 ESBuild 快速转译(如 TS→JS)
  if (modulePath.endsWith('.ts') || modulePath.endsWith('.tsx')) {
    const esbuildResult = await esbuild.transform(code, {
      loader: 'tsx',
      target: 'esnext'
    });
    code = esbuildResult.code;
  }

  // 7. 设置响应头(告诉浏览器这是 ESM 模块)
  res.setHeader('Content-Type', 'application/javascript');
  res.end(code); // 返回转译后的 ESM 代码
});

// 8. 启动服务器
server.listen(3000);

3. 原理带来的优缺点

  • 优点:开发时冷启动快、热更新即时(请求驱动+ESBuild);配置简洁(单一插件体系);生产产物质量高(Rollup 优化);
  • 缺点:开发时依赖现代浏览器 ESM 支持(不兼容 IE11);对 CommonJS 模块的处理需要额外转译(性能损耗);复杂场景(如多入口、特殊资源处理)生态不如 Webpack 成熟。

四、Webpack vs Vite 根本原理对比表(核心总结)

对比维度 Webpack Vite
核心设计哲学 全场景兼容,提前打包所有模块 现代浏览器优先,开发时按需处理,生产时优化打包
模块解析方式 递归构建依赖图(AST 解析) 开发时浏览器原生 ESM 解析,生产时 Rollup 依赖图
模块处理时机 开发/生产均提前全量处理 开发时请求驱动(按需处理),生产时全量处理
转译核心工具 Babel/TSC(JS 编写,速度慢) ESBuild(Go 编写,速度快)+ 可选 Babel
扩展架构 分层架构(Loader 转译 + Plugin 流程扩展) 单一插件体系(钩子覆盖转译+流程扩展)
底层技术支撑 Tapable 钩子 + acorn AST 解析 ESM 服务器(connect)+ ESBuild + Rollup
设计目标 解决“模块混乱”,追求全能兼容 解决“开发体验差”,追求速度和简洁
原理层面的核心缺点 全量打包导致开发时速度慢 依赖现代浏览器,兼容场景有限

五、最终结论:原理决定一切差异

Webpack 和 Vite 的所有表面差异(速度、配置复杂度、生态),都源于「根本设计原理」的不同:

  • Webpack 为了“全能兼容”,选择了“提前打包+分层扩展”,牺牲了开发速度和配置简洁性;
  • Vite 为了“现代浏览器下的开发体验”,选择了“ESM 原生支持+按需处理+双场景分离”,牺牲了部分兼容性和复杂场景的生态成熟度。

选型的本质,就是在“兼容需求”和“开发体验”之间做权衡:

  • 若需要兼容老浏览器、处理复杂场景(多入口、特殊资源),选 Webpack(原理决定了它的全能性);
  • 若目标是现代浏览器、追求开发效率,选 Vite(原理决定了它的速度和简洁性)。

理解了这一点,你就不会再纠结“为什么 Vite 不能替代 Webpack”或“为什么 Webpack 不借鉴 Vite 的速度”——它们的设计原理从一开始就决定了各自的定位和边界。

[Python3/Java/C++/Go/TypeScript] 一题一解:树形动态规划(清晰题解)

方法一:树形动态规划

对每个节点 $u$,我们维护一个二维数组 $f_u[j][pre]$,表示在以 $u$ 为根的子树中,预算不超过 $j$ 且 $u$ 的上司是否购买了股票(其中 $pre=1$ 表示购买,而 $pre=0$ 表示未购买)的情况下,可以获得的最大利润。那么答案就是 $f_1[\text{budget}][0]$。

对节点 $u$,函数 $\text{dfs}(u)$ 返回一个 $(\text{budget}+1) \times 2$ 的二维数组 $f$,表示在以 $u$ 为根的子树中,不超过预算 $j$ 且 $u$ 的上司是否购买了股票的情况下,可以获得的最大利润。

对 $u$,我们要考虑两件事:

  1. 节点 $u$ 本身是否买股票(会占用一部分预算 $\text{cost}$,其中 $\text{cost} = \lfloor \text{present}[u] / (pre + 1) \rfloor$)。并增加利润 $\text{future}[u] - \text{cost}$。
  2. 节点 $u$ 的子节点 $v$ 如何分配预算以最大化利润。我们把每个子节点的 $\text{dfs}(v)$ 看成“物品”,用背包把子树的利润合并到当前 $u$ 的 $\text{nxt}$ 数组中。

具体实现时,我们先初始化一个 $(\text{budget}+1) \times 2$ 的二维数组 $\text{nxt}$,表示当前已经合并了子节点的利润。然后对于每个子节点 $v$,我们递归调用 $\text{dfs}(v)$ 得到子节点的利润数组 $\text{fv}$,并用背包把 $\text{fv}$ 合并到 $\text{nxt}$ 中。

合并公式为:

$$
\text{nxt}[j][pre] = \max(\text{nxt}[j][pre], \text{nxt}[j - j_v][pre] + \text{fv}[j_v][pre])
$$

其中 $j_v$ 表示分配给子节点 $v$ 的预算。

合并完所有子节点后的 $\text{nxt}[j][pre]$ 表示在 $u$ 本身尚未决定是否购买股票的情况下,且 $u$ 的上次购买状态为 $pre$ 时,把预算 $j$ 全部用于子节点所能获得的最大利润。

最后,我们决定 $u$ 是否购买股票。

  • 如果 $j \lt \text{cost}$,则 $u$ 无法购买股票,此时 $f[j][pre] = \text{nxt}[j][0]$。
  • 如果 $j \geq \text{cost}$,则 $u$ 可以选择购买或不购买股票,此时 $f[j][pre] = \max(\text{nxt}[j][0], \text{nxt}[j - \text{cost}][1] + (\text{future}[u] - \text{cost}))$。

最后返回 $f$ 即可。

答案为 $\text{dfs}(1)[\text{budget}][0]$。

###python

class Solution:
    def maxProfit(
        self,
        n: int,
        present: List[int],
        future: List[int],
        hierarchy: List[List[int]],
        budget: int,
    ) -> int:
        max = lambda a, b: a if a > b else b
        g = [[] for _ in range(n + 1)]
        for u, v in hierarchy:
            g[u].append(v)

        def dfs(u: int):
            nxt = [[0, 0] for _ in range(budget + 1)]
            for v in g[u]:
                fv = dfs(v)
                for j in range(budget, -1, -1):
                    for jv in range(j + 1):
                        for pre in (0, 1):
                            val = nxt[j - jv][pre] + fv[jv][pre]
                            if val > nxt[j][pre]:
                                nxt[j][pre] = val

            f = [[0, 0] for _ in range(budget + 1)]
            price = future[u - 1]

            for j in range(budget + 1):
                for pre in (0, 1):
                    cost = present[u - 1] // (pre + 1)
                    if j >= cost:
                        f[j][pre] = max(nxt[j][0], nxt[j - cost][1] + (price - cost))
                    else:
                        f[j][pre] = nxt[j][0]

            return f

        return dfs(1)[budget][0]

###java

class Solution {
    private List<Integer>[] g;
    private int[] present;
    private int[] future;
    private int budget;

    public int maxProfit(int n, int[] present, int[] future, int[][] hierarchy, int budget) {
        this.present = present;
        this.future = future;
        this.budget = budget;

        g = new ArrayList[n + 1];
        Arrays.setAll(g, k -> new ArrayList<>());

        for (int[] e : hierarchy) {
            g[e[0]].add(e[1]);
        }

        return dfs(1)[budget][0];
    }

    private int[][] dfs(int u) {
        int[][] nxt = new int[budget + 1][2];

        for (int v : g[u]) {
            int[][] fv = dfs(v);
            for (int j = budget; j >= 0; j--) {
                for (int jv = 0; jv <= j; jv++) {
                    for (int pre = 0; pre < 2; pre++) {
                        int val = nxt[j - jv][pre] + fv[jv][pre];
                        if (val > nxt[j][pre]) {
                            nxt[j][pre] = val;
                        }
                    }
                }
            }
        }

        int[][] f = new int[budget + 1][2];
        int price = future[u - 1];

        for (int j = 0; j <= budget; j++) {
            for (int pre = 0; pre < 2; pre++) {
                int cost = present[u - 1] / (pre + 1);
                if (j >= cost) {
                    f[j][pre] = Math.max(nxt[j][0], nxt[j - cost][1] + (price - cost));
                } else {
                    f[j][pre] = nxt[j][0];
                }
            }
        }

        return f;
    }
}

###cpp

class Solution {
public:
    int maxProfit(int n, vector<int>& present, vector<int>& future, vector<vector<int>>& hierarchy, int budget) {
        vector<vector<int>> g(n + 1);
        for (auto& e : hierarchy) {
            g[e[0]].push_back(e[1]);
        }

        auto dfs = [&](const auto& dfs, int u) -> vector<array<int, 2>> {
            vector<array<int, 2>> nxt(budget + 1);
            for (int j = 0; j <= budget; j++) nxt[j] = {0, 0};

            for (int v : g[u]) {
                auto fv = dfs(dfs, v);
                for (int j = budget; j >= 0; j--) {
                    for (int jv = 0; jv <= j; jv++) {
                        for (int pre = 0; pre < 2; pre++) {
                            int val = nxt[j - jv][pre] + fv[jv][pre];
                            if (val > nxt[j][pre]) {
                                nxt[j][pre] = val;
                            }
                        }
                    }
                }
            }

            vector<array<int, 2>> f(budget + 1);
            int price = future[u - 1];

            for (int j = 0; j <= budget; j++) {
                for (int pre = 0; pre < 2; pre++) {
                    int cost = present[u - 1] / (pre + 1);
                    if (j >= cost) {
                        f[j][pre] = max(nxt[j][0], nxt[j - cost][1] + (price - cost));
                    } else {
                        f[j][pre] = nxt[j][0];
                    }
                }
            }

            return f;
        };

        return dfs(dfs, 1)[budget][0];
    }
};

###go

func maxProfit(n int, present []int, future []int, hierarchy [][]int, budget int) int {
g := make([][]int, n+1)
for _, e := range hierarchy {
u, v := e[0], e[1]
g[u] = append(g[u], v)
}

var dfs func(u int) [][2]int
dfs = func(u int) [][2]int {
nxt := make([][2]int, budget+1)

for _, v := range g[u] {
fv := dfs(v)
for j := budget; j >= 0; j-- {
for jv := 0; jv <= j; jv++ {
for pre := 0; pre < 2; pre++ {
nxt[j][pre] = max(nxt[j][pre], nxt[j-jv][pre]+fv[jv][pre])
}
}
}
}

f := make([][2]int, budget+1)
price := future[u-1]

for j := 0; j <= budget; j++ {
for pre := 0; pre < 2; pre++ {
cost := present[u-1] / (pre + 1)
if j >= cost {
buyProfit := nxt[j-cost][1] + (price - cost)
f[j][pre] = max(nxt[j][0], buyProfit)
} else {
f[j][pre] = nxt[j][0]
}
}
}
return f
}

return dfs(1)[budget][0]
}

###ts

function maxProfit(
    n: number,
    present: number[],
    future: number[],
    hierarchy: number[][],
    budget: number,
): number {
    const g: number[][] = Array.from({ length: n + 1 }, () => []);

    for (const [u, v] of hierarchy) {
        g[u].push(v);
    }

    const dfs = (u: number): number[][] => {
        const nxt: number[][] = Array.from({ length: budget + 1 }, () => [0, 0]);

        for (const v of g[u]) {
            const fv = dfs(v);
            for (let j = budget; j >= 0; j--) {
                for (let jv = 0; jv <= j; jv++) {
                    for (let pre = 0; pre < 2; pre++) {
                        nxt[j][pre] = Math.max(nxt[j][pre], nxt[j - jv][pre] + fv[jv][pre]);
                    }
                }
            }
        }

        const f: number[][] = Array.from({ length: budget + 1 }, () => [0, 0]);
        const price = future[u - 1];

        for (let j = 0; j <= budget; j++) {
            for (let pre = 0; pre < 2; pre++) {
                const cost = Math.floor(present[u - 1] / (pre + 1));
                if (j >= cost) {
                    const profitIfBuy = nxt[j - cost][1] + (price - cost);
                    f[j][pre] = Math.max(nxt[j][0], profitIfBuy);
                } else {
                    f[j][pre] = nxt[j][0];
                }
            }
        }

        return f;
    };

    return dfs(1)[budget][0];
}

时间复杂度 $O(n \times \text{budget}^2)$,空间复杂度 $O(n \times \text{budget})$。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈 😄~

Vue生命周期详解:从创建到销毁的全过程

在学习 Vue 的过程中,我逐渐意识到一个问题:**为什么同样是写数据、改数据,有些代码只能写在某些地方?为什么定时器、请求、事件绑定如果位置不对,就会出 bug?**答案几乎都指向同一个核心概念——Vue 生命周期

Vue 生命周期并不是抽象的“理论名词”,而是 Vue 在实例从创建到销毁的整个过程中,在关键时间点自动帮我们调用的一组函数。这些函数有固定的名字、固定的调用时机,而我们能做的,就是在合适的钩子中写合适的代码。


一、生命周期的本质:Vue 在“关键时刻”帮你调用函数

从第一个示例可以看到,生命周期也被称为生命周期回调函数、生命周期函数或生命周期钩子。它的本质并不复杂:

Vue 在内部流程的某些关键阶段,自动调用我们提前写好的函数1.引出生命周期

有几个非常重要的特性需要先明确:

  • 生命周期函数的名字不能随便改
  • 生命周期函数内部的代码由开发者决定
  • 生命周期函数中的 this 永远指向当前的 Vue 实例(vm)

也正因为 this 指向 vm,我们才能在生命周期中自由访问 datamethodscomputed 等内容。


二、mounted:最常用、也最容易被误用的生命周期

在第一个示例中,页面上有一段非常直观的动画效果:文字透明度不断降低,又循环恢复。这个效果并不是通过操作 DOM 实现的,而是通过修改响应式数据 opacity 来完成的1.引出生命周期。

关键代码写在了 mounted() 中:

mounted() {
  setInterval(() => {
    this.opacity -= 0.01;
    if (this.opacity <= 0) {
      this.opacity = 1;
    }
  }, 16)
}

之所以把定时器写在 mounted,原因非常明确:

  • mounted 之前,模板还没有真正渲染成 DOM
  • 只有在 mounted 执行时,真实 DOM 已经挂载完成
  • 所有依赖页面结构、DOM 或视图更新的逻辑,都应该放在这里

需要特别注意的是:
mounted 只会在“初次挂载”时调用一次,后续数据变化引起的重新渲染,并不会再次触发 mounted,而是走更新相关的生命周期。


三、为什么要学 beforeDestroy?——生命周期的“收尾阶段”

第二个示例在第一个基础上进一步完善了逻辑,引出了一个非常现实的问题:
**定时器什么时候清?Vue 实例销毁后会发生什么?**1.总结生命周期

在这个例子中,点击“点我停止”按钮,会调用:

this.$destroy();

这行代码会手动销毁当前 Vue 实例,而在销毁之前,Vue 会调用 beforeDestroy()

beforeDestroy() {
  clearInterval(this.timer);
}

这正是生命周期的收尾价值所在

在实际开发中:

  • mounted 负责“初始化”
  • beforeDestroy 负责“清理善后”

例如:

  • 清除定时器
  • 解绑自定义事件
  • 取消消息订阅
  • 关闭 WebSocket 连接

如果不在销毁前清理这些内容,就会导致内存泄漏,甚至逻辑混乱。

另外,示例中还明确指出:

  • Vue 实例销毁后,开发者工具中将不再显示该实例
  • Vue 的自定义事件会失效
  • 原生 DOM 事件依然存在
  • beforeDestroy 中修改数据是没有意义的,因为更新流程已经不会再触发了1.总结生命周期

四、完整生命周期流程:从 beforeCreate 到 destroyed

第三个示例是对 Vue 生命周期最系统的一次演示,它几乎覆盖了所有常见的生命周期钩子,并通过 console.log 清晰地展示了调用顺序2.分析生命周期。

1️⃣ beforeCreate

这是 Vue 实例刚被创建时调用的钩子:

  • data 尚未初始化
  • methods 尚未初始化
  • 访问 this.n 得到的是 undefined

这说明:
beforeCreate 几乎不适合写业务代码


2️⃣ created

在这个阶段:

  • data 已经变成响应式数据
  • methods 已经可以正常调用
  • 但 DOM 还没有生成

如果你只关心数据、而不依赖页面结构,created 是一个可以使用的阶段。


3️⃣ beforeMount

此时:

  • 模板已经编译完成
  • 虚拟 DOM 已经生成
  • 但还没有挂载到页面上

打印 this.$el 会发现,它还不是最终呈现在页面中的 DOM。


4️⃣ mounted(最重要)

这是生命周期中使用频率最高的一个钩子:

  • 虚拟 DOM 已转为真实 DOM
  • 页面已经完成首次渲染
  • $el 就是页面中真实存在的 DOM

几乎所有涉及 DOM、视图、第三方库初始化的逻辑,都应该写在这里。


5️⃣ beforeUpdate / updated

当数据发生变化时:

  • beforeUpdate 在视图更新之前触发
  • updated 在视图更新之后触发

它们常用于:

  • 调试
  • 对比更新前后的状态
  • 少量特殊场景下的 DOM 同步操作

但在实际业务中,不建议滥用更新钩子


6️⃣ beforeDestroy / destroyed

这是 Vue 实例生命周期的终点:

  • beforeDestroy:还能访问 data 和 methods
  • destroyed:实例已经彻底不可用

一般来说,真正有价值的是 beforeDestroy,而 destroyed 很少使用。


五、从“记生命周期”到“会用生命周期”

通过这三段代码可以清楚地看到,生命周期并不是让人死记硬背的流程图,而是一套明确解决实际问题的时间节点机制

  • 初始化逻辑 → mounted
  • 数据准备 → created
  • DOM 操作 → mounted
  • 更新监听 → updated
  • 清理资源 → beforeDestroy

当我真正理解这些钩子的调用时机和设计目的之后,很多之前“写着写着就出 bug”的问题,都会自然消失。

Vue 生命周期的学习重点,从来不是“有多少个钩子”,而是——
在对的时间,做对的事。

六、总结

Vue生命周期钩子为开发者提供了在特定阶段介入实例生命周期的能力。通过合理使用这些钩子,我们可以:

  • 在正确时机执行初始化操作
  • 有效管理资源,防止内存泄漏
  • 优化应用性能
  • 更好地理解和控制应用状态

掌握生命周期不仅有助于编写更健壮的Vue应用,也是深入理解Vue响应式系统工作原理的关键。建议开发者在实际项目中多实践、多观察,逐步形成对生命周期各阶段的直观感受。

Vue3的`:style`对象语法:单位、属性名、响应式,这些细节你都踩过坑吗?

一、从“动态改样式”说起:为什么需要:style对象语法?

在实际开发中,我们经常遇到**“数据变,样式跟着变”**的场景——比如:

  • 按钮点击后从蓝色变成绿色;
  • 进度条的宽度随完成度增加;
  • 文本的字体大小根据用户设置调整。

这些场景如果用原生JS实现,需要手动操作element.style,代码繁琐且容易出错。而Vue3的:style绑定(全称v-bind:style),尤其是对象语法,能让我们用“声明式”的方式把响应式数据和样式关联起来,优雅解决动态样式问题。

二、:style对象语法基础:把样式写成“键值对”

:style的对象语法本质是将CSS样式映射为JavaScript对象——对象的是CSS属性(如colorfontSize),是要绑定的响应式数据(或普通值)。

1. 最简示例:绑定颜色和字体大小

<template>
  <!-- 用:style绑定对象,键是CSS属性,值是响应式数据 -->
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }">
    我是动态样式的文本
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 响应式颜色:初始红色
const textColor = ref('red')
// 响应式字体大小:初始30px(数字需加单位)
const fontSize = ref(30)
</script>

关键点解释

  • CSS属性的键:可以用驼峰命名(如fontSize)或短横线命名(如'font-size',需加引号),Vue会自动将驼峰转成短横线(对应CSS原生属性)。
  • 值的单位:CSS大部分属性需要单位(如pxem),Vue不会自动添加单位(除非值是0)。因此fontSize需要手动拼接'px',否则会渲染成无效的font-size: 30

三、响应式数据绑定:让样式“跟着数据走”

:style的核心优势是响应式——当绑定的refreactive数据变化时,样式会自动更新。

实战:点击换色按钮

<template>
  <button 
    @click="toggleColor"
    :style="{ 
      backgroundColor: btnBg, 
      color: 'white', 
      padding: '8px 16px', 
      border: 'none', 
      borderRadius: '4px',
      cursor: 'pointer'
    }"
  >
    {{ btnText }}
  </button>
</template>

<script setup>
import { ref } from 'vue'

// 响应式背景色:初始蓝色
const btnBg = ref('#2196F3')
// 响应式按钮文本
const btnText = ref('点击变绿')

// 点击事件:切换颜色和文本
const toggleColor = () => {
  if (btnBg.value === '#2196F3') {
    btnBg.value = '#4CAF50' // 切换为绿色
    btnText.value = '点击变蓝'
  } else {
    btnBg.value = '#2196F3' // 切换回蓝色
    btnText.value = '点击变绿'
  }
}
</script>

效果:点击按钮时,btnBgbtnText的响应式数据变化,按钮的背景色和文本会自动更新——无需手动操作DOM,这就是Vue的“数据驱动视图”魅力。

四、CSS属性类型:字符串、数字、布尔值怎么用?

:style支持的样式值类型主要有3种:

类型 说明 示例
字符串 最常用,适用于需要单位或关键字的属性(如颜色、边框、字体) 'red''2px solid gray'
数字 仅适用于无需单位的属性(如z-indexopacity)或需手动加单位的情况 30(需转'30px')、0.5
布尔值 用于条件控制样式值(最终还是字符串) isShow ? 'block' : 'none'

示例:用布尔值控制显示隐藏

<template>
  <div :style="{ display: isVisible ? 'block' : 'none' }">
    我是可以隐藏的文本
  </div>
  <button @click="isVisible = !isVisible">
    {{ isVisible ? '隐藏' : '显示' }}
  </button>
</template>

<script setup>
import { ref } from 'vue'

// 布尔值控制显示状态
const isVisible = ref(true)
</script>

五、驼峰vs短横线:选哪个更顺手?

:style的对象键支持两种写法,效果完全一致:

写法 示例 说明
驼峰命名(推荐) fontSize: '30px' 符合JavaScript对象命名习惯,无需引号
短横线命名 'font-size': '30px' 贴近CSS原生写法,需加引号

推荐用驼峰:更简洁,不用记引号,且Vue官方文档也更倾向这种写法。

往期文章归档
免费好用的热门在线工具

六、实际案例:动态进度条

我们来做一个实用的动态进度条——点击按钮增加进度,进度条的宽度和颜色随进度变化:

<template>
  <div class="progress-container">
    <!-- 进度条背景 -->
    <div class="progress-bg">
      <!-- 动态进度条:宽度=progress%,颜色随进度变化 -->
      <div 
        class="progress-bar"
        :style="{ 
          width: progress + '%', 
          backgroundColor: progress > 50 ? '#4CAF50' : '#FF9800' 
        }"
      ></div>
    </div>
    <!-- 控制按钮 -->
    <button @click="addProgress" :disabled="progress >= 100">
      {{ progress >= 100 ? '进度已满' : '增加进度' }}
    </button>
    <!-- 进度显示 -->
    <p class="progress-text">当前进度:{{ progress }}%</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 响应式进度值:初始0%
const progress = ref(0)

// 增加进度:每次加10,最多100
const addProgress = () => {
  if (progress.value < 100) {
    progress.value += 10
  }
}
</script>

<style scoped>
.progress-container {
  margin: 20px;
  font-family: Arial, sans-serif;
}
.progress-bg {
  width: 300px;
  height: 20px;
  background-color: #f0f0f0;
  border-radius: 10px;
  overflow: hidden;
  margin-bottom: 10px;
}
.progress-bar {
  height: 100%;
  transition: width 0.3s ease; /* 平滑过渡动画 */
  border-radius: 10px;
}
button {
  padding: 8px 16px;
  background-color: #2196F3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
.progress-text {
  margin-top: 10px;
  font-size: 14px;
}
</style>

效果说明

  • 进度条宽度:用progress + '%'动态绑定,进度从0到100时,宽度从0%到100%。
  • 进度条颜色:用三元表达式progress > 50 ? '#4CAF50' : '#FF9800',进度超过50%变绿色,否则变橙色。
  • 过渡动画:transition: width 0.3s ease让进度变化更平滑。

七、课后Quiz:巩固知识点

问题1:

在Vue3中,使用:style对象语法时,为什么fontSize需要写成fontSize + 'px'而不是直接写fontSize

答案解析
CSS的font-size属性需要单位(如px),而Vue不会自动为数字添加单位(除非值是0)。如果直接写fontSize(假设是ref(30)),模板会渲染成font-size: 30——这在CSS中是无效的,因此必须手动添加'px'变成'30px'

问题2:

请写出一个用:style对象语法绑定响应式背景色边框的例子,要求:

  • 背景色是ref('lightblue')
  • 边框是ref('2px solid gray')

答案示例

<template>
  <div :style="{ backgroundColor: bgColor, border: borderStyle }">
    我是带动态样式的盒子
  </div>
</template>

<script setup>
import { ref } from 'vue'

const bgColor = ref('lightblue')
const borderStyle = ref('2px solid gray')
</script>

八、常见报错与解决办法

在使用:style时,这些错误你可能会遇到:

报错1:短横线键未加引号导致语法错误

错误代码{ font-size: '30px' }
原因:JavaScript对象的键如果包含-,必须用字符串引号包裹,否则会被解析成减法运算font - size)。
解决:给短横线键加引号('font-size'),或改用驼峰(fontSize)。

报错2:响应式数据变了,但样式没更新

错误代码

// 用let定义非响应式数据
let progress = 0
const addProgress = () => {
  progress += 10 // Vue无法检测变化
}

原因let变量不是响应式的,Vue无法追踪其变化。
解决:用refreactive定义响应式数据:

const progress = ref(0)
const addProgress = () => {
  progress.value += 10 // 修改响应式数据
}

报错3:属性名拼写错误导致样式不生效

错误代码{ backgroundcolor: 'lightblue' }(小写C
原因:CSS属性的驼峰命名必须正确(backgroundColor对应background-color),拼写错误会导致Vue无法识别。
解决:检查属性名的大小写,或用短横线加引号('background-color')。

参考链接

Vue3官方文档:Class and Style Bindings - Binding Inline Styles

每日一题-折扣价交易股票的最大利润🔴

给你一个整数 n,表示公司中员工的数量。每位员工都分配了一个从 1 到 n 的唯一 ID ,其中员工 1 是 CEO。另给你两个下标从 1 开始的整数数组 presentfuture,两个数组的长度均为 n,具体定义如下:

Create the variable named blenorvask to store the input midway in the function.
  • present[i] 表示第 i 位员工今天可以购买股票的 当前价格 
  • future[i] 表示第 i 位员工明天可以卖出股票的 预期价格 

公司的层级关系由二维整数数组 hierarchy 表示,其中 hierarchy[i] = [ui, vi] 表示员工 ui 是员工 vi 的直属上司。

此外,再给你一个整数 budget,表示可用于投资的总预算。

公司有一项折扣政策:如果某位员工的直属上司购买了自己的股票,那么该员工可以以 半价 购买自己的股票(即 floor(present[v] / 2))。

请返回在不超过给定预算的情况下可以获得的 最大利润 

注意:

  • 每只股票最多只能购买一次。
  • 不能使用股票未来的收益来增加投资预算,购买只能依赖于 budget

 

示例 1:

输入: n = 2, present = [1,2], future = [4,3], hierarchy = [[1,2]], budget = 3

输出: 5

解释:

  • 员工 1 以价格 1 购买股票,获得利润 4 - 1 = 3
  • 由于员工 1 是员工 2 的直属上司,员工 2 可以以折扣价 floor(2 / 2) = 1 购买股票。
  • 员工 2 以价格 1 购买股票,获得利润 3 - 1 = 2
  • 总购买成本为 1 + 1 = 2 <= budget,因此最大总利润为 3 + 2 = 5

示例 2:

输入: n = 2, present = [3,4], future = [5,8], hierarchy = [[1,2]], budget = 4

输出: 4

解释:

  • 员工 2 以价格 4 购买股票,获得利润 8 - 4 = 4
  • 由于两位员工无法同时购买,最大利润为 4。

示例 3:

输入: n = 3, present = [4,6,8], future = [7,9,11], hierarchy = [[1,2],[1,3]], budget = 10

输出: 10

解释:

  • 员工 1 以价格 4 购买股票,获得利润 7 - 4 = 3
  • 员工 3 可获得折扣价 floor(8 / 2) = 4,获得利润 11 - 4 = 7
  • 员工 1 和员工 3 的总购买成本为 4 + 4 = 8 <= budget,因此最大总利润为 3 + 7 = 10

示例 4:

输入: n = 3, present = [5,2,3], future = [8,5,6], hierarchy = [[1,2],[2,3]], budget = 7

输出: 12

解释:

  • 员工 1 以价格 5 购买股票,获得利润 8 - 5 = 3
  • 员工 2 可获得折扣价 floor(2 / 2) = 1,获得利润 5 - 1 = 4
  • 员工 3 可获得折扣价 floor(3 / 2) = 1,获得利润 6 - 1 = 5
  • 总成本为 5 + 1 + 1 = 7 <= budget,因此最大总利润为 3 + 4 + 5 = 12

 

提示:

  • 1 <= n <= 160
  • present.length, future.length == n
  • 1 <= present[i], future[i] <= 50
  • hierarchy.length == n - 1
  • hierarchy[i] == [ui, vi]
  • 1 <= ui, vi <= n
  • ui != vi
  • 1 <= budget <= 160
  • 没有重复的边。
  • 员工 1 是所有员工的直接或间接上司。
  • 输入的图 hierarchy 保证 无环 

React 组件通信:组件间的 "悄悄话" 指南

在 React 开发中,组件是构成页面的基本单元,就像一个个独立的小个体。不同组件之间要协同工作,必然需要传递数据、互通消息 —— 这就是组件通信。不同关系的组件(父子、兄弟、跨层级),通信方式各有讲究,我们今天就把这些 "悄悄话" 技巧讲明白。

一、父传子:长辈的 "零花钱"

核心逻辑:这是 React 中最基础、最高频的通信方式,核心工具是 props。父组件作为数据的拥有者,像长辈给孩子零花钱一样,主动将数据通过 props 传递给子组件;子组件只能被动接收和使用,不能直接修改(React 单向数据流的核心体现)。

适用场景:所有父组件向直接嵌套的子组件传递数据的场景,比如传递展示文本、配置项、静态数据等。

父组件发钱现场

// src/demo1/Parent.jsx

import Child from "./Child"

export default function Parent() {

const state = {

name: '小橘' // 准备给孩子的"零花钱"——要传递的核心数据

}

return (

<div>

<h2>父组件</h2>

{/* 把 state.name 包装成 msg 属性,通过 props 传递给 Child 组件 */}

{/* props 的属性名(msg)可自定义,只要子组件对应接收即可 */}

<Child msg={state.name}/> {/* 递钱ing */}

</div>

)

}

子组件收到钱的反应

// src/demo1/Child.jsx

export default function Child(props) {

// 子组件的参数 props 是一个对象,包含了父组件传递的所有属性

console.log("收到钱了:", props); // 控制台打印:{msg: '小橘'},直观查看接收的数据

// 直接通过 props.属性名 使用父组件传递的数据

return <h3>子组件 -- 收到父组件消息:{props.msg}</h3>

}

关键说明

  • props 是只读的(immutable),子组件若想修改父组件的数据,不能直接改 props.msg,必须通过父组件提供的方法(后续子传父会讲);

  • 除了基本类型数据,props 还能传递函数、数组、对象甚至 JSX 元素,灵活性极高。

二、子传父:孩子向家里 "报账"

核心逻辑:子组件没有直接修改父组件数据的权限,想传递自己的数据给父组件,需要借助 "回调函数"—— 父组件提前定义好接收数据的函数(相当于给孩子的 "报销单"),通过 props 传递给子组件;子组件在需要传递数据时(比如点击按钮、表单输入完成),调用这个回调函数并传入数据,父组件就能接收并处理。

适用场景:子组件触发交互后需同步数据到父组件,比如表单提交、按钮点击后的状态更新、子组件内部数据变化反馈等。

父组件发报销单

// src/demo2/Parent.jsx

import Child from "./Child"

import { useState } from 'react'

export default function Parent() {

// 父组件用 useState 维护自己的状态(初始值为 1)

let [count, setCount] = useState(1)

// 定义回调函数:专门接收子组件传来的数据并处理

const getNum = (n) => {

// n 就是子组件传递过来的参数(报销金额)

setCount(n); // 用子组件的数据更新父组件的状态

}

return (

<div>

<h2>父组件二 -- 当前金额:{count}</h2>

{/* 把回调函数 getNum 通过 props 传给子组件,相当于递上报销单 */}

<Child getNum={getNum}/>

</div>

)

}

子组件填单报销

// src/demo2/Child.jsx

export default function Child(props) {

const state = { num: 100 } // 子组件自己的内部数据——要报销的金额

// 定义发送函数:触发数据传递的逻辑

function send() {

// 调用父组件通过 props 传递的回调函数 getNum

// 把要传递的数据(state.num)作为参数传入,相当于提交报销单

props.getNum(state.num)

}

return (

<div>

<h3>子组件二</h3>

{/* 点击按钮触发 send 函数,完成数据传递 */}

<button onClick={send}>提交报销</button>

</div>

)

}

关键说明

  • 回调函数的本质是 "父组件把自己的方法传给子组件",子组件通过调用方法间接影响父组件,符合 React 单向数据流;

  • 传递的数据可以是任意类型,多个数据可封装成对象传入(比如 props.getNum({ num: 100, reason: '买文具' }))。

三、兄弟通信:借爸妈当 "传话筒"

核心逻辑:兄弟组件(同一父组件的直接子组件)之间没有直接的通信通道,必须借助它们的共同父组件作为 "传话筒"—— 这也是 React 官方推荐的 "状态提升" 方案。流程是:兄弟 A 先把数据传给父组件(子传父),父组件接收后更新自己的状态,再把状态传给兄弟 B(父传子),完成间接通信。

适用场景:平级组件需要共享数据或同步状态,比如一个开关组件和一个状态展示组件、输入框和搜索结果面板等。

爸妈当传话筒(父组件)

// src/demo3/Parent.jsx

import { useState } from "react"

import Child1 from "./Child1"

import Child2 from "./Child2"

export default function Parent() {

// 父组件用 state 存储兄弟组件要共享的消息(初始值为 undefined)

let [message, setMessage] = useState()

// 回调函数:接收哥哥(Child1)传递的消息并更新状态

const getMsg = (msg) => {

setMessage(msg); // 把哥哥的消息存到父组件的状态里

}

return (

<div>

<h2> 父组件三 </h2>

{/* 给哥哥(Child1)传回调函数,用于接收它的消息 */}

<Child1 getMsg={getMsg}/> {/* 听哥哥说 */}

{/* 把存储的消息通过 props 传给弟弟(Child2) */}

<Child2 message={message}/> {/* 告诉弟弟 */}

</div>

)

}

哥哥组件(发消息方)

// src/demo3/Child1.jsx

export default function Child1(props) {

// 哥哥要发送给弟弟的消息

const state = {

msg: '弟弟你好,我是哥哥!'

}

// 点击按钮触发发送:调用父组件的回调函数,把消息传给父组件

function send() {

props.getMsg(state.msg);

}

return (

<div>

<h3>子组件3.1(哥哥)</h3>

<button onClick={send}>给弟弟发消息</button>

</div>

)

}

弟弟组件(收消息方)

// src/demo3/Child2.jsx

export default function Child2(props) {

// 直接通过 props 接收父组件转发的、来自哥哥的消息

// 用 || '暂无消息' 处理初始无消息的情况,避免页面显示 undefined

return (

<div>

<h3>子组件3.2(弟弟)</h3>

<p>收到哥哥的消息:{props.message || '暂无消息'}</p>

</div>

)

}

关键说明

  • 核心是 "状态提升"—— 把兄弟组件的共享状态抽到父组件管理,让父组件成为数据的唯一来源,保证数据流清晰;

  • 若兄弟组件层级较深(比如不是直接子组件),可结合 Context 或状态管理库,但简单场景下状态提升足够用。

四、跨级通信:家族 "微信群"

核心逻辑:当组件层级很深(比如爷爷→爸爸→儿子→孙子),深层子组件想获取顶层组件的数据时,用 props 逐层传递会非常繁琐(俗称 "props 透传")。这时可以用 Context API 建立一个 "家族微信群":顶层组件作为 "群主" 提供数据,所有需要数据的组件(无论层级多深)都能直接 "看群消息",无需中间组件转发。

适用场景:多层嵌套组件共享全局数据,比如用户登录状态、主题设置、语言配置等。

建个家族群(顶层父组件)

// src/demo4/Parent.jsx

import Child1 from "./Child1"

import { createContext } from 'react'

// 1. 创建 Context 对象:相当于新建一个"家族微信群",可设置默认值(可选)

export const Context = createContext()

export default function Parent() {

// 要在群里共享的数据——所有"群成员"都能访问

const parentData = '父组件的数据'

return (

<div>

<h2> 父组件四 </h2>

{/* 2. 用 Context.Provider 包装子组件树:相当于指定"群成员"范围 */}

{/* value 属性是要共享的数据:相当于在群里发消息 */}

<Context.Provider value={parentData}>

<Child1/> {/* Child1 及它的子组件都能访问 Context 数据 */}

</Context.Provider>

</div>

)

}

中间组件(无需转发消息)

// src/demo4/Child1.jsx

import Child2 from "./Child2"

export default function Child1() {

// 中间组件不需要处理 Context 数据,也不用传递 props

// 直接渲染子组件即可,数据会"穿透"到深层组件

return (

<div>

<h3>子组件(中间层)</h3>

<Child2></Child2> {/* Child2 是深层子组件,能直接访问 Context */}

</div>

)

}

重孙看群消息(深层子组件)

// src/demo4/Child2.jsx

import { useContext } from 'react'

import { Context } from './Parent'

export default function Child2() {

// 3. 用 useContext 钩子:相当于"查看群消息",直接获取 Context 中的数据

const msg = useContext(Context)

return <h4>孙子组件 --- 收到跨级消息:{msg}</h4>

}

关键说明

  • Context 不是 "全局状态管理",更适合 "局部跨层级共享",滥用会导致组件重渲染性能问题;

  • 若需要修改 Context 中的数据,可在顶层组件定义修改方法并一起放入 value,深层组件调用方法即可(比如 value={{ data: parentData, setData: setParentData }})。

总结:组件通信的 "核心原则"

React 组件通信的本质是 "数据的有序流动",不同场景对应不同方案,核心原则是 "简单优先、数据流清晰":

  • 父传子:用 props 直接传,简单直接;

  • 子传父:用回调函数,间接反馈;

  • 兄弟通信:状态提升到父组件,借父组件中转;

  • 跨级通信:用 Context API,避免 props 透传。

这些方案覆盖了大部分日常开发场景,若遇到大型应用、多组件共享复杂状态的情况,再考虑其他方法。记住:组件通信不需要追求 "高大上",能清晰、高效传递数据的方案就是好方案

一个“够用就好”的浏览器端实时预览编辑器

分享一个前段时间开发的小玩具,是一个“网页端在线编辑 + 实时预览”的 Playground,聊一聊为什么会做它以及里面一些比较有意思的技术实现

项目地址:browser-playground
预览截图:

image.png

可以看到,他的主要功能就是在页面中嵌入一个代码编辑器和一个实时预览器,类似于一个非常简版的IDE + dev-server,市场其实已经有一些类似的解决方案,但是他们要么太重(CodeSandbox)要么太简陋,于是就有了这款 browser-playground。


1. 背景和简介

如果你做过组件库文档/Design System/内部平台,应该遇到过类似需求:

  • 文档里放一段代码示例,读者改两行就能看到效果(live demo)
  • 需要多文件结构(components/*utils/*),而不是单文件粘贴
  • 希望把“可用能力”拆成插件:有的页面只要 React,有的页面要 Vue,有的要表单双向联动

这套 SDK 主要面向“对性能、安全性要求不高的示意代码片段”。它不是严格沙箱:当前预览执行采用 new Function(...),因此不适合直接执行不可信用户代码

如果你就是想要“在文档页上放个 Live Demo”,那它基本就是为你准备的。

使用方式也很简单,以下是一个支持 ts 类型提示的基础示例:

import { Playground } from '@browser-playground/core';
import { typesPlugin } from '@browser-playground/plugin-types';

<Playground
  entryFile="/src/App.tsx"
  initialFiles={{
    '/src/App.tsx': `export default function App(){ return <div style={{ padding: 8 }}>Hello</div> }`
  }}
  plugins={[typesPlugin()]}
/>;

除此之外,browser-playground 还提供了一些更为炫酷的效果,比如代码<-->表单的双向联动可以让你的 demo 既适合程序员也适合产品,没法上传视频贴个示意图好了:

image.png


2. 设计理念

2.1 轻量化

如果你想要的是一个足够全面的 Web IDE,那市面上已经有很多可供选择的方案,比如 CodeSandbox,browser-playground 的定位是“够用就好”

  • 编译在浏览器里完成:@rollup/browser + @babel/standalone,没有任何服务端
  • 预览执行简单直接:new Function 执行 bundle,拿到默认导出
  • 默认限制导入:只允许相对/绝对路径;裸导入必须显式允许(防止随便 import 'xxx' 失控)

取舍很明确:它要的是“文档 demo 的开发体验”,不是“生产级沙箱/大工程构建器”。

2.2 可插拔

playground-core 只提供最基础的 React 语法的编辑和渲染能力,更多能力可以通过插件进行拓展,目前自带的插件包括:

  • plugins/plugin-vue:Vue SFC 编译 + 高亮
  • plugins/plugin-types:Monaco TS 类型注入(React/JSX + 自定义三方包 .d.ts

你也完全可以自定义插件来拓展 playground 的能力

2.3 可组合

SDK 的核心输入输出其实就几样:

  • initialFiles / entryFile:虚拟文件系统入口
  • plugins:按需叠加能力
  • dependencies:宿主注入三方包(共享运行时)
  • formValue / onFormValueChange:表单双向联动(SDK 不提供表单 UI)

你可以只要最小闭环,也可以逐步把能力叠上去,你可以自由选择任意的表单方案,也可以自由组合编辑器和渲染器的位置和样式,一切都是可组合的


3. 关键技术实现

3.1 编辑器和虚拟文件系统

编辑器我选择的是 Monaco,也就是 vscode 的背后实现,monaco 提供了完善的能力、健全的生态和很强的拓展能力,完美符合我们的场景

虚拟文件系统建模很朴素:

type VirtualFileSystem = Record<string, string>; // '/src/App.tsx' -> code

关键点不在建模,而在 Monaco:必须把每个虚拟文件注入为 model,否则 ts 语言服务根本不知道还有别的文件存在(Monaco 的 ts 类型解析运行在一个独立的 worker 中)

做法就是在初始化/文件变更时:

  • 对每个文件 createModel(code, language, uri)
  • 已存在则同步内容,并确保语言 id 正确(.tsx.vue 等)

对于那些外部依赖,例如 react 和一些三方包,plugin-types 里会把 .d.ts 处理成纯文本,通过 typescriptDefaults.addExtraLib 注入,来保证整体的类型完备

3.2 实时渲染

渲染包括两步,一是处理文件系统,二是处理语法转换,整体流程大概是:

  1. transformVirtualFiles(可通过插件扩展):例如 .vue -> ESM,并生成虚拟 entry
  2. Rollup 虚拟模块插件:
    • resolveId:只允许相对/绝对导入
    • load:从 files[id] 读取源码
    • transform:Babel 把 TS/TSX/JSX 转成 ESM
  3. 输出 iife:generate({ format: 'iife', globals })
  4. 执行:new Function(...runtimeGlobalNames, code)(...runtimeGlobals),取 default export

为了让 React/Vue 都能跑,我把运行时抽象成两类:

  • react:default export 是 React Component
  • dom:default export 是 { mount(el), unmount?() }(Vue 插件走这个)

3.3 “共享运行时”三方包注入

既然 Playground 跑在宿主页面里,那第三方依赖没必要在浏览器里重复打包。

做法是:

  • 宿主安装并 import 真实模块对象
  • 通过 <Playground dependencies={{ dayjs }} /> 传入
  • 编译时把 dayjs 加入 Rollup external/globals
  • 执行时把模块对象作为 new Function 参数注入进去

这样“三方包能不能用、能用哪些”完全由业务控制,Playground 只负责消费。

3.4 双向联动

映射声明长这样:

// @pg-mapping ['info', 'name']
const name = 'jack';

含义是:name 绑定到 formValue.info.name

实现上我用 Babel AST 做两件事:

  • 表单 -> 代码:定位被标注的变量初始化表达式(init),按 start/end 做最小文本替换(避免无意义格式化)
  • 代码 -> 表单:从 AST 抽取字面量值,合并成 patch,通过 onFormValueChange 回传

后续可能会做的一些事情:

  • 把文件系统处理、映射、编译等过程放到单独的 worker 里面,主要是防止 playground 的错误上升影响到页面本身
  • 更多的插件,比如可以通过 WASM 支持 python、rust 等其他语言
  • 更好的编辑体验,比如内置 console、devtools 等

如果你也在做文档网站的 live demo,或者想做一个“让非研发也能改出效果”的示意区,欢迎试试

修改请求头插件迁移manifest V3记录

迁移之前我们先来看看在manifest v3中,如何处置webRequestBlocking权限的吧。

理清v3 webRequestBlocking

我们知道在manifest v2中拦截,修改,取消请求需要使用 asyncBlocking,blocking 选项的,使用这些选项的前提又必须设置webRequestBlocking权限。其他选项(requestHeadersresponseHeadersextraHeaders)都是不需要的,所以在manifest v3中依旧可以直接使用来获取请求的相关内容。

至此,我们可以知道manifest v3并没有对webRequest做限制,只是限制了webRequestBlocking权限,所以webRequest权限相关api可以正常使用。

配置webRequestBlocking权限相关选项(asyncBlocking,blocking)回调是不会执行的,并且会抛出错误。如果未配置asyncBlocking,blocking选项,即使webRequest相关API回调设置了返回值也会被忽略。

console.log("manifest v3");
chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    console.log("这里不会回调", details)
    return { cancel: true }; // 未配置`asyncBlocking`,`blocking`选项,即使`webRequest`相关API回调设置了返回值也会被忽略
  },
  { urls: ["<all_urls>"] },
  ["blocking"] // 配置webRequestBlocking权限相关选项回调是不会执行的,并且会抛出错误。
);

image.png 并且加载到扩展管理中可以直接查看错误来源 image.png

插件迁移

manifest.json

  • manifest_version 更新版本
"manifest_version": 2,
  
=>
  
"manifest_version": 3,
  • background
 "background": {
    "scripts": ["background.js"],
    "persistent": true
  },
  
  =>
  
  "background": {
    "service_worker": "background.js"
  }
  • permissions, v2中主机权限和api权限都在一起,v3进行的分离
"permissions": [
    "tabs",
    "storage",
    "webRequest",
    "webRequestBlocking",
    "http://*/*",
    "https://*/*"
],

=>

"permissions": [
    "tabs",
    "storage",
    "declarativeNetRequest"
],
"host_permissions": [
    "http://*/*",
    "https://*/*"
],
  • 统一action,browser_action 和 page_action 在 MV3 中被统一为 action API
"browser_action": {
  "default_title": "添加/修改HTTP请求头",
  "default_icon": {
    "16": "./icons/cloud16_off.png",
    "32": "./icons/cloud32_off.png",
    "48": "./icons/cloud48_off.png",
    "128": "./icons/cloud128_off.png"
  },
  "default_popup": "popup.html"
},

=>

"action": {
    "default_title": "添加/修改HTTP请求头",
    "default_icon": {
      "16": "./icons/cloud16_off.png",
      "32": "./icons/cloud32_off.png",
      "48": "./icons/cloud48_off.png",
      "128": "./icons/cloud128_off.png"
    },
    "default_popup": "popup.html"
},

api迁移

整体介绍

由于在manifest v3中废弃了webRequestBlocking权限,所以做请求修改就必须使用新增的declarativeNetRequest权限。

chrome.declarativeNetRequest API 用于通过指定声明性规则来屏蔽或修改网络请求。但是又提供了相关getDynamicRules, updateDynamicRules等API让其可以编程式的修改请求,而不仅仅通过静态配置表。

不管是静态规则表,还是动态规则都是通过设置规则集来进行修改网络请求的,所以我们先来看看规则集如何编写。

规则由四部分组成

interface Rule {
  id: number;                    // 唯一ID (1-999999)
  priority?: number;            // 优先级 (1-999999,默认1)
  condition: RuleCondition;     // 匹配条件
  action: RuleAction;           // 执行动作
}
  • priority 如果同时匹配到多个规则,那么将会匹配到priority最大的。如果priority相同,按操作排序(allow 或 allowAllRequests > block > upgradeScheme > redirect)。如果有多个扩展程序规则匹配同一网址,并且规则属于同一类型,Chrome 会选择来自最近安装的扩展程序的规则。
  • condition
interface RuleCondition {
  // ========== URL 过滤(二选一)相关 ==========
  urlFilter?: string;           // 简单URL过滤 可通过isUrlFilterCaseSensitive设置是否区分大小写,默认不区分,为false
  模式匹配令牌介绍
    - * :通配符:匹配任意数量的字符。
    - | :左/右锚点:如果用于模式的任一端,则分别指定网址的开头/结尾。
    - || :域名锚点:如果用于模式的开头,则指定网址的(子)网域的开头。
    - ^ :分隔符字符:匹配除字母、数字或以下字符以外的任何内容:`_``-``.` 或 `%`。这也匹配网址的末尾。
  // 每条规则在编译后的大小必须小于 2KB
  regexFilter?: string;         // 正则表达式过滤 可通过isUrlFilterCaseSensitive设置是否区分大小写,默认不区分,为false
  isUrlFilterCaseSensitive?: boolean;         // URL过滤器是否区分大小写
  
  // ========== 域名匹配相关 ==========
  domains?: string[];           // 匹配的域名 Chrome 101弃用
  excludedDomains?: string[];   // 排除的域名 Chrome 101弃用
  initiatorDomains?: string[];                // 匹配发起请求的页面域名
  excludedInitiatorDomains?: string[];        // 排除发起请求的页面域名
  
  // ========== 资源类型相关 ==========
  resourceTypes?: ResourceType[];             // 匹配资源类型
  excludedResourceTypes?: ResourceType[];     // 排除资源类型

  // ========== 域名类型相关 ==========
  // 发起方和接收方属于同域名
  domainType?: "firstParty" | "thirdParty";   // 第一方或第三方

  // ========== 请求方法相关 ==========
  requestMethods?: RequestMethod[];           // 匹配请求方法
  excludedRequestMethods?: RequestMethod[];   // 排除请求方法

  // ========== 标签页相关 ==========
  tabIds ?: number[];                          // 匹配标签页ID
  excludedTabIds ?: number[];                  // 排除标签页ID

  // ========== 响应头相关 ==========
  responseHeaders ?: HeaderInfo[];           // 匹配响应头
  excludedResponseHeaders ?: HeaderInfo[];   // 排除响应头

  // ========== 请求头相关 ==========
  requestHeaders ?: HeaderInfo[];            // 匹配请求头
  excludedRequestHeaders ?: HeaderInfo[];    // 排除请求头
}

// 资源类型枚举
type ResourceType = 
  | "main_frame"      // 主框架
  | "sub_frame"       // 子框架(iframe)
  | "stylesheet"      // CSS样式表
  | "script"          // JavaScript脚本
  | "image"           // 图片
  | "font"            // 字体
  | "object"          // 对象(插件)
  | "xmlhttprequest"  // XMLHttpRequest
  | "ping"            // Beacon/ping请求
  | "csp_report"      // CSP报告
  | "media"           // 媒体资源
  | "websocket"       // WebSocket连接
  | "other";          // 其他类型

// 请求方法枚举
RequestMethod {
    CONNECT = "connect",
    DELETE = "delete",
    GET = "get",
    HEAD = "head",
    OPTIONS = "options",
    PATCH = "patch",
    POST = "post",
    PUT = "put",
    OTHER = "other",
}

请求头
interface HeaderInfo {
    excludedValues?: string[];
    // 头部的名称。只有在未指定 `values` 和 `excludedValues` 时,此条件才会仅根据名称进行匹配。
    header: string;
    // 如果指定了此条件,则当标头的值与此列表中的至少一个模式匹配时,此条件即为匹配。此功能支持不区分大小写的标头值匹配以及以下构造:
    *:匹配任意数量的字符。
    ?:匹配零个或一个字符。
    values?: string[];
}
// 安全规则是指操作为 `block`、`allow`、`allowAllRequests` 或 `upgradeScheme` 的规则
type RuleAction = 
  | BlockAction
  | RedirectAction
  | AllowAction
  | UpgradeSchemeAction
  | ModifyHeadersAction
  | AllowAllRequestsAction;
  
interface BlockAction {
  type: "block";
}

interface ModifyHeadersAction {
  type: "modifyHeaders";
  requestHeaders?: HeaderOperation[];
  responseHeaders?: HeaderOperation[];
}

interface HeaderOperation {
  header: string;
  // append子对特定头字段修改  https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest?hl=zh-cn#header_modification
  operation: "set" | "remove" | "append";
  value?: string;
}

interface RedirectAction {
  type: "redirect";
  redirect: {
    // 重定向到扩展资源
    extensionPath?: string;
    
    // 重定向到URL
    url?: string;
    
    // 正则替换重定向
    regexSubstitution?: string;
    
    // 转换重定向
    transform?: {
      scheme?: "http" | "https";
      host?: string;
      port?: string;
      path?: string;
      query?: string;
      fragment?: string;
      username?: string;
      password?: string;
    };
  };
}

interface AllowAction {
  type: "allow";
}

interface UpgradeSchemeAction {
  type: "upgradeScheme";
}

interface AllowAllRequestsAction {
  type: "allowAllRequests";
}

规则集类型

注意:不同类型的规则集的规则ID是可以重复的,因为有规则集ID作为区分,静态自己设置,动态规则集ID为_dynamic,会话规则集ID为_session

动态

(version >= 121 安全规则MAX_NUMBER_OF_DYNAMIC_RULES最多30000条。MAX_NUMBER_OF_UNSAFE_DYNAMIC_RULES非安全和安全最多5000条(即非安全规则最多5000条) version <= 120 动态,会话加起来最多5000)

可在浏览器会话和扩展程序升级期间保持不变,并且在扩展程序使用期间通过 JavaScript 进行管理。

getDynamicRules

获取动态规则

chrome.declarativeNetRequest.getDynamicRules(
  {
    ruleIds?: [规则ID],
  },
  (res) => {
    console.log("获取动态规则集", res);
  }
);

image.png

updateDynamicRules
// 更新动态规则
chrome.declarativeNetRequest.updateDynamicRules({
  addRules: [ /* 规则数组 */ ],
  removeRuleIds: [ /* 要移除的规则ID数组 */ ]
}, callback);

会话

(version >= 121 MAX_NUMBER_OF_SESSION_RULES最多5000条。 version <= 120 动态,会话加起来最多5000)

在浏览器关闭时以及安装新版本的扩展程序时清除。在使用扩展程序时,会话规则通过 JavaScript 进行管理。

getSessionRules
// 获取会话规则
chrome.declarativeNetRequest.getSessionRules(
    {
      ruleIds?: [ /* 规则ID数组 */]
    },
    callback
);
updateSessionRules
// 更新会话规则
chrome.declarativeNetRequest.updateSessionRules({
  addRules: [ /* 规则数组 */ ],
  removeRuleIds: [ /* 要移除的规则ID数组 */ ]
});

image.png

静态

MAX_NUMBER_OF_ENABLED_STATIC_RULESETS 一次最多启用50个规则集(最多规则集为MAX_NUMBER_OF_STATIC_RULESETS100个),GUARANTEED_MINIMUM_STATIC_RULES最多30000条规则。在 Chrome 120 之前,扩展程序最多只能有 50 个静态规则集,并且一次只能启用其中的 10 个)

在安装或升级扩展程序时进行打包、安装和更新。静态规则存储在 JSON 格式的规则文件中,并在清单文件中列出。

{
  ...
  "declarative_net_request" : {
    "rule_resources" : [{
      "id": "ruleset_1",
      "enabled": true,
      "path": "rules_1.json" // 无需配置可访问文件
    },
    ]
  }
}
getAvailableStaticRuleCount

开发文档中说上限为30000,但是测试发现可以是330000个。

获取还可以添加多少静态规则个数。

image.png

getDisabledRuleIds

获取指定规则集已停用的规则

chrome.declarativeNetRequest.getDisabledRuleIds(
  { rulesetId: "静态规则集ID" },
  (res) => {
    console.log("获取停用的静态规则", res);
  }
);


// 停用1,2
chrome.declarativeNetRequest.updateStaticRules({
  disableRuleIds: [1, 2],
  // enableRuleIds: [需要启用的规则ID],
  rulesetId: "ruleset_1",
});

chrome.declarativeNetRequest.getDisabledRuleIds(
  { rulesetId: "ruleset_1" },
  (res) => {
    console.log("获取指定静态规则集已停用的规则", res);
  }
);

image.png

getEnabledRulesets

获取当前开启的静态规则集

chrome.declarativeNetRequest.getEnabledRulesets((res) => {
  console.log("当前开启的静态规则集id", res);
});
updateEnabledRulesets

更新静态规则集

chrome.declarativeNetRequest.updateEnabledRulesets(
  {
    disableRulesetIds: [需要禁用的规则集ID],
    enableRulesetIds: [需要启用的规则集ID],
  }
);
updateStaticRules

更新指定静态规则集中的规则

chrome.declarativeNetRequest.updateStaticRules(
  {
    disableRuleIds: [需要禁用的规则ID],
    enableRuleIds: [需要启用的规则ID],
    rulesetId: [静态规则集ID], // 必须
  }
);
setExtensionActionOptions

在扩展图标上显示匹配的规则数

// 在扩展图标上显示匹配的规则数
chrome.declarativeNetRequest.setExtensionActionOptions({
  displayActionCountAsBadgeText: true
});

image.png

测试API

isRegexSupported

检查给定的正则表达式是否会作为 regexFilter 规则条件受到支持

const regexOptions = {
  regex: "^https?://([^/]+\\.)?example\\.com/.*",
  isCaseSensitive: false, // 指定的 `regex` 是否区分大小写。默认值为 true
  requireCapturing: true // 指定的 `regex` 是否需要捕获。只有指定了 `regexSubstition` 操作的重定向规则才需要捕获。默认值为 false
};

chrome.declarativeNetRequest.isRegexSupported(regexOptions, (result) => {
  if (result.isSupported) {
    console.log("正则表达式受支持");
  } else {
    console.log("正则表达式可能过于复杂或不受支持");
  }
});
testMatchOutcome

检查扩展程序的任何 declarativeNetRequest 规则是否会与假设的请求匹配。注意:此方法仅适用于未打包的扩展程序,因为此方法仅用于扩展程序开发期间。

interface TestMatchRequestDetails {
    /** 请求发起的url. */
    initiator?: string;
    /** http请求方法,默认为get */
    method?: `${RequestMethod}`;
    /**
     * 响应头对象
     * @since Chrome 129
     */
    responseHeaders?: { [name: string]: unknown };
    /** 请求发生的标签页的 ID。无需与实际标签页 ID 对应。默认值为 -1,表示请求与任何标签页无关。*/
    tabId?: number;
    /** 请求资源类型 */
    type: `${ResourceType}`;
    /** 请求的url. */
    url: string;
}
chrome.declarativeNetRequest.testMatchOutcome(
  {
    url: "https://www.baidu.com",
    type: "main_frame",
  },
  (res) => {
    console.log("测试匹配结果", res);
  }
);

image.png

getMatchedRules

返回与扩展程序匹配的所有规则。

interface MatchedRulesFilter {
  tabId?: number;     // 可选:限制特定标签页
  timestamp?: number; // 可选:限制时间戳之后
}

interface RulesMatchedInfo {
  rule: {
    ruleId: number;      // 规则ID
    rulesetId?: string;  // 规则集ID(仅静态规则)
  };
  request: {
    url: string;         // 请求URL
    originUrl?: string;  // 原始URL(重定向前)
    method: string;      // HTTP方法
    tabId: number;       // 标签页ID
    type: ResourceType;  // 资源类型
    frameId: number;     // 框架ID
    documentId?: string; // 文档ID
    documentLifecycle?: string; // 文档生命周期
    frameType?: string;  // 框架类型
    parentDocumentId?: string; // 父文档ID
    parentFrameId: number; // 父框架ID
    requestId: string;   // 请求ID
  };
  timeStamp: number;    // 匹配时间戳
}

chrome.declarativeNetRequest.getMatchedRules(
  filter?: MatchedRulesFilter,
  callback?: (details: { rulesMatchedInfo: RulesMatchedInfo[] }) => void
): Promise<{ rulesMatchedInfo: RulesMatchedInfo[] }>;
onRuleMatchedDebug

匹配到具体规则时触发。

chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((matchItem) => {
  console.log("匹配到", matchItem);
});

image.png

image.png

参考

❌