面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月27日 15:43

Serverless 架构下如何管理状态?

Serverless 架构的核心特征是函数无状态——每次调用可能由不同实例执行,上一次的内存数据在下一次调用时完全不可见。这让状态管理成为 Serverless 应用设计中必须直面的问题。为什么状态管理是 Serverless 的核心难题传统服务器可以依赖进程内存、本地文件系统维持状态,但 Serverless 函数的运行环境随时可能被回收。具体来说有三个关键限制:实例不固定:两次调用大概率落在不同容器上,进程内变量无法复用生命周期短暂:函数执行时间受限(如 AWS Lambda 默认最长 15 分钟),冷启动后实例可能随时销毁并发不可控:同一函数可能同时运行数十个实例,本地状态无法在实例间共享因此,任何需要跨调用持久化的数据,必须借助外部服务。方案一:外部存储服务这是最直接的思路——把状态从函数内部搬到托管存储。数据库方案关系型数据库(PostgreSQL、MySQL)适合结构化、强一致性的业务状态,如用户资料、订单记录。NoSQL 数据库(DynamoDB、MongoDB)则更适配高吞吐、灵活 Schema 的场景。以 DynamoDB 为例,一条记录即可表示一个用户会话状态:{ "userId": "u-1001", "sessionId": "s-abc123", "loginAt": "2026-05-27T10:00:00Z", "cartItems": ["item-A", "item-B"]}DynamoDB 按读写计费,与 Serverless 按需付费模型天然匹配,且单表即可支撑百万级 QPS。缓存方案Redis 或 Memcached 适合高频读写、对延迟敏感的临时状态,如验证码、限流计数器、排行榜。需要注意缓存数据的过期策略,避免状态残留。对象存储方案S3、Azure Blob Storage 适合大文件和冷数据,如用户上传的图片、生成的报表文件。访问延迟较高,不适合热数据。选择建议:热数据用 Redis,结构化数据用 DynamoDB/PostgreSQL,大文件用 S3,按数据特性分层存储。方案二:会话管理Web 应用中用户会话是最典型的跨调用状态,处理方式有三种路径:JWT 无状态会话将用户身份和权限信息编码在 Token 里,函数无需查库即可验证身份:// Lambda 函数中验证 JWTconst jwt = require("jsonwebtoken");exports.handler = async (event) => { const token = event.headers.Authorization?.replace("Bearer ", ""); try { const payload = jwt.verify(token, process.env.JWT_SECRET); return { statusCode: 200, body: JSON.stringify({ userId: payload.sub }) }; } catch { return { statusCode: 401, body: "Unauthorized" }; }};优点是完全无状态,水平扩展无障碍。缺点是 Token 一旦签发无法主动撤销,敏感操作仍需配合黑名单机制。外部会话存储将会话数据存入 Redis,以 sessionId 为键:SET session:abc123 '{"userId":"u-1001","role":"admin"}' EX 3600每次请求先查 Redis 获取会话状态。这种方式支持主动过期和撤销,但引入了外部依赖。Cookie 存储将少量非敏感状态编码在客户端 Cookie 中,适合主题偏好、语言设置等场景。绝不要在 Cookie 中存放敏感信息。方案三:工作流编排当业务涉及多个步骤和长时间运行的任务,单纯靠函数链式调用会难以追踪状态。Step Functions 状态机AWS Step Functions 用声明式 JSON 定义状态流转:{ "Comment": "订单处理流程", "StartAt": "ValidateOrder", "States": { "ValidateOrder": { "Type": "Task", "Resource": "arn:aws:lambda:...:validate", "Next": "ChargePayment" }, "ChargePayment": { "Type": "Task", "Resource": "arn:aws:lambda:...:charge", "Catch": [{ "ErrorEquals": ["PaymentFailed"], "Next": "Refund" }], "Next": "ShipOrder" }, "ShipOrder": { "Type": "Task", "Resource": "arn:aws:lambda:...:ship", "End": true }, "Refund": { "Type": "Task", "Resource": "arn:aws:lambda:...:refund", "End": true } }}Step Functions 自动记录每一步的输入输出和执行状态,支持错误重试和补偿回滚,非常适合订单处理、数据管道等场景。事件驱动方案通过 EventBridge、SQS、Kafka 等消息中间件,以事件而非直接调用的方式在函数间传递状态:函数 A 完成后发布事件到 EventBridge函数 B 订阅事件并继续处理状态随事件体传递,不依赖共享存储这种方式解耦性最好,但调试和链路追踪的复杂度较高。方案四:临时与本地缓存这些方案不适合持久化,但可以优化性能。/tmp 目录:Lambda 提供 512MB–10GB 的临时空间,同一实例的多次调用可复用。但实例回收后数据丢失,不能当持久存储用进程内存缓存:全局变量在实例存活期间有效,适合缓存配置信息或数据库连接。注意这只能减少冷启动开销,不能保证数据在调用间持久# Lambda 进程内存缓存示例(Python)import jsonimport urllib.request_config = None # 实例级缓存def get_config(): global _config if _config is None: # 首次调用时加载,后续调用复用 resp = urllib.request.urlopen("https://config-service/app-config") _config = json.loads(resp.read()) return _configdef handler(event, context): config = get_config() return {"statusCode": 200, "body": json.dumps(config)}如何选择合适的状态管理方案根据场景选择,而非追求统一方案:| 场景 | 推荐方案 | 理由 ||------|---------|------|| 用户认证 | JWT + Redis 黑名单 | 无状态验证,撤销时有兜底 || 购物车 | DynamoDB / Redis | 高频读写,数据量小 || 多步骤业务流程 | Step Functions | 内置状态追踪和错误恢复 || 文件上传处理 | S3 事件触发 | 文件天然适合对象存储 || 配置信息缓存 | 进程内存 | 访问频率高,变更频率低 || 实时数据统计 | Redis + 定期落库 | 内存计算快,持久化保安全 |实践中的三个关键原则第一,优先设计无状态函数。 函数只做计算,状态全部外置。这样函数可以随时被回收和重建,天然适配自动扩缩容。第二,保证幂等性。 网络重试、事件重复投递在 Serverless 环境中很常见,函数必须对同一输入多次执行产生相同结果。常用手段是请求去重键(如订单号+操作类型)和条件写入(如 DynamoDB 的 ConditionExpression)。第三,区分状态的生命周期。 临时状态用缓存,业务状态用数据库,流程状态用状态机,文件状态用对象存储。不要用 Redis 存长期业务数据,也不要用数据库做高频临时缓存。掌握这些方案和选型逻辑,就能在面试中清晰回答 Serverless 状态管理的核心思路和落地策略。
服务端阅读 05月27日 15:43

SVG 如何与 CSS 结合使用?8 种方式从基础到高级动画

SVG 不仅是矢量图形格式,它和 CSS 的结合才是真正释放 SVG 威力的关键。内联 SVG 是 DOM 的一部分,每个形状、路径、文字都可以被 CSS 选中并施加样式、过渡和动画——这是 PNG、WebP 等位图永远做不到的。内联 SVG 是前提只有内联 SVG(直接写在 HTML 中的 <svg> 标签)才能被 CSS 完整控制。通过 <img> 引入的 SVG,外部 CSS 无法选中其内部元素,伪类和动画也会失效。所以如果需要用 CSS 操控 SVG,必须用内联方式。1. 用 CSS 属性替代 SVG 属性SVG 元素支持通过 CSS 设置视觉属性,fill、stroke、stroke-width、opacity 等都可以写在 CSS 规则里,和设置 HTML 元素的 color、background 没有本质区别:.icon-circle { fill: #3b82f6; stroke: #1e40af; stroke-width: 2; opacity: 0.9;}<svg viewBox="0 0 100 100" width="100" height="100"> <circle class="icon-circle" cx="50" cy="50" r="40" /></svg>注意:CSS 属性会覆盖 SVG 元素上的同名属性(style 优先级高于 presentation attributes),所以把样式集中到 CSS 里更好维护。2. 用类名和选择器精确控制SVG 元素和 HTML 一样支持 class、id,CSS 的各种选择器都能用:/* 类选择器 */.logo-path { fill: #111; }/* 后代选择器 */.nav-icon .highlight { fill: #f59e0b; }/* 属性选择器 */circle[data-state="active"] { fill: #10b981; }/* :nth-child */.chart-bar:nth-child(odd) { fill: #6366f1; }.chart-bar:nth-child(even) { fill: #818cf8; }灵活运用选择器,可以避免给每个 SVG 元素加类名,减少标记冗余。3. 伪类实现交互反馈:hover、:focus、:active 对 SVG 元素完全有效,配合 transition 就能做出丝滑的交互效果:.btn-icon { fill: #64748b; transition: fill 0.2s, transform 0.2s; cursor: pointer;}.btn-icon:hover { fill: #3b82f6; transform: scale(1.15);}.btn-icon:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px;}<svg viewBox="0 0 24 24" width="24" height="24"> <path class="btn-icon" tabindex="0" d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01z" /></svg>tabindex="0" 让 SVG 元素可聚焦,配合 :focus-visible 提升键盘可访问性。实际项目中,图标 hover 变色、按钮按下缩放都是这么做的。4. CSS 过渡与关键帧动画过渡(transition)过渡适合状态切换——hover 时变色、展开时位移,简单高效:.sidebar-arrow { transition: transform 0.3s ease; transform-origin: center;}.sidebar-arrow.open { transform: rotate(90deg);}JavaScript 切换 .open 类名即可,不需要操作 SVG 属性。关键帧动画(@keyframes)需要持续或循环的效果用 @keyframes:.spinner { animation: spin 1s linear infinite; transform-origin: center;}@keyframes spin { to { transform: rotate(360deg); }}.pulse-dot { animation: pulse 1.5s ease-in-out infinite;}@keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.3); opacity: 0.6; }}加载旋转、呼吸闪烁,这些是最常见的 SVG CSS 动画场景。5. 描边动画:stroke-dasharray 与 stroke-dashoffset这是 SVG CSS 动画里最出效果的一招。原理很简单:先让 stroke-dasharray 等于路径总长度,整条线变成虚线且间距等于线长,视觉上不可见;然后通过 stroke-dashoffset 从线长过渡到 0,线就"画"出来了。.draw-path { stroke-dasharray: 300; stroke-dashoffset: 300; animation: draw 2s ease forwards;}@keyframes draw { to { stroke-dashoffset: 0; }}<svg viewBox="0 0 200 100" width="200"> <path class="draw-path" d="M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" /></svg>路径总长度可以通过 JavaScript 的 path.getTotalLength() 获取。实际开发中,Logo 描边动画、数据可视化图表的绘制效果,都是这个技术。6. CSS 变量动态控制样式CSS 变量让 SVG 样式变得可配置,一套图形换个主题色只需改几个变量::root { --icon-primary: #3b82f6; --icon-stroke: #1e40af; --icon-hover: #ef4444;}.themed-icon { fill: var(--icon-primary); stroke: var(--icon-stroke); transition: fill 0.2s;}.themed-icon:hover { fill: var(--icon-hover);}在暗色模式下覆盖变量即可,不用写重复的选择器:@media (prefers-color-scheme: dark) { :root { --icon-primary: #60a5fa; --icon-stroke: #93c5fd; --icon-hover: #f87171; }}7. 外部样式表与样式分离小型项目可以在 SVG 的 <style> 标签里写 CSS,但项目规模大了以后,把 SVG 样式抽到外部 CSS 文件更合理——和 HTML 样式统一管理,方便复用和压缩:<!-- HTML --><link rel="stylesheet" href="svg-styles.css" /><svg viewBox="0 0 24 24" class="icon"> <path class="icon-path" d="..." /></svg>/* svg-styles.css */.icon { width: 24px; height: 24px; }.icon-path { fill: currentColor; transition: fill 0.2s; }.icon:hover .icon-path { fill: #3b82f6; }currentColor 是个实用技巧——SVG 的 fill 继承父元素的 color,这样改文字颜色就能同步改图标颜色。8. 响应式 SVG 与媒体查询SVG 配合 viewBox 和 CSS 媒体查询,可以实现真正的响应式图形:<svg viewBox="0 0 400 200" width="100%"> <rect class="responsive-rect" x="10" y="10" width="180" height="180" rx="8" /> <text class="label" x="100" y="105" text-anchor="middle">Hello</text></svg>.responsive-rect { fill: #3b82f6; transition: fill 0.3s;}.label { fill: white; font-size: 16px;}@media (max-width: 600px) { .responsive-rect { fill: #ef4444; } .label { font-size: 12px; }}viewBox 让 SVG 自适应容器宽度,媒体查询根据屏幕尺寸调整样式,两者配合不需要 JavaScript。性能注意事项优先用 transform 和 opacity 做动画,这两个属性不触发重排(reflow),GPU 加速友好。fill、stroke 等属性的变化会触发重绘(repaint),大量元素同时动画时可能掉帧。避免对大量 SVG 元素同时施加复杂动画,可以用 will-change: transform 提示浏览器提前优化,但不要滥用。<img> 引入的 SVG 无法用外部 CSS 控制,需要交互和动画时必须内联。但内联 SVG 会增加 DOM 节点,大型图表类 SVG(数百个节点)要考虑虚拟滚动或懒加载。stroke-dashoffset 动画在低端设备上可能卡顿,路径越长越明显,必要时用 requestAnimationFrame 替代纯 CSS 方案。核心就一点:内联 SVG 的每个元素都是 DOM 节点,CSS 能对 HTML 做的事,对 SVG 照样做。掌握选择器、过渡、关键帧、描边动画这四样,基本覆盖日常开发 90% 的需求。
服务端阅读 05月27日 15:42

SVG 与其他图形格式有什么区别?各有什么优劣?

在前端开发中,选择合适的图形格式直接影响页面性能和用户体验。SVG 作为唯一的 Web 原生矢量格式,与 PNG、JPG、Canvas、WebP 等有着本质区别。理解这些差异是前端面试的高频考点,也是实际项目选型的关键。SVG 与位图格式(PNG/JPG)的本质区别SVG 是基于 XML 的矢量图形,用数学公式描述图形的点和路径;PNG 和 JPG 则是位图,由固定数量的像素点组成。这个根本差异带来了以下不同:缩放表现——SVG 无限放大依然清晰,位图放大后出现锯齿和模糊。一个 1KB 的 SVG 图标在 4K 屏幕上和 1080p 屏幕上显示效果一致,而 PNG 需要提供 @2x、@3x 多个版本才能适配。文件体积——简单图形(图标、logo、几何图形)SVG 体积远小于 PNG。但复杂图像(如照片)用 SVG 描述反而更大,因为每个像素都需要用路径节点表示。可操作性——SVG 可以直接用 CSS 修改颜色、添加动画、响应事件,也能被搜索引擎索引;位图一旦生成就是静态像素,无法单独操作内部元素。适用边界——照片、渐变复杂的图像不适合用 SVG,此时应选 JPG(有损压缩,体积小)或 PNG(无损压缩,支持透明)。SVG 与 Canvas 的核心差异SVG 和 Canvas 都能在浏览器中绘制图形,但工作方式截然不同:渲染模式——SVG 采用保留模式(Retained Mode),每个图形元素都是 DOM 节点,浏览器负责维护整个场景树;Canvas 采用立即模式(Immediate Mode),通过 JavaScript 逐帧绘制像素,画完之后不保留图形对象。<!-- SVG:声明式,每个元素可独立操作 --><svg width="200" height="200"> <circle cx="100" cy="100" r="50" fill="blue" id="myCircle"/></svg><script>// Canvas:命令式,逐帧绘制const canvas = document.getElementById("myCanvas");const ctx = canvas.getContext("2d");ctx.beginPath();ctx.arc(100, 100, 50, 0, Math.PI * 2);ctx.fillStyle = "blue";ctx.fill();</script>事件处理——SVG 的每个元素天然支持 click、hover 等事件,因为它们就是 DOM 节点;Canvas 需要手动计算坐标碰撞检测来实现交互。性能拐点——当图形元素少于 1000 个时,SVG 的 DOM 操作更直观高效;超过 1000 个元素后,SVG 的 DOM 重绘开销急剧上升,Canvas 的像素操作反而更快。数据可视化中的散点图(上万个点)用 Canvas,少量图元的交互图表用 SVG。内存占用——SVG 的 DOM 节点会持续占用内存,复杂场景可能导致页面卡顿;Canvas 只占用像素缓冲区,内存可控。SVG 与 WebP/AVIF 的选择WebP 和 AVIF 是面向照片类图像的现代格式,和 SVG 解决的不是同一个问题:SVG 解决矢量图形的缩放和交互问题WebP 比 JPG 小 25%-35%,支持透明和动画,适合替代 JPG/PNG 做照片展示AVIF 基于 AV1 编解码器,比 WebP 再小 20%-50%,但编码速度慢,适合预生成的静态资源实际项目中,图标和 UI 元素用 SVG,产品图片用 WebP(AVIF 做渐进增强):<picture> <source srcset="photo.avif" type="image/avif"> <source srcset="photo.webp" type="image/webp"> <img src="photo.jpg" alt="产品图片"></picture>SVG 与图标字体的取舍图标字体(如 Font Awesome)曾经是图标方案的主流,但 SVG 图标在多个维度更优:多色支持——字体图标只能是单色,SVG 支持渐变和多色定位精度——字体图标依赖字体的 baseline 对齐,可能出现像素级偏移;SVG 坐标系统精确可控无字体加载问题——字体加载失败时图标显示方框,SVG 内联不存在这个问题字体图标的优势在于兼容老旧浏览器和加载方式简单。新项目建议直接用 SVG sprite 或 SVG 组件方案:<!-- SVG Sprite 方案 --><svg class="icon"><use href="#icon-home"/></svg><!-- React 组件方案 --><HomeIcon size={24} color="currentColor" />如何做出正确的选型决策根据场景做选择,而不是追求统一方案:| 场景 | 推荐格式 | 原因 ||------|----------|------|| 图标、Logo | SVG | 矢量缩放、可交互、体积小 || 产品照片 | WebP/AVIF | 高压缩率、色彩丰富 || 数据图表(少量图元) | SVG | DOM 交互、可访问性 || 数据可视化(海量数据点) | Canvas/WebGL | 渲染性能 || 游戏画面 | Canvas/WebGL | 逐帧控制、GPU 加速 || 简单循环动画 | CSS + SVG | 流畅、可控 || 需要打印的文档 | PDF | 跨平台一致性 |核心原则:能用 SVG 的地方优先用 SVG(缩放无损、可交互、SEO 友好),照片类内容用 WebP/AVIF(压缩率高、加载快),高频重绘场景用 Canvas(性能可控)。三者不是互斥关系,一个页面中同时使用三种方案是常见做法。
服务端阅读 05月27日 15:42

Expo应用中如何选择状态管理方案?Zustand、Redux Toolkit、Jotai实战对比

Expo应用的状态管理选型直接影响项目可维护性和开发效率。目前社区主流方案有Zustand、Redux Toolkit、Jotai和Context API,它们各有适用场景。下面从实际项目出发,逐一分析各方案的用法、优劣势和集成方式。方案概览与选型依据| 方案 | 包大小(gzip) | 心智模型 | 是否需要Provider | 适合规模 ||------|-------------|---------|----------------|---------|| Context API | 0(内置) | 树形共享 | 是 | 小型 || Zustand | ~3KB | 集中式Store | 否 | 中小型 || Redux Toolkit | ~15KB | 集中式Store | 是 | 大型 || Jotai | ~4KB | 原子化 | 否 | 中型 |选型的核心判断依据:项目有多少全局状态、团队规模、是否需要时间旅行调试、以及包大小是否敏感。Context API:小项目的零依赖方案Context API适合主题切换、语言设置、用户登录信息等少量全局状态。不需要引入任何第三方库,但性能隐患在于:Context值变化时,所有消费该Context的组件都会重新渲染。import { createContext, useContext, useState, useMemo, useCallback } from 'react';import { Text, View, Button } from 'react-native';type UserState = { name: string; isLoggedIn: boolean;};type UserContextType = { user: UserState; login: (name: string) => void; logout: () => void;};const UserContext = createContext<UserContextType | null>(null);export function UserProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<UserState>({ name: '', isLoggedIn: false }); const login = useCallback((name: string) => { setUser({ name, isLoggedIn: true }); }, []); const logout = useCallback(() => { setUser({ name: '', isLoggedIn: false }); }, []); const value = useMemo(() => ({ user, login, logout }), [user, login, logout]); return <UserContext.Provider value={value}>{children}</UserContext.Provider>;}// 自定义hook封装,避免组件直接依赖Contextexport function useUser() { const ctx = useContext(UserContext); if (!ctx) throw new Error('useUser must be used within UserProvider'); return ctx;}关键点:用useMemo和useCallback避免不必要的重渲染,用自定义hook封装Context访问并添加错误提示。当全局状态超过3-4个时,建议切换到Zustand。Zustand:Expo项目首选方案Zustand是当前Expo社区推荐度最高的状态管理库。2025年React状态管理调查中,Zustand的"保留率"和"兴趣度"均排名第一。它体积小、API简洁、无需Provider包裹、原生支持React Native。基础用法import { create } from 'zustand';interface CartItem { id: string; name: string; price: number; quantity: number;}interface CartStore { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; totalPrice: () => number;}const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set((state) => { const existing = state.items.find((i) => i.id === item.id); if (existing) { return { items: state.items.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ), }; } return { items: [...state.items, item] }; }), removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id), })), totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),}));在组件中使用function CartScreen() { // 用selector精确订阅,避免不必要的重渲染 const items = useCartStore((s) => s.items); const addItem = useCartStore((s) => s.addItem); const totalPrice = useCartStore((s) => s.totalPrice); return ( <View> {items.map((item) => ( <Text key={item.id}>{item.name} x{item.quantity}</Text> ))} <Text>总计: {totalPrice()}</Text> </View> );}持久化状态Expo应用中经常需要将用户偏好、登录状态等持久化到本地。Zustand提供了persist中间件,配合expo-secure-store使用:import { create } from 'zustand';import { persist, createJSONStorage } from 'zustand/middleware';import * as SecureStore from 'expo-secure-store';// 封装SecureStore适配器const secureStorage = { getItem: async (name: string) => { return await SecureStore.getItemAsync(name); }, setItem: async (name: string, value: string) => { await SecureStore.setItemAsync(name, value); }, removeItem: async (name: string) => { await SecureStore.deleteItemAsync(name); },};interface SettingsStore { theme: 'light' | 'dark'; locale: string; setTheme: (theme: 'light' | 'dark') => void; setLocale: (locale: string) => void;}const useSettingsStore = create<SettingsStore>()( persist( (set) => ({ theme: 'light', locale: 'zh', setTheme: (theme) => set({ theme }), setLocale: (locale) => set({ locale }), }), { name: 'app-settings', storage: createJSONStorage(() => secureStorage), } ));调试技巧Zustand可以通过devtools中间件连接Redux DevTools。在Expo开发构建中,有社区插件可以直接在Expo Go中调试Zustand store。Redux Toolkit:大型团队的结构化选择Redux Toolkit适合10人以上团队、50+个store模块的大型应用。它的强项在于严格的代码规范和强大的调试工具,但样板代码多、学习成本高。Store配置与Sliceimport { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';import { Provider, useSelector, useDispatch } from 'react-redux';// 异步action:处理API请求export const fetchProducts = createAsyncThunk( 'products/fetch', async (category: string) => { const response = await fetch(`/api/products?category=${category}`); return response.json(); });const productsSlice = createSlice({ name: 'products', initialState: { items: [] as Product[], loading: false, error: string | null, }, reducers: { clearProducts: (state) => { state.items = []; }, }, extraReducers: (builder) => { builder .addCase(fetchProducts.pending, (state) => { state.loading = true; state.error = null; }) .addCase(fetchProducts.fulfilled, (state, action) => { state.loading = false; state.items = action.payload; }) .addCase(fetchProducts.rejected, (state, action) => { state.loading = false; state.error = action.error.message ?? 'Unknown error'; }); },});// 在Expo应用根组件包裹Providerconst store = configureStore({ reducer: { products: productsSlice.reducer, },});function App() { return ( <Provider store={store}> <RootLayout /> </Provider> );}Redux Toolkit的优势在于团队协作:严格的单向数据流和action日志让多人协作时状态变更可追溯。如果你的团队已经在用Redux且没有明显痛点,不必迁移。Jotai:细粒度原子化状态Jotai的原子化模型适合组件间有复杂依赖关系的场景,比如表单构建器、数据看板等。每个atom独立存在,只有订阅了该atom的组件才会在其变化时重新渲染。import { atom, useAtom } from 'jotai';// 基础atomconst filterAtom = atom('all');const searchQueryAtom = atom('');// 派生atom:依赖其他atom,自动缓存计算结果const filteredItemsAtom = atom((get) => { const filter = get(filterAtom); const query = get(searchQueryAtom); return allItems.filter((item) => { const matchFilter = filter === 'all' || item.category === filter; const matchQuery = item.name.toLowerCase().includes(query.toLowerCase()); return matchFilter && matchQuery; });});// 可写派生atom:同时读写const toggleFilterAtom = atom( (get) => get(filterAtom), (get, set, newFilter: string) => { set(filterAtom, newFilter); set(searchQueryAtom, ''); // 切换分类时清空搜索 });function FilterBar() { const [filter, setFilter] = useAtom(toggleFilterAtom); const [query, setQuery] = useAtom(searchQueryAtom); return ( <View> <TextInput value={query} onChangeText={setQuery} placeholder="搜索..." /> <Picker selectedValue={filter} onValueChange={setFilter}> <Picker.Item label="全部" value="all" /> <Picker.Item label="电子产品" value="electronics" /> </Picker> </View> );}function ItemList() { const [items] = useAtom(filteredItemsAtom); return ( <FlatList data={items} keyExtractor={(item) => item.id} renderItem={({ item }) => <Text>{item.name}</Text>} /> );}Jotai的派生atom机制让状态依赖关系清晰可读,但atom数量多时管理成本上升,适合状态依赖复杂但总量可控的场景。服务端状态:别忘了TanStack Query以上方案管理的是客户端状态。对于API请求、缓存、后台刷新等服务端状态,应该使用TanStack Query(React Query)。它和任何客户端状态管理库可以并存:import { useQuery } from '@tanstack/react-query';function ProductList({ category }: { category: string }) { const { data, isLoading, error, refetch } = useQuery({ queryKey: ['products', category], queryFn: () => fetchProducts(category), staleTime: 5 * 60 * 1000, // 5分钟内不重新请求 }); if (isLoading) return <ActivityIndicator />; if (error) return <Text>加载失败</Text>; return ( <FlatList data={data} keyExtractor={(item) => item.id} renderItem={({ item }) => <ProductCard product={item} />} refreshing={false} onRefresh={refetch} /> );}将服务端状态和客户端状态分离是Expo应用架构的重要原则:TanStack Query管API数据,Zustand或Jotai管UI状态。实战选型建议1-5个页面的个人项目:Context API足够,不用引入额外依赖。5-20个页面的中型项目:Zustand。API简单,3KB体积对移动端友好,持久化中间件开箱即用。20+页面、多人协作的大型项目:Redux Toolkit。结构化规范减少沟通成本,DevTools让问题排查效率翻倍。状态依赖复杂的表单/看板类应用:Jotai。派生atom让计算逻辑内聚,避免props层层传递。有大量API交互的项目:Zustand + TanStack Query组合。Zustand管UI和导航状态,TanStack Query管服务端缓存和同步。无论选择哪个方案,注意三条原则:优先使用局部状态而非全局状态;用selector精确订阅,避免整棵状态树触发重渲染;移动端对包大小敏感,能不引入的依赖就不引入。
服务端阅读 05月27日 15:38

SVG 的坐标系统和 viewBox 变换原理是什么?

SVG 坐标系的基本模型SVG 使用笛卡尔坐标系,但和数学课本上的不同:原点 (0,0) 在画布左上角,x 轴向右为正,y 轴向下为正。这个设定和浏览器渲染一致,也意味着"向上移动"对应的 y 值是负数。理解 SVG 坐标系要抓住两个层次:视口(viewport):由 <svg> 的 width 和 height 决定,是 SVG 在页面上占据的实际像素区域。width="200" height="200" 就是 200x200 像素的画布。用户坐标系(user coordinate system):SVG 内部绘图使用的逻辑坐标空间。默认一个用户单位等于一个像素,但 viewBox 会改变这个映射关系。单位方面,SVG 支持 px、em、rem、cm、mm、% 等,无单位数字默认等同于 px。实际开发中绝大多数场景用无单位数字就够了。viewBox 做了什么?viewBox 是 SVG 里最关键的属性之一,它定义内部逻辑坐标系的范围,再将该范围映射到视口上。语法为 viewBox="min-x min-y width height"。<svg viewBox="0 0 100 100" width="200" height="200"> <circle cx="50" cy="50" r="40" fill="red" /></svg>这里 viewBox="0 0 100 100" 声明逻辑坐标系为 100x100,视口为 200x200 像素。浏览器计算缩放比:水平 200/100=2,垂直 200/100=2,所以逻辑坐标中 1 个单位等于 2 个像素。圆心在逻辑坐标 (50,50),实际渲染在视口的 (100,100) 像素位置。viewBox 的三个核心作用:解耦绘图尺寸与渲染尺寸:图标设计常用 viewBox="0 0 24 24",因为 24 的网格便于对齐和计算,实际显示大小由外部 CSS 控制。实现响应式缩放:设置 width="100%" 配合 viewBox,SVG 自动适配容器大小,内部坐标无需改动。控制可视区域:调整 min-x 和 min-y 可以平移可视区域,类似"镜头移动"。viewBox="-50 -50 200 200" 相当于将坐标系原点向右下偏移 50 个单位,让你看到原点左上方的内容。preserveAspectRatio 如何处理宽高比不一致?当 viewBox 的宽高比和视口不一致时,preserveAspectRatio 决定 SVG 内容如何适配视口。语法为 preserveAspectRatio="align meetOrSlice":align:对齐方式,由 x 方向(xMin / xMid / xMax)和 y 方向(YMin / YMid / YMax)组合,共 9 种。meetOrSlice:缩放策略,meet 保持比例完整显示(可能留白),slice 保持比例填充区域(可能裁切)。<!-- 居中完整显示,保持比例(默认值) --><svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" width="300" height="200"> <rect x="0" y="0" width="100" height="100" fill="blue" /></svg><!-- 居中裁切填充,保持比例 --><svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice" width="300" height="200"> <rect x="0" y="0" width="100" height="100" fill="blue" /></svg><!-- 拉伸变形,忽略原始比例 --><svg viewBox="0 0 100 100" preserveAspectRatio="none" width="300" height="200"> <rect x="0" y="0" width="100" height="100" fill="blue" /></svg>日常开发中 xMidYMid meet 是最常用的默认配置。none 一般避免使用,除非确实需要拉伸效果(比如背景图案)。一个典型的 slice 场景是全屏背景图:你希望图片铺满容器,宁可裁切也不留白。SVG transform 有哪些变换类型?SVG 的 transform 属性支持以下变换函数:translate — 平移将元素沿 x 和 y 方向移动指定距离。只写一个值时 y 方向默认为 0。<rect x="10" y="10" width="50" height="50" fill="red" transform="translate(100, 80)" />注意:SVG 的 translate 是相对于 SVG 画布当前坐标系原点,而非元素自身位置。CSS 的 translate 则是相对于元素自身的,这点容易混淆。rotate — 旋转rotate(angle, cx, cy) 指定旋转角度(度)和旋转中心坐标。省略旋转中心时默认围绕当前坐标系原点 (0,0) 旋转。<!-- 围绕元素中心旋转 45 度 --><rect x="0" y="0" width="50" height="50" fill="blue" transform="rotate(45, 25, 25)" /><!-- 围绕坐标系原点旋转 30 度 --><rect x="100" y="100" width="50" height="50" fill="green" transform="rotate(30)" />这是 SVG transform 和 CSS transform 的一个重要差异:CSS 默认以元素中心为旋转原点(transform-origin: 50% 50%),SVG 默认以坐标系原点。在 SVG 中想让元素绕自身中心旋转,必须手动指定 cx, cy,或者用 translate + rotate + translate 三步模拟。scale — 缩放scale(sx, sy) 分别指定水平和垂直方向的缩放倍数。只写一个值时 sy 等于 sx。<!-- 等比放大 2 倍 --><rect x="50" y="50" width="30" height="30" fill="green" transform="scale(2)" /><!-- 水平放大 1.5 倍,垂直缩小到 0.5 倍 --><rect x="50" y="50" width="30" height="30" fill="yellow" transform="scale(1.5, 0.5)" />关键细节:scale 以坐标系原点为缩放中心,同时会改变元素的坐标位置。transform="scale(2)" 会让位于 (50,50) 的元素实际渲染到 (100,100)。想让元素在原位放大,需要先 translate 到原点,再 scale,再 translate 回去。skewX / skewY — 倾斜沿 x 轴或 y 轴方向倾斜指定角度。<rect x="50" y="50" width="50" height="50" fill="purple" transform="skewX(30)" /><rect x="50" y="50" width="50" height="50" fill="orange" transform="skewY(20)" />倾斜在日常开发中用得较少,但在制作平行四边形、梯形等几何效果时会派上用场。matrix — 矩阵变换所有变换最终都归结为矩阵运算。matrix(a, b, c, d, e, f) 对应变换矩阵:| a c e || b d f || 0 0 1 |其中 a 和 d 控制缩放,b 和 c 控制倾斜,e 和 f 控制平移。其他变换函数本质上是 matrix 的语法糖:translate(tx, ty) = matrix(1, 0, 0, 1, tx, ty)scale(s) = matrix(s, 0, 0, s, 0, 0)rotate(a) = matrix(cos(a), sin(a), -sin(a), cos(a), 0, 0)直接用 matrix 的场景不多,但在需要高性能批量变换(如 Canvas 导出 SVG、复杂动画插值)时更高效。组合变换多个变换函数写在同一个 transform 属性中,按从右到左的顺序依次应用(矩阵乘法的右乘规则):<rect x="0" y="0" width="50" height="50" fill="red" transform="translate(100, 100) rotate(45) scale(1.5)" />变换顺序非常重要:translate -> rotate -> scale 和 rotate -> translate -> scale 的结果完全不同。每次变换都在修改当前坐标系,后续变换基于已修改的坐标系执行。实践中推荐"先缩放、再旋转、最后平移"的顺序(SRT),这样平移的方向不受旋转影响,缩放也不影响平移距离。SVG transform 和 CSS transform 有什么区别?这是面试中的高频混淆点:| 对比项 | SVG transform | CSS transform ||--------|--------------|---------------|| 变换原点 | 默认为当前坐标系原点 (0,0) | 默认为元素中心 (50% 50%) || 语法 | 属性写在元素上:transform="rotate(45)" | 样式写在 CSS 中:transform: rotate(45deg) || 单位 | rotate 不需要单位 | rotate 必须带 deg/rad 等单位 || 坐标系 | 相对于 SVG 当前用户坐标系 | 相对于元素自身的包含块 || transform-origin | 不支持(需手动 translate 模拟) | 支持 transform-origin 属性 |在 SVG 2 规范中,SVG 的 transform 属性和 CSS transform 属性正在统一。现代浏览器已支持在 SVG 元素上使用 CSS transform,这意味着你可以用 transform-origin: center 来简化旋转操作。但在需要兼容旧浏览器时,仍要注意区别。另一个容易忽略的细节:给 SVG 元素加 CSS transform 时,变换原点仍然默认是 (0,0) 而非元素中心,这和普通 HTML 元素不同。需要显式设置 transform-origin 才能改变行为。嵌套坐标系的运作方式<g> 元素可以创建局部坐标系,其 transform 属性会影响所有子元素。绘制复杂图形时,把一组元素看作整体进行变换比逐个操作高效得多。<svg viewBox="0 0 200 200"> <g transform="translate(50, 50)"> <circle cx="0" cy="0" r="20" fill="red" /> <circle cx="50" cy="0" r="20" fill="blue" /> <circle cx="25" cy="43" r="20" fill="green" /> </g></svg><g> 上的 transform="translate(50, 50)" 为所有子元素建立新的局部坐标系,原点偏移到 (50,50)。三个圆的坐标都相对于这个新原点。嵌套可以多层叠加,外层变换会传递到内层:<g transform="translate(50, 50)"> <g transform="rotate(30)"> <rect x="0" y="0" width="40" height="40" fill="teal" /> </g></g>矩形先在父 <g> 的平移坐标系中旋转 30 度,再随父 <g> 整体平移。这和 transform="translate(50,50) rotate(30)" 写在同一个元素上效果一致。实际项目中,嵌套坐标系最常见的应用是组件化图形:比如数据可视化中,每个图表模块用一个 <g> 包裹,通过外层 translate 定位,内部元素用相对坐标绘制,互不干扰。动画中的坐标变换注意事项SVG 坐标变换在动画场景有几个容易踩的坑:CSS 动画和 SVG 属性动画的坐标系不同:用 CSS @keyframes 做 transform 动画时,变换原点遵循 CSS 规则;用 SVG 的 <animateTransform> 时遵循 SVG 规则。两者混用会导致意外行为。transform 不可叠加:SVG 的 <animateTransform> 的 additive 属性默认为 replace,多个动画会互相覆盖。需要 additive="sum" 才能叠加。缩放动画的坐标偏移:对带有 x、y 属性的元素做 scale 动画,元素会向原点方向移动(因为坐标值也被缩放了)。解决方案是用 <g> 包裹,对 <g> 做动画。实际开发中的核心要点始终设置 viewBox:让 SVG 具备响应式能力,避免硬编码固定尺寸。图标用 viewBox="0 0 24 24" 或 viewBox="0 0 16 16",通过 CSS 控制实际显示大小。用 viewBox 而非 width/height 控制缩放:设置 width="100%" 或不设宽高,通过 CSS 控制尺寸,viewBox 负责逻辑坐标映射。注意 transform 顺序:不同顺序产生不同结果,推荐 SRT 顺序(Scale -> Rotate -> Translate)。用 <g> 组织和变换元素组:减少重复代码,提高可维护性,也便于动画控制。SVG 旋转需指定中心点:CSS 中可以 transform-origin: center,SVG 中必须手动写 rotate(angle, cx, cy) 或用 translate 模拟。避免在 SVG 内部使用百分比坐标:百分比在 SVG 中的计算规则复杂,优先使用 viewBox 内的绝对坐标值。CSS transform 和 SVG 属性 transform 不要混用:在同一元素上同时设置两者,行为在不同浏览器中可能不一致。选一种方式贯彻到底。
服务端阅读 05月27日 15:38

如何在 Astro 项目中使用 Vitest 和 Playwright 进行测试?

Astro 项目中的测试涉及单元测试、组件测试和端到端测试三个层次,不同层次的测试需要搭配不同的工具和策略。实际开发中,Vitest 负责 .ts 工具函数和框架组件的单元测试,Playwright 负责页面级别的端到端测试,而 .astro 组件的测试则需要借助 Container API 或 vitest-browser-astro 方案。测试框架选型Vitest 是 Astro 官方推荐的单元测试框架,它基于 Vite 构建,与 Astro 的构建体系天然兼容,配置简单且执行速度快。如果你的项目已经使用 Vite,Vitest 几乎可以零配置接入。Playwright 是端到端测试的首选,支持 Chromium、Firefox 和 WebKit 三大浏览器引擎,能够在真实浏览器环境中模拟用户操作,验证页面导航、表单提交、交互逻辑等完整流程。Jest 虽然生态成熟,但需要额外的 transform 配置才能处理 Astro 项目中的 TypeScript 和 JSX,配置成本较高,一般不推荐在 Astro 项目中使用。安装测试依赖# 安装 Vitestnpm install -D vitest# 安装 vitest-browser-astro(用于测试 .astro 组件)npm install -D vitest-browser-astro @vitest/browser-playwright playwright# 安装框架组件测试工具(按需选择)npm install -D @testing-library/react # React 组件npm install -D @testing-library/vue # Vue 组件npm install -D @vue/test-utils # Vue 组件(替代方案)# 安装 Playwrightnpm install -D @playwright/testnpx playwright install配置 VitestAstro 提供了 getViteConfig() 辅助函数,可以自动将项目的 Astro 配置应用到 Vitest,避免手动对齐 Vite 插件和路径别名。// vitest.config.ts/// <reference types="vitest" />import { getViteConfig } from 'astro/config';export default getViteConfig({ test: { // 服务端代码测试用 node 环境 environment: 'node', globals: true, setupFiles: ['./src/test/setup.ts'], },});如果部分测试依赖 DOM 环境,可以在单个测试文件顶部通过注释指定:// @vitest-environment jsdomimport { describe, it, expect } from 'vitest';getViteConfig() 还支持第二个参数,用于在测试中覆盖 Astro 配置:export default getViteConfig( { test: { environment: 'node' } }, { site: 'https://example.com/', trailingSlash: 'always' });测试 .astro 组件.astro 组件是服务端渲染的,不能像 React 组件那样直接用 Testing Library 的 render 方法。Astro 提供了两种测试方案:方案一:Container APIContainer API 是 Astro 内置的实验性功能,可以在服务端渲染 .astro 组件并返回 HTML 字符串:// src/components/__tests__/Card.test.tsimport { describe, it, expect } from 'vitest';import { Container } from 'astro:container';import Card from '../Card.astro';describe('Card 组件', () => { it('渲染标题文本', async () => { const container = await Container.create(); const result = await container.renderToString(Card, { props: { title: 'Hello Astro' }, }); expect(result).toContain('Hello Astro'); });});方案二:vitest-browser-astrovitest-browser-astro 将组件渲染到真实浏览器 DOM 中,支持更丰富的交互断言:// vitest.config.ts 中添加插件import { getViteConfig } from 'astro/config';import { astroRenderer } from 'vitest-browser-astro/plugin';import { playwright } from '@vitest/browser-playwright';export default getViteConfig({ plugins: [astroRenderer()], test: { browser: { enabled: true, instances: [{ browser: 'chromium' }], provider: playwright(), headless: true, }, },});// src/components/__tests__/Card.test.tsimport { render } from 'vitest-browser-astro';import { expect, test } from 'vitest';import Card from '../Card.astro';test('渲染卡片标题', async () => { const screen = await render(Card, { props: { title: 'Hello Astro' }, }); await expect.element(screen.getByText('Hello Astro')).toBeVisible();});如果组件中嵌套了 React 或 Vue 框架组件,需要在配置中注册对应的 container renderer:import { getContainerRenderer } from '@astrojs/react';const container = await Container.create({ renderers: [getContainerRenderer()],});注意:同一个配置中只能使用一种 JSX 框架 renderer,Vue 和 Svelte 等非 JSX 框架可以与 JSX 框架共存。测试框架组件React 组件测试// src/components/__tests__/Counter.test.tsx// @vitest-environment jsdomimport { describe, it, expect } from 'vitest';import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import Counter from '../Counter';describe('Counter', () => { it('显示初始计数值', () => { render(<Counter initialCount={0} />); expect(screen.getByText('Count: 0')).toBeInTheDocument(); }); it('点击按钮后计数值加一', async () => { const user = userEvent.setup(); render(<Counter initialCount={0} />); await user.click(screen.getByRole('button', { name: 'Increment' })); expect(screen.getByText('Count: 1')).toBeInTheDocument(); });});Vue 组件测试// src/components/__tests__/TodoList.test.ts// @vitest-environment jsdomimport { describe, it, expect } from 'vitest';import { mount } from '@vue/test-utils';import TodoList from '../TodoList.vue';describe('TodoList', () => { it('渲染待办事项列表', () => { const todos = [ { id: 1, text: 'Learn Astro', completed: false }, { id: 2, text: 'Build app', completed: true }, ]; const wrapper = mount(TodoList, { props: { todos } }); expect(wrapper.findAll('.todo-item')).toHaveLength(2); expect(wrapper.text()).toContain('Learn Astro'); }); it('勾选完成状态后触发事件', async () => { const wrapper = mount(TodoList, { props: { todos: [{ id: 1, text: 'Task', completed: false }] }, }); await wrapper.find('input[type="checkbox"]').setValue(true); expect(wrapper.emitted('complete')).toBeTruthy(); });});测试 API 路由Astro 的 API 路由导出 GET、POST 等函数,可以直接在测试中调用:// src/pages/api/__tests__/users.test.tsimport { describe, it, expect, beforeEach, vi } from 'vitest';import { GET, POST } from '../users';describe('Users API', () => { beforeEach(() => { vi.clearAllMocks(); }); it('GET 返回用户列表', async () => { const request = new Request('http://localhost/api/users'); const response = await GET({ request } as any); const data = await response.json(); expect(response.status).toBe(200); expect(Array.isArray(data.users)).toBe(true); }); it('POST 创建新用户', async () => { const request = new Request('http://localhost/api/users', { method: 'POST', body: JSON.stringify({ name: 'John', email: 'john@example.com' }), }); const response = await POST({ request } as any); const data = await response.json(); expect(response.status).toBe(201); expect(data.name).toBe('John'); });});测试中间件Astro 中间件的 onRequest 函数可以像普通函数一样测试,关键是构造正确的 context 对象:// src/middleware/__tests__/auth.test.tsimport { describe, it, expect, vi } from 'vitest';import { onRequest } from '../auth';describe('认证中间件', () => { it('无 token 时重定向到登录页', async () => { const request = new Request('http://localhost/dashboard'); const redirect = vi.fn(); await onRequest({ request, redirect } as any); expect(redirect).toHaveBeenCalledWith('/login'); }); it('有效 token 时放行请求', async () => { const request = new Request('http://localhost/dashboard', { headers: { Authorization: 'Bearer valid-token' }, }); const next = vi.fn().mockResolvedValue(new Response()); await onRequest({ request, next } as any); expect(next).toHaveBeenCalled(); });});端到端测试(Playwright)Playwright 测试在真实浏览器中运行,需要先启动开发服务器。在 playwright.config.ts 中配置 webServer:// playwright.config.tsimport { defineConfig } from '@playwright/test';export default defineConfig({ webServer: { command: 'npm run dev', port: 4321, reuseExistingServer: !process.env.CI, }, testDir: './e2e',});编写端到端测试:// e2e/navigation.spec.tsimport { test, expect } from '@playwright/test';test.describe('页面导航', () => { test('首页加载正常', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/My Astro App/); await expect(page.locator('h1')).toContainText('Welcome'); }); test('导航到关于页', async ({ page }) => { await page.goto('/'); await page.click('text=About'); await expect(page).toHaveURL(/\/about/); }); test('表单提交成功', async ({ page }) => { await page.goto('/contact'); await page.fill('input[name="name"]', 'John'); await page.fill('input[name="email"]', 'john@example.com'); await page.fill('textarea[name="message"]', 'Hello!'); await page.click('button[type="submit"]'); await expect(page.locator('.success-message')).toBeVisible(); });});测试覆盖率在 vitest.config.ts 中配置覆盖率报告:// vitest.config.tsimport { getViteConfig } from 'astro/config';export default getViteConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*', ], }, },});npm scripts 配置在 package.json 中添加测试命令:{ "scripts": { "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed" }}测试策略建议分层测试金字塔:底层大量单元测试覆盖工具函数和独立组件,中间适量集成测试验证组件协作和 API 逻辑,顶层少量端到端测试保障核心业务流程。.astro 组件测试优先用 Container API:简单场景用 renderToString 断言 HTML 内容即可,需要交互断言时再用 vitest-browser-astro。Mock 外部依赖:用 vi.mock() 隔离第三方模块和 API 调用,确保测试稳定且不依赖外部服务。CI 中强制执行:在 CI 流程中运行 vitest run 和 playwright test,并设置覆盖率阈值,低于阈值则构建失败。
服务端阅读 05月27日 15:35

SVG 的 clipPath 裁剪和 mask 蒙版有什么区别?怎么用?

SVG 里有两套视觉裁切机制:clipPath 和 mask。前者做硬边缘裁剪,后者做透明度遮罩。前端面试经常考两者的区别,实际开发中圆形头像、文字镂空、渐变淡出也都靠它们实现。这篇文章从语法、属性、CSS 联动到实战案例,逐一拆解。clipPath:硬边缘裁剪clipPath 的逻辑很简单:定义一个封闭区域,区域内的内容保留,区域外的内容直接消失,不存在半透明过渡。基本语法<svg width="200" height="200"> <defs> <clipPath id="circleClip"> <circle cx="100" cy="100" r="80" /> </clipPath> </defs> <rect width="200" height="200" fill="#4A90D9" clip-path="url(#circleClip)" /></svg>clipPath 内部放什么形状,就按什么形状裁。矩形、圆形、多边形、文字都可以。clipPathUnits 属性这个属性决定了裁剪路径的坐标系,默认值是 userSpaceOnUse。userSpaceOnUse:裁剪路径使用元素所在的用户坐标系。clipPath 内的坐标值是绝对坐标,跟被裁剪元素的位置无关。objectBoundingBox:裁剪路径使用被裁剪元素的包围盒作为参考系,坐标值范围 0~1,表示相对比例。<defs> <!-- objectBoundingBox 模式:0.5 表示元素宽度/高度的 50% --> <clipPath id="halfClip" clipPathUnits="objectBoundingBox"> <rect x="0" y="0" width="0.5" height="1" /> </clipPath></defs><rect x="20" y="20" width="160" height="160" fill="#E74C3C" clip-path="url(#halfClip)" />objectBoundingBox 适合做"裁掉左半边/下半边"这类比例裁切,不用算具体像素。clip-rule 属性clip-rule 控制路径内部的填充判定规则,和 SVG 的 fill-rule 一致:nonzero(默认):非零环绕规则,适合普通形状evenodd:奇偶规则,适合有镂空的复合形状<defs> <clipPath id="ringClip"> <!-- evenodd 让环形区域被裁剪保留,内部圆被镂空 --> <path clip-rule="evenodd" d="M100,20 A80,80 0 1,1 99.9,20 Z M100,50 A50,50 0 1,0 99.9,50 Z" /> </clipPath></defs><rect width="200" height="200" fill="#9B59B6" clip-path="url(#ringClip)" />用文字做裁剪路径把文字作为 clipPath 的形状,可以让背景图片或渐变只在文字轮廓内显示,这种效果在 banner 设计中很常见。<svg width="400" height="120"> <defs> <linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" stop-color="#FF6B6B" /> <stop offset="100%" stop-color="#4ECDC4" /> </linearGradient> <clipPath id="textClip"> <text x="200" y="85" font-size="72" font-weight="bold" text-anchor="middle">SVG</text> </clipPath> </defs> <rect width="400" height="120" fill="url(#textGrad)" clip-path="url(#textClip)" /></svg>裁剪图片实现圆形头像<svg width="160" height="160"> <defs> <clipPath id="avatarClip"> <circle cx="80" cy="80" r="76" /> </clipPath> </defs> <image href="/avatar.jpg" x="0" y="0" width="160" height="160" clip-path="url(#avatarClip)" /></svg>mask:透明度遮罩mask 和 clipPath 最大的区别在于:mask 不是非黑即白的,它用灰度值控制透明度。白色区域完全显示,黑色区域完全隐藏,中间灰度值对应半透明。基本语法<svg width="200" height="200"> <defs> <mask id="holeMask"> <rect width="200" height="200" fill="white" /> <circle cx="100" cy="100" r="50" fill="black" /> </mask> </defs> <rect width="200" height="200" fill="#4A90D9" mask="url(#holeMask)" /></svg>白色背景 + 黑色圆 = 蓝色矩形中间被挖了一个圆洞。mask-type 属性:luminance 还是 alphamask-type 决定了 mask 的计算方式:luminance(默认):根据颜色的亮度值计算透明度。白色=不透明,黑色=透明,灰色=半透明alpha:直接使用颜色的 alpha 通道,忽略颜色本身<defs> <!-- alpha 模式:只看 alpha 通道,颜色不重要 --> <mask id="alphaMask" mask-type="alpha"> <rect width="200" height="200" fill="rgba(255,0,0,1)" /> <circle cx="100" cy="100" r="60" fill="rgba(0,0,0,0)" /> </mask></defs>CSS 中对应的是 mask-mode 属性(match-source | luminance | alpha)。实际开发中 alpha 模式更直觉——直接设置 rgba 的透明度就好,不用去算灰度。maskUnits 和 maskContentUnits这是两个容易混淆的属性:maskUnits(默认 objectBoundingBox):控制 mask 元素自身定位框的坐标系。决定 mask 的 x、y、width、height 参照什么maskContentUnits(默认 userSpaceOnUse):控制 mask 内部子元素的坐标系。决定你画的那些 rect、circle 的坐标参照什么<defs> <!-- mask 自身定位按用户坐标,内容也按用户坐标 --> <mask id="m1" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse"> <rect x="0" y="0" width="200" height="200" fill="white" /> </mask></defs>大多数场景下用默认值就够了,只有在需要 mask 跟随元素尺寸自动缩放时才需要调整。渐变蒙版实现淡出效果这是 mask 最典型的应用场景——让元素从一侧渐变消失,clipPath 做不到这种软边缘。<svg width="300" height="100"> <defs> <linearGradient id="fadeGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" stop-color="white" /> <stop offset="100%" stop-color="black" /> </linearGradient> <mask id="fadeMask"> <rect width="300" height="100" fill="url(#fadeGrad)" /> </mask> </defs> <rect width="300" height="100" fill="#2ECC71" mask="url(#fadeMask)" /></svg>用径向渐变做聚光灯效果<svg width="300" height="200"> <defs> <radialGradient id="spotGrad" cx="50%" cy="50%" r="50%"> <stop offset="0%" stop-color="white" /> <stop offset="100%" stop-color="black" /> </radialGradient> <mask id="spotMask"> <rect width="300" height="200" fill="url(#spotGrad)" /> </mask> </defs> <image href="/scene.jpg" width="300" height="200" mask="url(#spotMask)" /></svg>clipPath 和 mask 的核心区别| 对比项 | clipPath | mask ||--------|----------|------|| 裁切方式 | 硬边缘,非显即隐 | 支持透明度渐变 || 透明度控制 | 无 | 白=显示,黑=隐藏,灰=半透明 || 事件响应 | 裁剪区域外不响应事件 | 遮罩透明区域仍可响应事件 || 性能 | 更好,计算简单 | 较重,需要逐像素计算 || 典型场景 | 形状裁剪、头像、镂空文字 | 渐变淡出、聚光灯、阴影遮罩 |面试时记住一句话:clipPath 是剪刀,mask 是滤镜。剪刀只有剪和不剪,滤镜可以调透明度。clipPath 能否嵌套?mask 能否叠加?clipPath 嵌套:一个元素只能有一个 clip-path,但 clipPath 内部可以放多个形状,取并集作为裁剪区域。如果需要交集裁剪,可以把裁剪结果包一层 group 再裁剪。mask 叠加:一个元素只能有一个 mask,多个 mask 需要手动合并到同一个 <mask> 元素内,通过叠加绘制实现组合效果。CSS clip-path 与 SVG clipPath 的联动CSS 的 clip-path 属性可以直接引用 SVG 中定义的 clipPath,这让 SVG 裁剪能作用于普通 HTML 元素。<!-- 定义 SVG 裁剪路径 --><svg width="0" height="0"> <defs> <clipPath id="starClip"> <polygon points="50,5 20,95 95,35 5,35 80,95" /> </clipPath> </defs></svg><!-- 在 HTML 元素上引用 --><div style="width:200px;height:200px;background:#E74C3C;clip-path:url(#starClip)"></div>CSS 也支持直接使用基本形状函数,不需要定义 SVG:.avatar { clip-path: circle(50%);}.banner { clip-path: polygon(0 0, 100% 0, 100% 80%, 0 100%);}但自定义复杂路径仍然需要 SVG clipPath。两者配合使用是最灵活的方案。CSS mask 属性同样可以引用 SVG mask,还可以用图片做遮罩:.card { mask-image: linear-gradient(to bottom, white, transparent); -webkit-mask-image: linear-gradient(to bottom, white, transparent);}组合使用:圆形裁剪 + 渐变淡出<svg width="200" height="200"> <defs> <clipPath id="circleClip"> <circle cx="100" cy="100" r="80" /> </clipPath> <linearGradient id="maskGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" stop-color="white" /> <stop offset="100%" stop-color="black" /> </linearGradient> <mask id="fadeMask"> <rect width="200" height="200" fill="url(#maskGrad)" /> </mask> </defs> <rect width="200" height="200" fill="#4A90D9" clip-path="url(#circleClip)" mask="url(#fadeMask)" /></svg>先用 clipPath 把矩形裁成圆形,再用 mask 让圆形从左到右渐变消失。动态裁剪与动画通过 JavaScript 修改 clipPath 内元素的属性,可以实现动画效果:<svg width="200" height="200"> <defs> <clipPath id="dynamicClip"> <circle id="clipCircle" cx="100" cy="100" r="50" /> </clipPath> </defs> <rect width="200" height="200" fill="#4A90D9" clip-path="url(#dynamicClip)" /></svg><script>const circle = document.getElementById('clipCircle');let r = 50, growing = true;function animate() { r += growing ? 0.5 : -0.5; if (r >= 80) growing = false; if (r <= 30) growing = true; circle.setAttribute('r', r); requestAnimationFrame(animate);}animate();</script>如果只需要 CSS 动画,也可以用 CSS clip-path 配合 @keyframes:.box { clip-path: circle(30%); animation: breathe 3s ease-in-out infinite alternate;}@keyframes breathe { to { clip-path: circle(80%); }}CSS 动画方案更轻量,适合简单的缩放裁切动画。需要路径变形等复杂动画时,才需要回到 JavaScript 操作 SVG 节点。浏览器兼容性clipPath 和 mask 在现代浏览器中支持良好,但有几个坑要注意:Firefox 对 clipPath 内使用 <use> 引用外部形状有历史 bugSafari 对 CSS mask 需要加 -webkit- 前缀clipPathUnits="objectBoundingBox" 在老版本 WebKit 中可能有精度问题CSS clip-path 作用于 HTML 元素时,Firefox 早期版本需要额外的 SVG 引用处理生产环境中建议在关键路径上做特性检测或提供降级方案,Safari 的 -webkit- 前缀别忘了加。实际业务中怎么选选型其实不复杂,记住两条原则:只要不需要透明度过渡,就用 clipPath。性能好,语义清晰,浏览器计算快。需要渐变、半透明、软边缘时,才上 mask。代价是渲染开销更大。几个典型业务场景的选型参考:用户头像裁成圆形 → clipPath,硬边缘就够了文字 banner 镂空渐变 → clipPath 做文字裁剪 + 渐变填充卡片底部淡出效果 → mask,需要从实到虚的渐变鼠标跟随聚光灯 → mask + 径向渐变斜切/波浪形分割线 → clipPath 或 CSS clip-path: polygon(),不需要半透明如果项目已经大量使用 CSS clip-path 和 mask-image,SVG 定义可以作为复杂路径的补充,两者并不冲突。
服务端阅读 05月27日 15:35

Expo应用的测试策略有哪些?如何进行单元测试和端到端测试?

Expo应用的测试是保障代码质量和团队协作效率的基石。实际项目中,测试策略的选择直接影响迭代速度和线上稳定性。本文从单元测试、组件测试、端到端测试三个层面,梳理 Expo 项目中经过实战验证的测试方案和踩坑经验。Jest 单元测试:从工具函数到 HooksJest 是 Expo 官方推荐的测试框架,配合 jest-expo preset 可以开箱即用,无需手动配置复杂的 transform 规则。安装依赖:npx expo install jest-expo jest --dev在 package.json 中添加脚本和配置:{ "scripts": { "test": "jest", "test:ci": "jest --coverage --ci" }, "jest": { "preset": "jest-expo" }}工具函数测试是最直接的切入点,输入输出明确,不依赖任何 UI 渲染:// utils/format.test.tsimport { formatDate, calculateTotal } from '../format';describe('formatDate', () => { it('格式化有效日期', () => { expect(formatDate(new Date('2024-01-15'))).toBe('2024-01-15'); }); it('空值返回空字符串', () => { expect(formatDate(null)).toBe(''); }); it('非法输入抛出错误', () => { expect(() => formatDate('invalid')).toThrow(); });});自定义 Hooks 测试需要 @testing-library/react-hooks,它在测试环境中模拟 React 的渲染周期:// hooks/useUser.test.tsimport { renderHook, act } from '@testing-library/react-hooks';import { useUser } from './useUser';describe('useUser', () => { it('加载用户数据', async () => { const { result, waitFor } = renderHook(() => useUser('123')); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.user.name).toBe('John'); }); it('网络错误时设置 error 状态', async () => { jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network')); const { result, waitFor } = renderHook(() => useUser('999')); await waitFor(() => expect(result.current.error).toBeTruthy()); });});踩坑提醒: 如果项目使用了 react-native-reanimated、expo-linear-gradient 等原生模块,Jest 运行时会报模块找不到的错误。解决方式是在 jest.config.js 中统一 mock:module.exports = { preset: 'jest-expo', setupFilesAfterSetup: ['./jest.setup.js'],};// jest.setup.jsjest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));jest.mock('expo-linear-gradient', () => { const { View } = require('react-native'); return { LinearGradient: View };});React Native Testing Library:面向用户行为的组件测试组件测试的核心原则是「测试用户能看到什么、能做什么」,而不是测试组件内部状态和方法。@testing-library/react-native 正是基于这个理念设计的。安装:npx expo install @testing-library/react-native --dev交互测试示例——登录表单:// components/LoginForm.test.tsximport { render, fireEvent, waitFor } from '@testing-library/react-native';import LoginForm from './LoginForm';describe('LoginForm', () => { it('输入合法数据后提交', async () => { const onSubmit = jest.fn(); const { getByPlaceholderText, getByText } = render( <LoginForm onSubmit={onSubmit} /> ); fireEvent.changeText(getByPlaceholderText('邮箱'), 'user@example.com'); fireEvent.changeText(getByPlaceholderText('密码'), 'password123'); fireEvent.press(getByText('登录')); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com', password: 'password123', }); }); }); it('空邮箱时显示错误提示', () => { const { getByText, queryByText } = render(<LoginForm onSubmit={jest.fn()} />); fireEvent.press(getByText('登录')); expect(getByText('请输入邮箱')).toBeTruthy(); expect(queryByText('请输入密码')).toBeNull(); });});导航测试是 Expo 项目中的高频场景。组件内如果用了 useNavigation,测试时需要包裹 NavigationContainer:import { NavigationContainer } from '@react-navigation/native';const renderWithNavigation = (ui: React.ReactElement) => { return render(<NavigationContainer>{ui}</NavigationContainer>);};AsyncSelect / 搜索框测试要注意 waitFor 的使用,避免断言在异步操作完成前执行:it('搜索时展示建议列表', async () => { const { getByPlaceholderText, findAllByText } = render(<SearchSelect />); fireEvent.changeText(getByPlaceholderText('搜索'), 'React'); const items = await findAllByText(/React/); expect(items.length).toBeGreaterThan(0);});端到端测试:Maestro vs Detox端到端测试模拟真实用户操作,验证从启动到完成某个流程的完整链路。Expo 项目中目前有两个主流选择。Maestro:轻量高效的 E2E 方案Maestro 是近年来在 React Native 社区快速崛起的 E2E 工具,配置简单,YAML 驱动,适合快速上手。安装:curl -Ls "https://get.maestro.mobile.dev" | bash编写测试用例(YAML 格式):# .maestro/login.yamlappId: com.example.myapp---- launchApp- assertVisible: "邮箱"- inputText: "user@example.com" id: "email-input"- inputText: "password123" id: "password-input"- tapOn: "登录"- assertVisible: "欢迎回来"运行:maestro test .maestro/login.yamlMaestro 的优势在于不需要写原生构建配置,测试用例可读性强,非开发人员也能理解和维护。对于 Expo 项目,配合 eas build 生成的开发构建即可运行测试。Detox:灰盒测试的经典方案Detox 由 Wix 团队开发,在 React Native 生态中使用广泛。它的「灰盒」机制能同步等待异步操作完成,减少 flaky test。安装和初始化:npm install --save-dev detox detox-clidetox init -r jestDetox 要求先生成原生代码,所以需要先执行 npx expo prebuild。配置文件 .detoxrc.js:module.exports = { testRunner: { args: { '$0': 'jest', config: 'e2e/jest.config.js' }, }, apps: { 'ios.debug': { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', }, }, devices: { simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } }, }, configurations: { 'ios.sim.debug': { device: 'simulator', app: 'ios.debug' }, },};E2E 测试用例:// e2e/login.e2e.tsdescribe('登录流程', () => { beforeAll(async () => { await device.launchApp({ newInstance: true }); }); it('合法凭据登录成功', async () => { await element(by.id('email-input')).typeText('user@example.com'); await element(by.id('password-input')).typeText('password123'); await element(by.id('login-button')).tap(); await expect(element(by.id('welcome-screen'))).toBeVisible(); }); it('错误凭据显示提示', async () => { await element(by.id('email-input')).typeText('wrong@example.com'); await element(by.id('password-input')).typeText('wrong'); await element(by.id('login-button')).tap(); await expect(element(by.text('账号或密码错误'))).toBeVisible(); });});选型建议: 新项目或团队 E2E 经验不多,优先选 Maestro,学习成本低、维护简单;已有 Detox 基础设施或需要细粒度同步控制的团队,继续用 Detox。测试金字塔与覆盖率策略测试不是越多越好,投入产出比最高的分布是:单元测试占 70%——覆盖工具函数、Hooks、状态管理逻辑,运行快、维护成本低组件测试占 20%——覆盖关键交互流程(表单提交、列表筛选、弹窗关闭等)E2E 测试占 10%——只覆盖核心业务链路(注册、登录、支付、下单),每个用例运行耗时是单元测试的 50-100 倍覆盖率配置:{ "collectCoverage": true, "coverageReporters": ["text", "lcov"], "coverageThreshold": { "global": { "branches": 70, "functions": 70, "lines": 70, "statements": 70 } }}注意:覆盖率达到 70% 即可,追求 100% 会导致大量测试代码维护负担,反而拖慢迭代速度。CI/CD 集成将测试接入 CI 是保证每次提交质量的关键一步。GitHub Actions 配置:name: Teston: [push, pull_request]jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm run test:ci e2e-test: runs-on: macos-latest needs: unit-test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npx expo prebuild - run: npm run e2e:ios单元测试和 E2E 测试分两个 Job,单元测试先跑,通过后才触发耗时的 E2E 测试。Mock 策略与常见陷阱测试中的 Mock 是一把双刃剑,过度 Mock 会让测试失去意义。应该 Mock 的:网络请求(用 jest.spyOn 或 msw)第三方 SDK 的初始化和调用AsyncStorage、SecureStore 等持久化存储不应该 Mock 的:被测组件自身的子组件(这属于内部实现细节)React 的 hooks(如 useState、useEffect)网络请求 Mock 示例:import { rest } from 'msw';import { setupServer } from 'msw/node';const server = setupServer( rest.get('/api/user', (_, res, ctx) => res(ctx.json({ id: 1, name: 'John' })) ));beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());快照测试的取舍: 快照测试适合稳定的 UI 组件(如 Button、Card 等基础组件),但对频繁变动的业务页面,快照测试几乎每次都会失败,维护成本高于收益。建议只在组件库中使用快照测试。实战经验总结先测工具函数,再测组件,最后补 E2E——这条路径学习曲线最平缓,产出最快E2E 测试只覆盖主流程——登录、支付、核心操作各一条用例足够,不要试图用 E2E 覆盖所有边界情况测试代码也是代码——保持测试文件的命名、结构和复用性,抽取公共的 renderWithProvider 工具函数CI 中跑测试,本地不强制——开发时快速迭代,提交时由 CI 兜底,避免测试成为开发的阻碍关注测试失败的原因,而非数量——一个经常 flaky 的 E2E 测试比没有测试更糟糕,遇到不稳定用例优先修复或删除
服务端阅读 05月27日 15:33

Gin 框架中如何实现模板渲染和静态文件服务?

Gin 作为 Go 语言最流行的 Web 框架之一,内置了对 HTML 模板渲染和静态文件服务的完善支持。理解这两个核心功能的实现方式,是构建服务端渲染 Web 应用的基础。模板渲染基础Gin 的模板系统基于 Go 标准库 html/template,提供了模板加载、渲染和自定义函数的能力。加载模板文件Gin 提供两种模板加载方式——LoadHTMLGlob 按通配符批量加载,LoadHTMLFiles 按文件路径逐个加载:r := gin.Default()// 批量加载:匹配 templates/ 下所有模板r.LoadHTMLGlob("templates/*")// 逐个加载:指定具体文件路径r.LoadHTMLFiles("templates/index.html", "templates/about.html")生产环境中更推荐 LoadHTMLGlob,配合子目录组织模板时可使用 templates/**/*.html 匹配多级目录。渲染 HTML 响应加载模板后,在路由处理函数中调用 c.HTML() 渲染页面:r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ "title": "首页", "message": "欢迎使用 Gin", })})gin.H 是 map[string]interface{} 的类型别名,用于向模板传递数据。模板内通过 {{ .title }} 访问对应字段。模板继承与布局实际项目中通常需要统一的页面布局(头部、导航、底部)。Gin 支持 Go 模板的 define/block 语法实现模板继承:先定义基础布局模板 templates/base.html:{{ define "base.html" }}<!DOCTYPE html><html><head><title>{{ .title }}</title></head><body> <nav>统一导航栏</nav> {{ block "content" . }}{{ end }} <footer>统一页脚</footer></body></html>{{ end }}再定义子模板 templates/index.html,填充具体内容:{{ template "base.html" . }}{{ define "content" }}<section> <h1>{{ .message }}</h1></section>{{ end }}注意:使用模板继承时,必须用 LoadHTMLGlob 加载所有关联模板,否则子模板找不到基础模板的定义。自定义模板函数当内置的模板语法不够用时,可以注册自定义函数。常见场景包括日期格式化、字符串处理、安全 HTML 输出等:import ( "html/template" "strings" "time")func main() { r := gin.Default() t := template.Must(template.New("").Funcs(template.FuncMap{ "upper": strings.ToUpper, "formatDate": func(t time.Time) string { return t.Format("2006-01-02") }, "safe": func(s string) template.HTML { return template.HTML(s) }, }).ParseGlob("templates/*")) r.SetHTMLTemplate(t) r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ "name": "gin", "date": time.Now(), }) }) r.Run(":8080")}template.FuncMap 的 key 是模板中调用的函数名,value 是对应的 Go 函数。注册时机必须在 ParseGlob 之前,否则函数不会生效。静态文件服务Web 应用中的 CSS、JS、图片等静态资源,Gin 提供了三种服务方式。目录级静态文件服务r.Static() 是最常用的方式,将一个 URL 路径前缀映射到本地目录:r.Static("/static", "./static")r.Static("/assets", "./assets")访问 /static/css/style.css 会返回 ./static/css/style.css 文件的内容。Gin 底层使用 http.FileServer 实现,自动处理 MIME 类型和 Content-Type 响应头。单文件服务对于 favicon 等独立文件,用 r.StaticFile() 更精确:r.StaticFile("/favicon.ico", "./resources/favicon.ico")自定义文件系统r.StaticFS() 支持传入自定义的 http.FileSystem,常用于嵌入静态资源(Go 1.16+ 的 embed 包):import "embed"//go:embed static/*var staticFS embed.FSfunc main() { r := gin.Default() r.StaticFS("/assets", http.FS(staticFS)) r.Run(":8080")}使用 embed 打包后,部署时无需单独复制静态文件目录,编译出单个二进制即可运行。如果需要禁止目录列表,可以使用 Gin 提供的 Dir 函数:r.StaticFS("/uploads", gin.Dir("./uploads", false)) // false = 禁止目录列表模板与静态文件的协作推荐的项目目录结构project/├── main.go├── templates/│ ├── base.html│ ├── index.html│ └── about.html├── static/│ ├── css/│ ├── js/│ └── images/└── uploads/模板和静态文件分目录存放,模板通过 /static/css/style.css 这样的路径引用资源,与 Gin 的路由配置对应。完整示例以下是一个同时配置模板渲染和静态文件服务的完整示例:package mainimport ( "net/http" "github.com/gin-gonic/gin")func main() { r := gin.Default() // 加载模板 r.LoadHTMLGlob("templates/**/*") // 配置静态文件 r.Static("/static", "./static") r.StaticFile("/favicon.ico", "./resources/favicon.ico") // 页面路由 r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ "title": "首页", }) }) r.GET("/about", func(c *gin.Context) { c.HTML(http.StatusOK, "about.html", gin.H{ "title": "关于", }) }) r.Run(":8080")}模板文件 templates/index.html 中引用静态资源:{{ define "content" }}<link rel="stylesheet" href="/static/css/style.css"><script src="/static/js/app.js"></script><h1>{{ .title }}</h1>{{ end }}静态资源性能优化启用 Gzip 压缩生产环境建议开启 gzip 压缩,减少传输体积:import "github.com/gin-contrib/gzip"func main() { r := gin.Default() r.Use(gzip.Gzip(gzip.DefaultCompression)) r.Static("/static", "./static") r.Run(":8080")}设置缓存头对不频繁变更的资源设置 Cache-Control,减少重复请求:r.GET("/static/*filepath", func(c *gin.Context) { c.Header("Cache-Control", "public, max-age=86400") http.FileServer(http.Dir("./static")).ServeHTTP(c.Writer, c.Request)})资源版本控制通过文件修改时间戳实现缓存失效:func versionedPath(path string) string { info, err := os.Stat("." + path) if err != nil { return path } return fmt.Sprintf("%s?v=%d", path, info.ModTime().Unix())}// 模板中使用c.HTML(http.StatusOK, "index.html", gin.H{ "cssPath": versionedPath("/static/css/style.css"),})模板安全要点XSS 防护Go 的 html/template 默认对变量进行 HTML 转义,<script> 等标签会被转义为 <script>。需要输出原始 HTML 时,必须显式使用 template.HTML 类型:c.HTML(http.StatusOK, "index.html", gin.H{ "content": "<script>alert('xss')</script>", // 自动转义,安全 "rawHTML": template.HTML("<div>安全内容</div>"), // 原始输出,需确保内容可信})CSRF 防护表单提交场景需要 CSRF 令牌。使用 gin-csrf 中间件:import csrf "github.com/utrack/gin-csrf"func main() { r := gin.Default() r.Use(csrf.Middleware(csrf.Options{ Secret: "your-secret-key", ErrorFunc: func(c *gin.Context) { c.String(http.StatusBadRequest, "CSRF 校验失败") c.Abort() }, })) r.GET("/form", func(c *gin.Context) { c.HTML(http.StatusOK, "form.html", gin.H{ "csrfToken": csrf.GetToken(c), }) }) r.POST("/submit", func(c *gin.Context) { // CSRF 中间件自动校验 }) r.Run(":8080")}模板中的表单需要包含令牌:<form method="POST" action="/submit"> <input type="hidden" name="_csrf" value="{{ .csrfToken }}"> <button type="submit">提交</button></form>开发模式与生产模式的差异开发阶段可以禁用模板缓存以支持热更新,生产环境则应开启缓存提升性能:if gin.Mode() == gin.DebugMode { // 开发模式:不缓存模板,修改后刷新即生效 r.LoadHTMLGlob("templates/*")} else { // 生产模式:模板只加载一次 r.LoadHTMLGlob("templates/*")}Gin 在 gin.DebugMode 下默认不缓存模板,每次渲染都会重新解析。切换到 gin.ReleaseMode 后,模板只解析一次并缓存。静态文件在开发时可直接指向本地目录;生产环境建议使用 CDN 托管静态资源,Nginx 反向代理处理静态请求,Gin 专注动态路由和 API 逻辑。核心要点回顾模板加载:LoadHTMLGlob 批量加载,LoadHTMLFiles 逐个加载,SetHTMLTemplate 自定义引擎模板继承:用 define/block/template 组合实现布局复用自定义函数:通过 FuncMap 注册,必须在解析模板之前调用 Funcs()静态文件:Static 映射目录、StaticFile 映射单文件、StaticFS 支持自定义文件系统embed 打包:Go 1.16+ 可用 embed.FS 将静态资源编译进二进制安全:模板默认转义防 XSS,表单需 CSRF 令牌,静态文件目录应禁止列表性能:生产环境开启 gzip 压缩、设置缓存头、模板缓存,静态资源走 CDN 更佳
服务端阅读 05月27日 15:31

SVG 路径命令怎么用?M/C/Q/A 每个命令的语法和原理详解

SVG 路径(<path>)是 SVG 中功能最强大的绘图元素,通过 d 属性里的一组命令来描述任意形状。无论是简单直线还是复杂曲线,都可以用路径命令精确表达。本文按命令类别逐一讲解每条命令的语法、坐标规则和实际绘制效果。路径命令的基本规则路径命令由一个字母加上若干数字组成,写在 <path> 元素的 d 属性中:大写字母表示绝对坐标,数值参照 SVG 画布原点。小写字母表示相对坐标,数值参照当前画笔位置(即上一条命令的终点)。连续使用同一命令时,命令字母可省略,只写参数。命令之间的空格和逗号可以省略,但保留空格有助于可读性。可以把路径命令想象成一支虚拟画笔:M 把笔尖移到某个位置但不落笔,L、C、A 等命令则从当前位置画线到新位置,Z 把笔尖拉回起点闭合路径。移动命令 M / mM 是每条路径的起点,把画笔移动到指定坐标,不产生线条。M 50 50这表示将画笔移到绝对坐标 (50, 50)。小写 m 10 0 则表示从当前位置向右移动 10 个单位。路径必须以 M 或 m 开头,否则浏览器无法确定起始位置。直线命令 L / H / VL —— 画直线到指定点L x y 从当前位置画一条直线到 (x, y),是最常用的画线命令。M 10 10 L 90 90这条路径从 (10, 10) 画直线到 (90, 90)。小写 l dx dy 表示相对偏移,l 80 80 与上面的效果相同。H / V —— 画水平线或垂直线当只需要沿一个轴画线时,用 H 或 V 比 L 更简洁:H x:画水平线到 x 坐标,y 坐标不变。V y:画垂直线到 y 坐标,x 坐标不变。M 10 10 H 90 V 90 H 10 Z这个路径画了一个矩形:从 (10,10) 水平画到 (90,10),垂直画到 (90,90),水平画到 (10,90),最后 Z 闭合回起点。三次贝塞尔曲线 C / SC —— 两个控制点的曲线三次贝塞尔曲线是路径中最常用的曲线命令,语法为:C x1 y1, x2 y2, x y它需要两个控制点 (x1, y1) 和 (x2, y2) 以及一个终点 (x, y)。起点是上一条命令的终点。控制点不在曲线上,它们像磁铁一样把曲线拉向自己的方向:第一个控制点 (x1, y1) 决定曲线离开起点时的切线方向。第二个控制点 (x2, y2) 决定曲线进入终点时的切线方向。M 10 80 C 40 10, 65 10, 95 80这条曲线从 (10, 80) 出发,被第一个控制点 (40, 10) 向左上方拉,又被第二个控制点 (65, 10) 从右上方拉向终点 (95, 80),形成一个 S 形弧线。S —— 平滑三次贝塞尔曲线当需要连续画多段曲线并保持衔接处平滑时,用 S 可以省略一个控制点:S x2 y2, x yS 会自动将前一段曲线的第二个控制点关于当前起点做对称,作为本段曲线的第一个控制点。这样就保证了连接处的切线方向一致,曲线不会出现尖角。M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80第二段曲线的第一个控制点自动取 (125, 150)——即前一段第二控制点 (65, 10) 关于 (95, 80) 的对称点。如果 S 不是跟在 C 或 S 后面,它的第一个控制点会和起点重合,退化为二次贝塞尔曲线的效果。二次贝塞尔曲线 Q / TQ —— 一个控制点的曲线二次贝塞尔曲线只需一个控制点,语法更简单:Q x1 y1, x y控制点 (x1, y1) 同时影响起点和终点的切线方向。曲线弯曲程度比三次贝塞尔曲线弱,适合画简单的弧线。M 10 80 Q 95 10, 180 80T —— 平滑二次贝塞尔曲线和 S 的思路一致,T 自动推算控制点:T x yT 会取前一段 Q 或 T 的控制点关于当前起点的对称点作为新的控制点,保证平滑衔接。M 10 80 Q 95 10, 180 80 T 350 80如果 T 不是跟在 Q 或 T 后面使用,控制点会和终点重合,画出直线。椭圆弧命令 A椭圆弧是路径命令中最复杂的一个,语法为:A rx ry x-axis-rotation large-arc-flag sweep-flag x y七个参数的含义:| 参数 | 含义 ||------|------|| rx, ry | 椭圆的 x 方向和 y 方向半径 || x-axis-rotation | 椭圆的旋转角度(度数) || large-arc-flag | 0 画小弧(小于 180°),1 画大弧(大于 180°) || sweep-flag | 0 逆时针方向,1 顺时针方向 || x, y | 弧线终点坐标 |理解椭圆弧的关键在于:给定起点、终点和椭圆半径,实际上存在四条可能的弧线。large-arc-flag 决定选大弧还是小弧,sweep-flag 决定画弧的方向,两个标志组合起来唯一确定一条弧线。M 80 80 A 45 45 0 0 0 125 125这条弧线以 45×45 的圆(rx=ry 即为圆弧),不旋转,选择小弧、逆时针方向,从 (80,80) 画到 (125,125)。如果把 large-arc-flag 改为 1,会画出同一起终点之间大于 180° 的那段弧:M 80 80 A 45 45 0 1 0 125 125闭合路径 ZZ 或 z 从当前位置画一条直线回到路径起点,闭合整条路径。大小写效果相同。M 10 10 L 90 10 L 50 90 Z这画了一个三角形,Z 自动从 (50, 90) 连回 (10, 10)。闭合路径对于绘制填充图形(fill 不为 none)尤为重要——未闭合的路径在填充时浏览器会自动补一条闭合线,但可能出现渲染差异,建议显式闭合。命令速查表| 命令 | 名称 | 参数 | 说明 ||------|------|------|------|| M/m | 移动 | x y | 移动画笔,不画线 || L/l | 直线 | x y | 画直线到指定点 || H/h | 水平线 | x | 画水平线 || V/v | 垂直线 | y | 画垂直线 || C/c | 三次贝塞尔 | x1 y1, x2 y2, x y | 两个控制点的曲线 || S/s | 平滑三次贝塞尔 | x2 y2, x y | 自动推算第一控制点 || Q/q | 二次贝塞尔 | x1 y1, x y | 一个控制点的曲线 || T/t | 平滑二次贝塞尔 | x y | 自动推算控制点 || A/a | 椭圆弧 | rx ry rot large sweep x y | 椭圆弧线 || Z/z | 闭合 | 无 | 回到起点 |常见问题相对坐标什么时候用? 当路径需要平移或复用时,相对坐标更方便——只需改 M 的起点,后续命令自动跟随。手写简单图形时绝对坐标更直观。S 和 T 在什么时候会退化? 如果 S 不是紧跟 C 或 S,第一个控制点会与起点重合;T 不是紧跟 Q 或 T 时同理,画出来是直线。弧线的 large-arc-flag 和 sweep-flag 怎么选? 先确定需要大弧还是小弧(看弧线是否超过半圆),再确定绘制方向。两个标志各有两种取值,共四种组合,只有一种符合你要的弧线。