阅读视图

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

耗时十分钟,做了一个 uniapp 灵感泡泡机

最近,我用 UniApp 搭配 CodeBuddy 实现了一个充满童话感的小应用,名叫 IdeaBubbles(灵感泡泡机)。它是一个单页 WebApp,用来记录那些转瞬即逝的灵感时刻。整个界面以梦幻气泡和彩虹玻璃拟态为主题,视觉效果令人愉悦,而交互体验也尽可能做到了顺滑灵动。

这个项目几乎是我一句话提出想法后,由 CodeBuddy 自动构建完成的,从页面结构到动画细节,一气呵成。我想用这篇文章记录下整个过程,也许能给同样热爱前端创意项目的你一些灵感。


项目灵感和视觉设定

我一开始就对 CodeBuddy 说:“我要用 UniApp 做一个单页 WebApp,名字叫 IdeaBubbles,风格为梦幻气泡、彩虹玻璃拟态,用来记录碎片灵感。”

结果真没想到,它不仅听懂了我说的“梦幻气泡”和“玻璃拟态”,还迅速给出了完整的页面结构设计。顶部是渐变标题,中部是灵感输入和泡泡生成按钮,底部还有泡泡数量和清空功能。更惊喜的是,它自动为每个区域设计了细节:字体使用 Quicksand,背景是渐变+玻璃纹理叠加,交互上加入了拖动、双击删除、长按复制等趣味机制。


创建项目:从 0 到启动

CodeBuddy 指导我使用以下命令一键生成项目框架:

npm install -g @vue/cli @dcloudio/uni-cli
vue create -p dcloudio/uni-preset-vue IdeaBubbles
cd IdeaBubbles
npm install

我几乎不用动手去搜索或排查错误,它就一步步把项目搭建好了。所有配置都井井有条,甚至连 Vue 和 uni-app 的版本依赖也预处理好了,省去了很多环境配置上的麻烦。


中心交互:灵感生成泡泡

项目的灵魂就是“泡泡生成”。我只需输入一句灵感,比如“试试用 SVG 画点水波纹”,然后点击那个精美的圆形按钮,就会在屏幕中间“啪”地生成一个带有渐变背景的小泡泡。这个泡泡可以自由拖动、放大发光,双击就会消失,长按还能复制内容,非常有趣。

实现这部分交互时,CodeBuddy 的代码让我非常惊艳。比如泡泡生成使用 position: absolute 和容器 relative 配合,泡泡初始位置是随机计算的:

const left = Math.random() * (window.innerWidth - 100)
const top = Math.random() * (window.innerHeight - 200)

而拖拽功能则使用了 touchstarttouchmovetouchend 三个事件组合,同时在拖动过程中加了 scale 和光晕特效,视觉体验非常顺滑。

在这里插入图片描述


视觉细节:玻璃与彩虹的碰撞

视觉风格是这个项目最吸睛的部分。整个页面背景是一个柔和的彩虹渐变:

background: linear-gradient(to bottom right, #FFDEE9, #B5FFFC);

加上叠加的 SVG 流动纹理,真的有种“空气中漂浮着泡泡”的感觉。每一个 UI 元素都采用玻璃拟态的设计:透明白色背景、8px 模糊滤镜、微妙阴影和圆角。输入框和按钮都像是嵌在冰雕里,点按时带有轻微的放大动画,增强了交互反馈感。

CodeBuddy 对这些细节的处理非常专业,按钮 hover 动画使用了 CSS 的 transform: scale(1.05),加了 transition 让它看起来不突兀,甚至连 box-shadow 的透明度都调得刚刚好,不浓不淡,舒服极了。


实用功能:泡泡统计与清空动画

底部区域设置了泡泡统计和一个“清空泡泡”按钮。当点击“清空”时,屏幕上的所有泡泡会同时触发缩放+淡出动画,像一场气泡爆破秀。

这部分我本来没提需求,但 CodeBuddy 很贴心地加了这个功能,动画使用 scale(1.2) + opacity: 0,并通过 setTimeout 配合删除 DOM,确保视觉和逻辑同步完成。用户体验在细节上达到了完整闭环。

在这里插入图片描述


结语:这不仅是工具,而是代码魔术师

从最开始的构思到最后的交互完成,我几乎没有写过一行核心逻辑代码。整个项目完全是由 CodeBuddy 主动驱动完成,它不仅理解了我想要的视觉风格、功能需求,还在细节和交互上不断给我惊喜。它写的代码非常有条理,结构清晰,变量命名规范,CSS 动效自然细腻,几乎不需要我修改就可以直接上线运行。

尤其让我感动的是,它在设计交互时考虑得非常全面,哪怕是一个按钮 hover 的手感,或者泡泡出现的位置不遮挡输入框,都做了细致处理。

如果说以前我写前端更像是拼积木,那这次和 CodeBuddy 合作,更像是与一位熟悉设计美学又擅长编码的搭档共创作品。这不是建议代码,而是主动创作

希望未来还能和它一起,做出更多有趣又漂亮的网页小工具。


如果你也有一个灵感,不妨把它告诉 CodeBuddy,它说不定已经开始帮你实现了。

在这里插入图片描述

JavaScript 垃圾回收与内存泄漏

在 JavaScript 开发中,垃圾回收和内存泄漏是两个重要的概念。垃圾回收机制可以自动管理内存,但如果不了解其原理,很容易导致内存泄漏,进而影响程序性能甚至导致崩溃。

一、什么是内存泄漏?

程序运行时需要占用内存。当程序申请的内存不再使用时,如果没有及时释放,就会导致内存占用越来越高,最终可能影响系统性能,甚至导致程序崩溃。这种现象称为内存泄漏(Memory Leak)。内存泄漏不仅会导致程序运行缓慢,还可能引发更严重的问题,如内存不足导致的程序崩溃。

二、JavaScript 的垃圾回收机制

JavaScript 具有自动垃圾回收机制(Garbage Collection, GC),这意味着开发者不需要手动管理内存。垃圾回收器会定期检查并释放不再使用的内存。虽然垃圾回收机制大大简化了内存管理,但了解其工作原理仍然非常重要。

(一)垃圾回收的时机

垃圾回收器会按照固定的时间间隔周期性地运行。它会在后台自动执行,释放那些不再使用的内存。垃圾回收的频率取决于多种因素,包括程序的运行时间、内存使用情况等。

(二)垃圾回收的策略

JavaScript 中常见的垃圾回收策略有两种:标记清除引用计数

1. 标记清除

标记清除是 JavaScript 中最常用的垃圾回收方式。其工作原理如下:

  • 标记阶段:垃圾回收器会遍历所有变量,将进入环境的变量标记为“进入环境”,将离开环境的变量标记为“离开环境”。
  • 清除阶段:垃圾回收器会清除那些被标记为“离开环境”的变量所占用的内存。
function test() {
    var a = 10; // 被标记为“进入环境”
    var b = 20; // 被标记为“进入环境”
}
test(); // 执行完毕后,a 和 b 被标记为“离开环境”,并被回收

标记清除策略的优点是简单高效,但它也有一个缺点:无法处理循环引用的情况。

2. 引用计数

引用计数的含义是跟踪记录每个值被引用的次数。其工作原理如下:

  • 引用次数增加:当一个变量被赋值为某个对象时,该对象的引用次数加 1。
  • 引用次数减少:当一个变量被重新赋值或被删除时,该对象的引用次数减 1。
  • 释放内存:当一个对象的引用次数变为 0 时,垃圾回收器会释放该对象所占用的内存。
function test() {
    var a = {}; // a 的引用次数为 1
    var b = a;  // a 的引用次数加 1,变为 2
    var c = a;  // a 的引用次数再加 1,变为 3
    var b = {}; // a 的引用次数减 1,变为 2
}

引用计数策略的优点是可以快速释放不再使用的内存,但它也有一个严重的缺点:无法处理循环引用的情况。

(三)循环引用问题

循环引用是指两个或多个对象相互引用,形成一个闭环。在引用计数策略下,循环引用会导致内存泄漏,因为这些对象的引用次数永远不会变为 0。

function fn() {
    var a = {};
    var b = {};
    a.pro = b;
    b.pro = a;
}
fn();

在上面的代码中,ab 互相引用,形成一个闭环。在引用计数策略下,ab 的引用次数永远不会变为 0,因此它们不会被垃圾回收器回收,导致内存泄漏。

三、避免内存泄漏的策略

(一)及时释放引用

在不再需要某个变量时,及时将其设置为 null,释放对它的引用。

var element = document.getElementById('someElement');
element = null; // 释放引用

(二)移除事件监听器

在不再需要某个事件监听器时,及时移除它。

var element = document.getElementById('someElement');
element.addEventListener('click', function handler() {
    // 一些操作
});
element.removeEventListener('click', handler); // 移除事件监听器

(三)避免不必要的闭包

在不需要闭包时,避免使用闭包,或者及时释放闭包。

function createClosure() {
    var largeArray = new Array(1000000).fill(0);
    return function() {
        console.log(largeArray.length);
    };
}

var closure = createClosure();
closure = null; // 释放闭包

(四)使用弱引用

在某些情况下,可以使用 WeakMapWeakSet 来存储对对象的弱引用,这些引用不会阻止垃圾回收器释放内存。

var weakMap = new WeakMap();
var element = document.getElementById('someElement');
weakMap.set(element, 'some data');
element = null; // 释放引用

四、总结

JavaScript 的垃圾回收机制虽然可以自动管理内存,但开发者仍然需要了解其工作原理,以避免内存泄漏。希望本文能帮助你更好地理解和应用这些知识。

react中hooks-你真的会用useState吗?

useState参数的类型可以是基本数据类型,还可以是复杂类型,复杂类型中我们介绍一下数组, 在 React 中使用 useState 管理数组时,直接调用数组方法修改原数组会导致视图无法更新,这是由 React 的状态更新机制和不可变性原则决定的。以下是具体原因及解释:


一、React 的浅比较机制

React 在状态更新时通过 浅比较(Shallow Comparison) 判断状态是否变化。如果新旧状态的引用地址相同,React 会认为状态未改变,从而跳过重新渲染。

例如:

javascript
javascript
复制
const [list, setList] = useState([1, 2, 3]);
list.push(4); // 直接修改原数组
setList(list); // 引用未变,视图不更新

此时 list 的引用地址与原数组相同,React 检测不到变化,导致视图未更新。


二、数组方法的副作用

许多 JavaScript 数组方法(如 pushpopreversesort直接修改原数组,而非返回新数组。这违反了 React 的 不可变性原则,即状态更新应通过创建新数据实现,而非直接修改原数据。

例如:

javascript
javascript
复制
// 错误:直接修改原数组
list.reverse(); // 原数组被修改,但引用地址不变
setList(list); // 视图不更新

// 正确:创建新数组
const newList = [...list].reverse();
setList(newList); // 引用地址改变,触发渲染

三、不可变性的优势

  1. 可预测性
    通过创建新数组,可以明确追踪状态变化路径,避免因直接修改原数据导致的意外副作用。
  2. 性能优化
    React 依赖浅比较快速判断是否需要重新渲染。若直接修改原数组,即使数据内容变化,引用地址未变会导致 React 无法触发更新,或依赖深比较带来性能损耗。
  3. 兼容 React 特性
    不可变性是 React 高阶功能(如 useMemouseCallback)和性能优化组件(如 React.memoPureComponent)的基础。

四、如何正确更新数组状态?

  1. 使用返回新数组的方法

    • 扩展运算符:const newList = [...list, newItem];
    • map/filter/sliceconst filtered = list.filter(item => item > 2);
    • 拷贝后修改:const reversed = [...list].reverse();
  2. 避免直接修改嵌套数组
    若数组元素是对象或其他引用类型,需同时保证嵌套数据的不可变性:

    
    // 错误:直接修改嵌套对象
    const newList = [...list];
    newList[0].name = "Bob"; // 修改了原对象的引用
    setList(newList);
    
    // 正确:创建新对象
    const newList = list.map((item, index) => 
      index === 0 ? { ...item, name: "Bob" } : item
    );
    setList(newList);
    
  3. 使用不可变库(如 Immer)
    Immer 可通过“草稿”模式简化不可变操作:

    
    import produce from "immer";
    const newList = produce(list, draft => {
      draft.push(4); // 直接操作草稿,自动生成新数组
    });
    setList(newList);
    

五、常见错误场景

  1. 异步更新问题
    在异步操作(如事件回调、定时器)中直接修改原数组,可能导致状态更新不同步:

    
    // 错误:异步中直接修改原数组
    setTimeout(() => {
      list.push(4);
      setList(list); // 可能不触发更新
    }, 1000);
    
    // 正确:使用函数式更新
    setTimeout(() => {
      setList(prev => [...prev, 4]); // 基于最新状态创建新数组
    }, 1000);
    
  2. 批量更新失效
    React 可能合并多个 setState 调用,若直接修改原数组,可能导致更新丢失:

    
    // 错误:三次修改可能被合并
    list.push(1); setList(list);
    list.push(2); setList(list);
    list.push(3); setList(list);
    
    // 正确:函数式更新保证独立性
    setList(prev => [...prev, 1]);
    setList(prev => [...prev, 2]);
    setList(prev => [...prev, 3]);
    

总结

在 React 中直接修改 useState 数组会导致视图不更新,因为:

  1. 浅比较机制依赖引用地址
  2. 不可变性原则要求创建新数据
  3. 直接修改会破坏 React 的性能优化逻辑

通过使用扩展运算符、map/filter 等方法创建新数组,或借助 Immer 等工具,可以既保证代码的可维护性,又符合 React 的设计哲学。

npm 【workspace】【npm link】【安装本地包】【类库开发调试】

前言

当我们在开发类库时,往往会在实际项目中引入开发中的类库,来调试实际使用的效果。并且最好是需要类库改变后,能够自动、实时的在项目中反馈。 npm有三种方法实现不发布npm包,就在本地像npm i安装一样去使用它,如下:

特性 工作空间(Workspaces) 本地路径依赖 (file:) npm link
依赖管理方式 单仓库多包(monorepo) 跨项目文件引用 全局符号链接
版本控制 由根 package.json 统一管理 各自维护版本,可能冲突 忽略版本,直接引用源码
修改传播 实时生效(无需重新安装) 实时生效 实时生效
依赖结构 扁平化(共享根 node_modules 嵌套结构(可能导致重复安装) 全局链接,可能与其他项目冲突
生产环境兼容性 完全兼容(发布独立包) 需替换为 npm 包版本 不可用(必须替换为 npm 包)
适用场景 大型项目内部模块协作 临时引用本地包 频繁开发的本地包

下面我们就来分别介绍它们的使用

1.前置条件

首先我们建立一个项目目录,后面的案例都按照这个目录操作

├─ app/                     # app项目
│  ├─ index.js               
│  └─ package.json           
├─ lib1/                     # sub项目
│  ├─ index.js             
│  └─ package.json  

1.本地路径依赖 and npm link

本地依赖开发非常简单,直接将依赖指向本地地址即可。它其实最终也是执行的npm link,相对于"scripts"项的脚本一样

// app/package.json
{
    "dependencies":{
        "lib-name1":"../lib1",// lib目录必须是一个npm项目
    }
}

执行npm i,npm就会自动把./lib目录链接到app/node_modules下,然后像普通模块一样引入即可

// app/index.js
import m from "lib-name1";

然后我们看看npm link的使用方式,直接在app/目录的命令行中执行

app> npm link ../lib1; //在app/node_modules中的文件名由../lib1/package.json中的name项决定

你可以查看app/node_modules目录,可以看到本地路径依赖npm link最终都是在其内部生成了一个目录链接符

2.workspace

建立一个新的目录结构

├─ app/ # app项目
|   ├─ index.js
|   ├─ package.json
|   |
|   ├─ pack1/
|   |   ├─ index.js
|   |   └─ package.json #s 引入lib1
|   |
|   └─ packages/
|       └─ pack2/
|           ├─ index.js
|           └─ package.json #s 引入lib2
│             
├─ pack0/                 
   ├─ index.js             
   └─ package.json #s 引入lib0

然后配置workspace

// app/package.json
{
  "workspace":[
      "../pack0",
      "./pack1",
      "./packages/*", // 引入./packages/目录下所有项目
  ]
}

workspace最终也是建立一个"npm link"目录链接,但是它与npm link本地路径依赖不一样的是,workspace如果配置的是在当前根目录内的项目作为依赖,那么所有的依赖都是统一安装到根目录node_module下进行复用,即便你是在依赖项中执行npm i也不会安装到workspace项目中的node_module

如果配置的workspace项目,根目录外,则无法复用。依赖项会安装到workspace项目的node_modules中。

在上面的目录结构执行完npm i之后,包安装位置结果如下

|-app/node_modules
|    |- lib1
|    |- lib2
|-pack0/node_modules
     |- lib0

3.结论

不论是库还是单体项目,都推荐使用workspace进行包管理!

Edge Runtime 与 Serverless Runtime

一、基本定义

类型 定义
Serverless Runtime 指在云服务(如 AWS Lambda)上运行的无状态函数,按请求触发,运行在**中心化服务器(Region)**中。
Edge Runtime 指代码运行在**CDN 边缘节点(Edge Node)**的沙箱环境中,靠近用户,延迟更低,使用 Web 标准 API 执行。

二、底层运行原理对比

特性 Serverless Runtime Edge Runtime
运行环境 Node.js、支持 CommonJS/ESM 基于 V8 引擎(非 Node.js),无 Node 核心模块
执行位置 云 Region 中心服务器(如 AWS us-east-1) 离用户最近的 CDN 边缘节点(如东京、新加坡)
触发方式 按请求启动(有冷启动) 按请求启动(极低冷启动)
资源限制 内存大、运行时间长 内存小、执行时间短
支持 API Node.js API 全支持,如 fshttpcrypto 仅支持 Web 标准 API,如 fetchRequestResponse
文件系统访问 ✅ 支持 ❌ 不支持
数据库连接 ✅ 支持直连 MySQL/MongoDB 等 ⚠️ 不推荐(无 TCP 支持、无连接池)

三、启动与响应性能对比

对比项 Serverless Edge
启动时间 50ms ~ 800ms(冷启动) <10ms(近似无冷启动)
运行时长限制 通常几分钟(如 AWS Lambda 最长 15 分钟) 通常几秒(如 Cloudflare Worker 最多 30 秒)
并发能力 支持高并发(自动扩展) 支持极高并发(且无需冷启动)
本地模拟支持 ✅ 可用 next dev 本地模拟 ⚠️ 本地模拟困难、需平台支持(如 Vercel CLI)

四、应用场景对比

场景类型 Serverless Runtime Edge Runtime
SSR 页面渲染 ✅ 推荐,支持数据库、缓存等复杂逻辑 ⚠️ 不推荐,因不支持数据库直连
国际化重定向 ✅ 可做,但慢 ✅ 非常推荐,极快
登录鉴权 ✅ 推荐 ✅ 推荐(如 JWT 验证)
A/B 实验 ✅ 推荐 ✅ 推荐(执行速度快)
数据库操作 ✅ 推荐 ❌ 不推荐
静态资源预处理 ❌ 无意义 ✅ 可拦截 CDN 请求处理逻辑
CDN 边缘响应 API ❌ 慢 ✅ 最佳选择
页面 Layout Server Components(App Router) ✅ 支持 ✅ 支持,需 export const runtime = 'edge'

五、Next.js 中的使用方式

1. Serverless(默认)

适用于任何 API 路由、SSR 页面:

// 默认 Node.js Runtime
export async function GET(req: Request) {
  // 可以连接数据库、使用 fs、进行 SSR 等
}

2. Edge Runtime

middleware.ts、API 路由或 page.tsx 中启用:

export const runtime = 'edge';

export async function GET() {
  return new Response('Hello from Edge!');
}

六、与平台的关系

平台 Serverless Runtime Edge Runtime
Vercel ✅ 默认支持 ✅ 强力支持(推荐平台)
Cloudflare Workers ❌ 不支持 ✅ 支持(原生)
Netlify Functions ✅ 支持 ✅ 支持(部分)
AWS Lambda ✅ 支持 ✅ 需使用 Lambda@Edge
阿里云函数计算 ✅ 支持 ⚠️ 支持有限
自建 Node.js 服务 ✅ 支持 ❌ 不支持 Edge

七、优缺点总结

✅ Serverless Runtime 优势

  • 支持数据库连接、文件操作

  • 更强的计算能力和执行时长

  • 更灵活,调试方便

❌ Serverless Runtime 缺点

  • 冷启动慢(尤其是首次)
  • 距离用户远,延迟高
  • 无法处理 CDN 级别请求

✅ Edge Runtime 优势

  • 毫秒级冷启动
  • 离用户更近,延迟极低
  • 非常适合轻量请求、前置逻辑、边缘缓存判断

❌ Edge Runtime 缺点

  • 无法访问 Node 核心模块
  • 无法直连数据库
  • 无文件系统、不适合重计算任务

总结建议

场景 推荐运行时
页面 SSR + 数据库渲染 ✅ Serverless
登录状态校验 / token 验证 ✅ Edge
CDN 边缘路由拦截(如国际化) ✅ Edge
复杂 API 接口处理 ✅ Serverless
页面 Layout 的快速响应组件 ✅ Edge(App Router)

实战例子

登录校验(middleware.ts + Edge)

export function middleware(request) {
  const token = request.cookies.get('token')
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  return NextResponse.next()
}

国际化跳转

export function middleware(req) {
  const lang = req.headers.get('accept-language')
  const url = req.nextUrl
  if (!url.pathname.startsWith('/zh')) {
    url.pathname = '/zh' + url.pathname
    return NextResponse.redirect(url)
  }
}

突破浏览器最小字号限制:前端超小字号实现技巧

一. 使用SVG文本呈现文字

 <svg width="100%" height="20">
       <text x="50%" y="10" font-size="7" font-family='SourceHanSansCN-bold' fill="#a62f1c" font-weight="600" text-anchor="middle">{{ gift.txt }}</text>
</svg>
  1. 使用x="50%"text-anchor="middle"实现文字水平居中

二. 使用 transform: scale()缩小文字

.txt {
     font-size: 12px;
     transform: scale(0.8);
 }
  1. font-size的大小取决于浏览器限制的最小字体

AI回答:

突破浏览器最小字号限制的方法主要有以下几种‌:

  1. 使用CSS的transform: scale()属性‌:通过将字体大小设置为一个较大的值,然后使用transform: scale()属性进行缩放,可以实现小于12px的字体效果。例如,将字体大小设置为20px,然后通过0.5倍缩放来实现10px的字体效果。这种方法适用于块级元素和行内块元素,但需要注意缩放属性会影响元素的布局和样式‌12。
  2. 使用SVG文本‌:对于需要在图标或徽标中显示非常小的文本,可以考虑使用SVG(可缩放矢量图形)来呈现文本。SVG文本不受浏览器最小字体大小的限制,可以直接设置所需的字体大小‌4。
  3. 修改Chrome浏览器设置‌:可以通过调整Chrome浏览器的设置来禁用最小字体大小限制。在Chrome地址栏输入chrome://settings/,然后搜索“字体”或“外观”,尝试调整相关设置。但这种方法可能因Chrome版本和操作系统而异,且不保证所有用户都会进行相同的设置‌45。

这些方法的优缺点如下‌:

  • CSS缩放‌:

    • 优点‌:代码简洁,文本可选中,兼容性好。
    • 缺点‌:影响布局和样式,大量文本缩放会影响渲染性能‌。
  • SVG文本‌:

    • 优点‌:不受浏览器最小字体大小限制,适合特定场景。
    • 缺点‌:需要使用SVG,不适合所有文本内容‌。
  • 修改浏览器设置‌:

    • 优点‌:操作简单,直接。
    • 缺点‌:只对当前用户的浏览器生效,不适用于所有用户‌45。

zoom

CSS 中的 zoom 属性用于缩放元素的大小,包括其内容、边框和内边距。它类似于 transform: scale(),但有一些关键区别。以下是关于 zoom 的详细说明和使用方法。


1.基本用法

zoom 属性接受一个数值或百分比,表示元素的缩放比例。

.example {
  zoom: 1.5; /* 放大到 150% */
}css
  • zoom: 1:默认大小(无缩放)。
  • zoom: 0.5:缩小到 50%。
  • zoom: 200%:放大到 200%。

2.与 transform: scale() 的区别

特性 zoom transform: scale()
兼容性 仅部分浏览器支持(如 IE、Edge) 所有现代浏览器支持
布局影响 会重新计算布局,影响其他元素 不会影响布局,仅视觉缩放
性能 性能较差 性能较好
缩放中心 默认从左上角缩放 可以通过 transform-origin 设置中心点

3.使用 zoom 的场景

  • 兼容旧版浏览器:如果需要支持 IE 或旧版 Edge,可以使用 zoom
  • 简单缩放:如果不需要复杂的布局控制,zoom 是一个简单的解决方案。

4.示例

放大元素
.zoom-in {
  zoom: 1.5; /* 放大到 150% */
}css
缩小元素
.zoom-out {
  zoom: 0.75; /* 缩小到 75% */
}css
结合百分比
.zoom-percent {
  zoom: 200%; /* 放大到 200% */
}css

5.注意事项

  1. 兼容性问题

    • zoom 不是标准属性,现代浏览器(如 Chrome、Firefox)不支持。
    • 如果需要跨浏览器兼容,建议使用 transform: scale()
  2. 布局影响

    • zoom 会重新计算元素的布局,可能导致页面其他元素的位置发生变化。
  3. 性能问题

    • zoom 的性能较差,尤其是在复杂页面中。

6.替代方案:transform: scale()

如果不需要支持旧版浏览器,建议使用 transform: scale()

.example {
  transform: scale(1.5); /* 放大到 150% */
  transform-origin: 0 0; /* 设置缩放中心点 */
}css
  • transform-origin:设置缩放的中心点,默认是元素中心。

7.结合 JavaScript 动态缩放

通过 JavaScript 动态设置 zoom 或 transform: scale()

<div id="box" style="width: 100px; height: 100px; background: red;"></div>
<button onclick="zoomIn()">放大</button>
<button onclick="zoomOut()">缩小</button>

<script>
  const box = document.getElementById('box');

  function zoomIn() {
    box.style.zoom = (parseFloat(box.style.zoom) || 1) + 0.1;
  }

  function zoomOut() {
    box.style.zoom = (parseFloat(box.style.zoom) || 1) - 0.1;
  }
</script>html

8.响应式缩放

通过媒体查询实现响应式缩放。

@media (max-width: 600px) {
  .responsive-zoom {
    zoom: 0.8; /* 在小屏幕上缩小到 80% */
  }
}css

总结

  • zoom 是一个简单的缩放属性,但兼容性和性能较差。
  • 现代开发中,建议使用 transform: scale() 作为替代方案。
  • 如果需要支持旧版浏览器(如 IE),可以结合 zoom 和 transform: scale() 实现兼容性。

参考文档

blog.csdn.net/weixin_4509…

juejin.cn/post/733874…

uni-app项目从0-1基础架构搭建全流程

前情

最近新接了一个全新项目,我负责从0开始搭建小程序,我选用的技术栈是uni-app技术栈,UI库选择的是uview-plus,CSS引入现在流行的tainlwindcss,实现CSS原子化书写,实现小程序分包,分包中实现webview使用和彩蛋页(方便开发和测试使用的功能页),同时实现接口请求、本地缓存等常用工具方法的封装

基础架构主要内容

image.png

项目搭建

tailwindcss原子化CSS框架引入

对于uni-app项目官方有自带的IDE,以往新项目我都是通过IDE新建空白项目,再一点点增加自己的东西这样做的。

image.png

但这一次我就踩到了第一个小坑,我一开始是建的空白项目,在引入tainlwindcss的时候,发现按文挡一步步操作下来,最后一直报错,折腾了好一会没解决,我都已经为前面我接手的二个历史项目都成功引入了,但这一次就是没法搞成功,好在因项目是刚刚开始,没有历史负担,也不想浪费太多时间折腾了,最后是直接用的已经引入了tailwindcss的项目模板开始的。

tailwindcss引入方案选用的是weapp-tailwindcss,官网:tw.icebreaker.top

使用的项目基础模板仓库:github.com/icebreaker-…

uview-plus的ui库引入

官方文挡有提供几种方式在自己项目中引入,我这里使用的Hbuilder X的方式,文挡地址:uiadmin.net/uview-plus/…

image 1.png

点击下载地址去unicloud插件市场下载安装插件即可,安装好插件后,按官方文挡:下载安装方式配置 | uview-plus - 全面兼容nvue/鸿蒙/uni-app-x的uni-app生态框架 - uni-app UI框架 一步一步配置即可,至此UI组件已成功引入

uview-plus我在很多个项目中都有使用,组件挺丰富,但是BUG也有遇到一些,自己改改源码问题不大,最让人恼火的就是每天看文挡都要强制你看一条视频广告,其实页面中放广告,这真的不是问题,这是开源作者的自救,我是支持的,但是强制观看广告才能查文挡有点吃相不是特别好,但这是人家的开源项目,你也不能说什么,如果你很介意那强制的广告官方好像有办会员免广告,或者你可以使用uvui库:我的资料管理-uv-ui 是全面兼容vue3+2、nvue、app、h5、小程序等多端的uni-app生态框架,也是个不错选择,他们的组件API大差不大,我接手过一个老项目里面就用了很多的uvui的组件,我都是改下组件名无缝切换了,当然对于前端开发的我们,uview-plus那小小拦截弹框也难不倒我们,再加之我先接触的是uview-plus,我此项目使用的就是它了

分包实现

对于绝大多数公司的小程序项目,分包是必须要做的,就是不为性能考虑,小程序单包2M的限制,对于公司项目动不动就上百个页面的,不分包是不太可能的,所以我一开始搭基础架构的时候就做好分包

小程序分包是非常简单的,就一个配置一个目录就搞定的事,一个配置就是在pages.json中申明分包名称和当前分包的路由,目录就是分包的本地目录

{
    "pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
        {
            "path": "pages/index/index",
            "style": {
                "navigationBarTitleText": "首页",
                "navigationBarTextStyle": "white",
                "navigationBarBackgroundColor": "#191320",
                "disableScroll": true,
                "enablePullDownRefresh": false,
                "navigationStyle": "custom",
                // #ifdef MP-ALIPAY
                "transparentTitle": "always",
                "titlePenetrate": "YES",
                "allowsBounceVertical": "NO"
                // #endif
            }
        },
        ...
    ],
    // 分包配置
    "subPackages": [
        {
            "root": "other",
            "pages": [
                {
                    "path" : "egg/egg",
                    "style" :
                    {
                        "navigationBarTitleText" : "彩蛋页"
                    }
                },
                {
                    "path" : "webview/webview",
                    "style" :
                    {
                        "navigationBarTitleText" : ""
                    }
                },
                ...
  ],
    ...
}

你想把放到子包的页面放到对应子包目录中即完成了分包,这里分了一个other分包,基础架构提供彩蛋页和webview页面

彩蛋页主要用于方便测试,提供了当前小程序所处环境展示,手动切换小程序环境,还有本地缓存管理,这个你可以在开发版中在页面的某一个不显眼的地点放一个入口,我的做法是连续点击底部logo 6次进入彩蛋页,彩蛋页可以根据你项目要求自己实现添加一些方便测试的功能。

image 2.png

webview页面主要是用于小程序中展示H5页面,像隐私协议等一些不是特别重要,但又不得不有的H5页面,通过link传一个链接过去,就可以展示H5页面

通用工具方法

对于前端项目,接口请求,本地缓存操作是基本不会缺少的功能需求,于是引入我自己已经封装好且已经发布在插件市场的一个插件:常用工具方法 - DCloud 插件市场,该插件基于uni.request封装了接口请求,支持请求和响应的拦截,同时地封装本地存储方法,支持设置过期时间,本地存储提供了二个,你可以选择使用,同时提供了小程序获取元素信息(宽高位置等)的方法,对于一些常用的正则也提供了方法,具体使用可以查看文挡,或者查看源码使用,都不复杂,使用中遇到问题可以留言,我会不时观看用户留言的

基础架构模板目录结构

经过上面一些折腾最终生成的项目目录结构如下:

项目根目录
├── .editorconfig                // 编辑器配置文件
├── .gitignore                   // Git忽略文件
├── App.vue                      // 应用入口组件
├── index.html                   // HTML入口文件
├── main.js                      // 主入口文件
├── manifest.json               // uni-app配置文件
├── package.json                // 项目配置文件
├── pages.json                  // 页面路由配置
├── postcss.config.cjs         // PostCSS配置
├── README.md                   // 项目说明文档
├── shared.js                   // 公用配置
├── tailwind.config.js         // Tailwind CSS配置
├── uni.scss                    // uniapp全局样式
├── vite.config.js             // Vite配置文件
│
├── api/                        // API接口目录
│   └── login.js
│
├── components/                 // 组件目录
│
├── config/                     // 配置目录
│   └── http.js                // HTTP请求配置
│
├── other/                      // other子包
│   ├── egg/
│   │   └── egg.vue
│   └── webview/
│       └── webview.vue
│
├── pages/                      // 页面目录
│   ├── example_tailwindcss/
│   │   └── index.vue
│   └── index/
│       └── index.vue
│
├── static/                     // 静态资源目录,放弃项目图片等静态资源
│   └── .gitkeep
│
├── uni_modules/                // uni-app模块目录
│   ├── hbxw-utils/            // hbxw-utils工具包
│   │   ├── changelog.md
│   │   ├── package.json
│   │   ├── readme.md
│   │   └── js_sdk/
│   └── uview-plus/            // uView Plus组件库
│       ├── changelog.md
│       ├── index.js
│       └── ...
│
└── utils/                    // 工具函数目录
    ├── request.js            // 请求工具
    └── share.js              // 微信分享方法

基本项目架构模板分享

代码仓库

其实有机会参与一个项目从0-1是很幸运的,很多时间大多数都是接手前辈们留下的珍贵历史代码,久久在其中出不来,当然这也是常态,我也接手过不少的这种历史项目,其实还好吧,在公司没有下决心重构代码的时候,记住一点就好,前辈的代码能跑就就不要去动它,开发功能也是基于扩展,除非是非动不可,有点说跑题,这一次正好有一个从0到1的项目,在进入开发的时候我就发现有必要整理出一套模板,后续新项目的基础架构可以一步到位,我于是趁这个机会搭建了这一套基础架构模板,欢迎大家clone使用,仓库地址:xiewu/uniapp-vue3-tailwindcss-uview-plus

模板生成器cli

直接clone使用体验,而且代码仓库没有放github,你也不用老担心没法访问的问题,体验也是非常棒的,如果你觉得clone不合自己味口,可以使用我写好的模板生成器cli:@xwy-cli/cli - npm,它可以一键生成这个项目模板,同时还支持生成一些其它开发中常用的文件模板,如.gitignore,editconfig等,后续会有别的模板我也会持续更新添加,欢迎关注使用,如果你有需要添加的模板,我也可以帮忙添加的,当然前提是要通用的,而不是个人定制的,如果你有这种个人定制的需求,可以留言或者私信我,我推荐个软件给你,也是我经常用的用于保存自己的代码段和模板的工具🤝

最后闲聊

模板只提供了基本的项目架构,可能并不一定满足你的需求,或者你clone下来项目报错了,使用不了,就在心里骂娘了,大可不必,我在DCloud插件市场分享了不少组件,有好几个开发者即不说明报什么错,也不说遇到什么问题,直接一星差评又说垃圾代码不要分享出来,我又无法跟踪问题在哪里,很头疼,使用不了有可能跟你node版本或者别的环境问题有关,你可以尝试解决,通过搜索引擎搜索对应错误提示,加之现在又AI胜行,报什么错可以尝试丢给AI,让它帮你瞧瞧,也许调整调整就好了,实在解决不了可以留言或者换一个,东边不亮西边亮👀

鸿蒙next 定位开发全场景实践

一、开场白

在智能设备普及的今天,位置服务已成为移动应用的基础设施。无论是外卖配送的实时轨迹追踪、导航应用的路径规划,还是运动健康类App的卡路里计算,精准的位置定位都是用户体验的关键支撑。鸿蒙next提供的位置服务框架,通过整合GNSS卫星定位与网络定位能力,为开发者提供了一套覆盖多场景、高可用的定位解决方案。本文将结合实际开发场景,深入解析四种典型定位模式的实现原理与代码实践,并分享常见问题的排查思路,帮助大家快速掌握鸿蒙next位置定位开发的核心知识点。

二、定位基础:两种核心定位方式解析

在鸿蒙next中,位置服务主要通过两种方式实现定位:GNSS定位网络定位,二者特性对比如下:

定位方式 技术原理 核心优势 典型场景
GNSS定位 基于GPS、北斗等全球导航卫星系统,通过设备芯片算法解算卫星信号获取位置 精度高(米级) 户外导航、精准打卡
网络定位 整合WLAN热点、蓝牙信标、基站信号等网络数据进行位置估算 响应快(秒级) 室内定位、快速签到

实际开发中,大家可以根据自己的业务需求灵活选择定位策略:例如户外导航场景优先使用GNSS定位保证精度,而室内场景则可结合网络定位提升响应速度。两种定位方式并非互斥,通过合理配置定位请求参数,可实现优势互补。

三、四大核心场景开发实战

3.1 当前位置定位:获取设备瞬时坐标

3.1.1 实现原理

通过getCurrentLocation()接口单次获取设备位置,支持两种定位请求类型:

  • CurrentLocationRequest:通用定位请求,可配置定位优先级(如精度优先或速度优先)
  • SingleLocationRequest:单次快速定位请求,适用于对时效性要求高的场景(如打车定位)

3.1.2 开发步骤

  1. 权限申请:在config.json中声明定位权限
{
  "reqPermissions": [
    {
      "name": "ohos.permission.LOCATION"
    }
  ]
}
  1. 配置定位请求:以快速定位为例,设置速度优先策略
// 实例化单次定位请求对象
const singleRequest: geoLocationManager.SingleLocationRequest = {
  locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_LOCATING_SPEED, // 速度优先策略
  locatingTimeoutMs: 10000 // 超时时间10秒
};
  1. 获取位置信息:通过Promise方式处理异步结果
geoLocationManager.getCurrentLocation(singleRequest)
  .then((location: geoLocationManager.Location) => {
    // 解析位置坐标
    const { latitude, longitude } = location;
    console.log(`当前坐标:纬度${latitude},经度${longitude}`);
  })
  .catch((err: BusinessError) => {
    console.error(`定位失败:${err.code}, ${err.message}`);
  });
  1. 逆地理编码:将坐标转换为地址描述
const reverseGeocodeRequest: geoLocationManager.ReverseGeocodeRequest = {
  latitude: location.latitude,
  longitude: location.longitude
};

geoLocationManager.getAddressesFromLocation(reverseGeocodeRequest, (err, data) => {
  if (data && data.length > 0) {
    const address = data[0].placeName;
    console.log(`当前地址:${address}`); // 如:文松路6号院1号
  }
});

3.2 实时位置定位,追踪运动轨迹

3.2.1 实现原理

通过on('locationChange')接口订阅位置变化事件,支持配置定位场景类型(如步行、驾车)与上报间隔,系统会根据场景自动优化定位策略。

3.2.2 开发步骤

  1. 权限与请求配置:声明权限并创建持续定位请求
const continuousRequest: geoLocationManager.ContinuousLocationRequest = {
  locationScenario: geoLocationManager.UserActivityScenario.NAVIGATION, // 导航场景(优化定位频率)
  interval: 1, // 每秒上报一次位置
  locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_LOCATING_ACCURACY // 精度优先
};
  1. 开启位置订阅:绑定回调函数处理实时数据
// 定义位置变化处理器
const handleLocationChange = (location: geoLocationManager.Location) => {
  const timestamp = new Date().toLocaleTimeString();
  console.log(`${timestamp} 实时坐标:${location.latitude}, ${location.longitude}`);
  // 此处可将坐标发送至服务器更新轨迹
};

// 订阅位置变化事件
geoLocationManager.on('locationChange', continuousRequest, handleLocationChange);
  1. 资源释放:停止定位时取消订阅
// 移除位置变化监听器
geoLocationManager.off('locationChange', handleLocationChange);

··

3.3 应用后台持续获取定位

3.3.1 实现原理

后台定位需要同时申请后台定位权限长时任务权限,通过BackgroundTaskManager维持后台服务,确保应用切至后台后仍能获取位置更新。

3.3.2 开发步骤

  1. 权限声明:在module.json5中配置后台权限
{
  "reqPermissions": [
    {
      "name": "ohos.permission.LOCATION_IN_BACKGROUND",
      "reason": "需要在后台获取位置信息",
      "usedScene": {
        "abilities": ["MainAbility"],
        "when": "always"
      }
    },
    {
      "name": "ohos.permission.KEEP_BACKGROUND_RUNNING",
      "reason": "维持后台定位任务"
    }
  ],
  "abilities": [
    {
      "name": ".MainAbility",
      "backgroundModes": ["location"] // 声明支持定位后台模式
    }
  ]
}
  1. 启动后台任务:创建长时任务并绑定定位订阅
// 启动后台定位任务
const startBackgroundTask = () => {
  const context = getContext(this) as common.UIAbilityContext;
  if (!context) return;

  // 创建任务代理
  const wantAgentInfo: wantAgent.WantAgentInfo = {
    wants: [{
      bundleName: context.abilityInfo.bundleName,
      abilityName: context.abilityInfo.name
    }],
    operationType: wantAgent.OperationType.START_ABILITY
  };

  wantAgent.getWantAgent(wantAgentInfo).then(wantAgentObj => {
    backgroundTaskManager.startBackgroundRunning(context, 
      backgroundTaskManager.BackgroundMode.LOCATION, 
      wantAgentObj
    ).then(() => {
      // 启动位置订阅
      this.subscribeLocationChange();
      console.log('后台任务启动成功');
    });
  });
};
  1. 位置订阅与处理:在后台任务中持续获取位置
private subscribeLocationChange() {
  const request: geoLocationManager.ContinuousLocationRequest = {
    locationScenario: geoLocationManager.UserActivityScenario.FITNESS, // 运动场景(平衡精度与功耗)
    interval: 5 // 每5秒上报一次
  };

  geoLocationManager.on('locationChange', request, (location) => {
    // 存储轨迹数据或发送至云端
    this.saveTrack(location);
  });
}
  1. 停止后台任务:确保资源正确释放
const stopBackgroundTask = () => {
  const context = getContext(this) as common.UIAbilityContext;
  backgroundTaskManager.stopBackgroundRunning(context).then(() => {
    geoLocationManager.off('locationChange'); // 取消位置订阅
    console.log('后台任务停止');
  });
};

3.4 历史定位获取,结合缓存数据

3.4.1 实现原理

通过getLastLocation()接口获取系统缓存的最近一次有效位置,适用于网络信号弱或需要降低功耗的场景,比如后台静默定位。

3.4.2 开发步骤

// 获取缓存位置
const lastLocation = geoLocationManager.getLastLocation();
if (lastLocation) {
  console.log(`缓存坐标:${lastLocation.latitude}, ${lastLocation.longitude}`);
  // 执行逆地理编码
  this.reverseGeocode(lastLocation);
} else {
  console.log('没有可用缓存位置,需要发起实时定位');
}

3.4.3 注意事项

  • 缓存位置可能非最新,需结合时间戳判断有效性
  • 首次定位时可能无缓存数据,需 fallback 至实时定位

四、常见问题排查和解决

4.1 定位不准或偏差

问题现象

定位结果在地图上显示偏移,尤其在国内使用非华为地图时更为明显。

根因分析

  • 定位接口返回的是国际通用的WGS84坐标系
  • 国内地图服务(如华为地图)通常使用GCJ02坐标系,直接渲染会导致偏移

解决方案

// WGS84转GCJ02坐标系示例(需引入坐标转换工具库)
import { wgs84ToGcj02 } from '@huawei/map-kit-utils';

const { latitude, longitude } = location;
const [gcjLat, gcjLng] = wgs84ToGcj02(latitude, longitude);
// 使用转换后的坐标渲染地图

4.2 定位失败了如何检查

排查步骤 检查点 修复措施
1. 权限校验 是否申请ohos.permission.LOCATION module.json5配置文件中补充权限声明
2. 系统设置 设备定位开关是否开启 引导用户至系统设置开启位置服务
3. 网络状态 是否连接网络/插入SIM卡 提示用户检查网络连接
4. 物理环境 是否处于室内或信号遮挡区域 建议移动至开阔地带重新定位

4.3 缓存位置不一致

问题场景

连续调用getCurrentLocation()getLastLocation()返回不同结果。

原因解析

系统缓存位置为全局共享,其他应用的定位操作可能刷新缓存。

应对策略

// 获取位置时记录时间戳
const currentLocation = {
  ...location,
  timestamp: Date.now()
};

// 对比缓存位置时间
const lastLocation = geoLocationManager.getLastLocation();
if (lastLocation && currentLocation.timestamp - lastLocation.timestamp < 30000) {
  // 缓存有效,使用当前数据
} else {
  // 缓存过期,发起新定位
}

五、性能优化与功耗控制

5.1 定位策略动态调整

  • 户外场景:优先使用GNSS定位(PRIORITY_LOCATING_ACCURACY),提升精度
  • 室内场景:切换至网络定位(PRIORITY_LOCATING_SPEED),降低功耗
  • 后台场景:采用UserActivityScenario.FITNESS模式,延长上报间隔至30秒以上

5.2 资源及时释放

  • 非必要时调用off('locationChange')取消位置订阅
  • 后台任务使用完毕后通过stopBackgroundRunning()终止服务
  • 页面销毁时及时释放资源

5.3 功耗测试

可以使用DevEco Studio的Energy Profile工具分析定位模块功耗,重点关注:

  • 定位接口调用频率
  • 后台任务存活时间
  • 网络请求与传感器使用时长

六、总结

本文通过了最常见的四大核心场景,展现了鸿蒙next位置服务从权限配置、定位请求构建到数据处理的完整流程,大家重点关注以下的四点。

  1. 定位策略的选择:根据业务需求平衡精度与功耗
  2. 坐标系转换:国内场景需强制进行WGS84到GCJ02的坐标转换
  3. 后台任务管理:合理使用长时任务,避免资源泄漏
  4. 异常处理:完善的错误捕获与用户引导机制

没有设计稿也能很漂亮,非常适合独立开发:Trae + 飞个马MCP

大家好,我是一名前端工程师,也是开源图片编辑器vue-fabric-editor项目的作者,最近一直在迭代我们的商业版图片编辑器😍,因为团队规模比较小,没有专门的设计师,就尝试使用Trae + figma + MCP来优化页面样式,没想到效果超级棒,真的惊艳到我了, 非常适合没有设计师提供设计稿的小团队或者独立开发者。

作为一个工作十余年的切图仔,真的觉得是在解放生产力,这里做一下使用的简单介绍,推荐给大家。

说明

大部分开发者都希望一键生成,目前看多少还是有点噱头的,直接生成HTML可以,但是要生成完整可运行的代码,稍微加点业务逻辑就不行了,但是换个思路,稍微调整一下步骤,就出现了事半功倍的效果

我的思路是先开发功能,再调整样式,使用起来效果就很好。

我们的步骤是先让实习的同事做功能开发,把调用接口和展示逻辑开发完成,但是一般页面都会素素的,很没有食欲(别笑,你写也不行...), 如下图:

image.png

然后我再通过Trae + figma + MCP来做样式优化,这是优化完成的效果,下边是调整后的效果:

image.png

样式优化的结果我很满意的,另外我只是在AI的结果上做了轻微少量的调整,真的很高效。

如何使用

一共分为5步,前2个步骤只需要设置一次,几分钟搞定,后续直接使用就可以。

  1. 获取Figma账号 Token。
  2. Trae设置 MCP Token。
  3. Figma 挑选喜欢的模板
  4. 复制元素链接并交给AI,预览结果
  5. 微调 上线。

1. 获取Figma账号 Token。

登录后从设置页面,生成Token,权限选择只读。

20250520094828.png

image.png

image.png

2. Trae设置MCP Token

搞前端Trae还不知道就不说了,这么漂亮的编辑器,用起来很顺手,我是不舍得换了。 AI对话框点击设置 => MCP,然后点击添加,搜索Figma AI Bridge,安装后设置Token就可以了。

image.png

image.png

image.png

好了,这些设置只需要1次,设置完以后就不用每次调整了,接下来就是使用了。

3. Figma 挑选喜欢的模板

接下来就很简单了,在Figma网站上挑选自己喜欢的模板,我搜索的关键词是 dashboard,可以挑选一些和现有页面机构类似的效果图。

image.png

这是我挑选的几个效果图:

image.png

image.png

4. 复制元素链接并交给AI,预览结果

Figma 可以直接定位到某个元素的链接,我们选中一个区域后,右键复制链接。

image.png

然后在Trae的AI对话框中选择智能体,把链接复制上,并选中要调优的代码,把你的需求告诉AI。

image.png

image.png

5. 微调 上线

相比比较我们自己手写很多样式去调整,AI的效率很高了,好描述好理解的就交给AI,简单的就自己手动调整一下(别太懒,AI再智能就没工作了😂)。

image.png

结尾

自己也算是一个比较资深的切图仔了,从网页三剑客的Dreamweaver写Table布局开始,再到Sublime的快捷键编写网页,再到VScode,再到现在的AI类智能编辑器,真的是翻天覆地的变化。

我很认同在某个播客采访中提到的一个观点:积极的拥抱AI吧,未来是属于会用好AI的人。

最后,为我们的开源图片编辑器 github.com/ikuaitu/vue… 拉个粉,大家Star一下吧。

计算机图形学中的法线

引言

在计算机图形学中,法线(Normal)是一个基础且关键的概念。它们在光照计算、表面交互、物理模拟等多个领域都有重要应用。理解法线的概念及其工作原理,是掌握计算机图形学渲染技术的重要一步。

为什么需要学习法线?

法线是 3D 渲染中光照计算的基础。没有正确的法线信息,3D 模型看起来会是完全平坦的,没有阴影、高光或任何立体感。

什么是法线?

在计算机图形学中,法线是垂直于表面的向量。对于平面来说,法线在整个平面上都是相同的;但对于曲面,法线会随着表面的弯曲而变化。

法线的直观理解

想象你站在一个 3D 模型的表面上,头顶所指的方向就是该点的法线方向。对于一个完美的球体,表面上每个点的法线都指向球心的反方向。

  • 法线总是垂直于表面
  • 法线通常是单位向量(长度为 1)
  • 法线的方向决定了表面看起来是朝向还是背向光源

法线的数学表示

在 3D 空间中,法线通常用三维向量表示。对于一个平面,可以通过平面上的两个不平行向量的叉乘来计算法线。

向量叉乘计算法线

给定平面上的两个向量 A 和 B,它们的叉乘结果是一个垂直于这两个向量的新向量,即平面的法线。

// 向量叉乘计算法线
function crossProduct(vectorA, vectorB) {
    return [        vectorA[1] * vectorB[2] - vectorA[2] * vectorB[1],
        vectorA[2] * vectorB[0] - vectorA[0] * vectorB[2],
        vectorA[0] * vectorB[1] - vectorA[1] * vectorB[0]
    ];
}
// 计算平面法线示例
const vectorA = [1, 0, 0]; // X轴方向的向量
const vectorB = [0, 1, 0]; // Y轴方向的向量
const normal = crossProduct(vectorA, vectorB);
console.log("计算得到的法线:", normal); // 输出: [0, 0, 1],即Z轴方向

注意: 法线向量的方向遵循右手定则:如果你的右手四指从向量 A 弯曲到向量 B,那么大拇指所指的方向就是法线方向。

归一化法线向量

在实际应用中,我们通常需要将法线向量归一化,使其长度为 1。这样可以确保光照计算的一致性和正确性。

// 向量归一化函数
function normalize(vector) {
    const length = Math.sqrt(
        vector[0] * vector[0] + 
        vector[1] * vector[1] + 
        vector[2] * vector[2]
    );
    
    // 避免除以零
    if (length === 0) return [0, 0, 0];
    
    return [
        vector[0] / length,
        vector[1] / length,
        vector[2] / length
    ];
}
// 归一化法线示例
const nonNormalizedNormal = [3, 4, 0];
const normalizedNormal = normalize(nonNormalizedNormal);
console.log("归一化前:", nonNormalizedNormal); // 输出: [3, 4, 0]
console.log("归一化后:", normalizedNormal);   // 输出: [0.6, 0.8, 0],长度为1

计算法线

在计算机图形学中,我们通常需要为 3D 模型的每个顶点或面计算法线。下面介绍几种常见的计算方法。

面法线(Face Normals)

面法线是指垂直于多边形面的法线。对于三角形面,可以通过其三个顶点的位置计算得出。

// 计算三角形面法线
function calculateFaceNormal(vertexA, vertexB, vertexC) {
    // 计算边向量
    const edge1 = [        vertexB[0] - vertexA[0],
        vertexB[1] - vertexA[1],
        vertexB[2] - vertexA[2]
    ];
    
    const edge2 = [        vertexC[0] - vertexA[0],
        vertexC[1] - vertexA[1],
        vertexC[2] - vertexA[2]
    ];
    
    // 计算叉乘
    const normal = crossProduct(edge1, edge2);
    
    // 归一化
    return normalize(normal);
}
// 示例:计算三角形面法线
const vertexA = [0, 0, 0];
const vertexB = [1, 0, 0];
const vertexC = [0, 1, 0];
const faceNormal = calculateFaceNormal(vertexA, vertexB, vertexC);
console.log("三角形面法线:", faceNormal); // 输出: [0, 0, 1]

顶点法线(Vertex Normals)

顶点法线是指与顶点相关联的法线。对于平滑表面,顶点法线通常是共享该顶点的所有面法线的平均值。

// 计算顶点法线
function calculateVertexNormals(vertices, faces) {
    // 初始化所有顶点法线为零向量
    const vertexNormals = Array(vertices.length).fill().map(() => [0, 0, 0]);
    
    // 遍历每个面,累加面法线到对应的顶点
    faces.forEach(face => {
        const vA = vertices[face[0]];
        const vB = vertices[face[1]];
        const vC = vertices[face[2]];
        
        // 计算面法线
        const faceNormal = calculateFaceNormal(vA, vB, vC);
        
        // 累加到每个顶点的法线
        for (let i = 0; i < 3; i++) {
            vertexNormals[face[i]][0] += faceNormal[0];
            vertexNormals[face[i]][1] += faceNormal[1];
            vertexNormals[face[i]][2] += faceNormal[2];
        }
    });
    
    // 归一化所有顶点法线
    return vertexNormals.map(normalize);
}
// 示例:计算简单立方体的顶点法线
const cubeVertices = [    // 前面    [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
    // 后面
    [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]
];
const cubeFaces = [    [0, 1, 2], [0, 2, 3], // 前面
    [1, 5, 6], [1, 6, 2], // 右面
    [5, 4, 7], [5, 7, 6], // 后面
    [4, 0, 3], [4, 3, 7], // 左面
    [3, 2, 6], [3, 6, 7], // 上面
    [4, 5, 1], [4, 1, 0]  // 下面
];
const vertexNormals = calculateVertexNormals(cubeVertices, cubeFaces);
console.log("立方体顶点法线:", vertexNormals);

法线在着色中的应用

法线在光照和着色计算中起着核心作用。它们决定了光线如何与物体表面交互,从而影响表面的亮度和颜色。

兰伯特着色模型(Lambertian Shading)

兰伯特着色模型是一种基于表面法线和光线方向的基本光照模型。表面的亮度与表面法线和光线方向之间的夹角的余弦成正比。

// 兰伯特着色计算
function lambertShading(normal, lightDirection) {
    // 确保法线和光线方向都是单位向量
    const normalizedNormal = normalize(normal);
    const normalizedLightDirection = normalize(lightDirection);
    
    // 计算点积
    const dotProduct = 
        normalizedNormal[0] * normalizedLightDirection[0] +
        normalizedNormal[1] * normalizedLightDirection[1] +
        normalizedNormal[2] * normalizedLightDirection[2];
    
    // 确保结果非负(如果表面背对光源,则为0)
    return Math.max(0, dotProduct);
}
// 示例:计算表面点的光照强度
const surfaceNormal = [0, 0, 1]; // 表面法线朝上
const lightDirection = [0.5, 0, -0.5]; // 光线从斜上方照射
const intensity = lambertShading(surfaceNormal, lightDirection);
console.log("光照强度:", intensity); // 输出约为0.707

Phong 着色模型

Phong 着色模型在兰伯特模型的基础上增加了镜面反射分量,能够模拟光泽表面的高光效果。

// Phong着色计算
function phongShading(normal, lightDirection, viewDirection, shininess) {
    // 兰伯特漫反射分量
    const diffuseIntensity = lambertShading(normal, lightDirection);
    
    // 计算反射光线方向
    const normalizedNormal = normalize(normal);
    const normalizedLightDirection = normalize(lightDirection);
    
    // 反射向量计算: R = 2(N·L)N - L
    const dotNL = 
        normalizedNormal[0] * normalizedLightDirection[0] +
        normalizedNormal[1] * normalizedLightDirection[1] +
        normalizedNormal[2] * normalizedLightDirection[2];
    
    const reflectionDirection = [
        2 * dotNL * normalizedNormal[0] - normalizedLightDirection[0],
        2 * dotNL * normalizedNormal[1] - normalizedLightDirection[1],
        2 * dotNL * normalizedNormal[2] - normalizedLightDirection[2]
    ];
    
    // 计算镜面反射分量
    const normalizedViewDirection = normalize(viewDirection);
    const dotRV = 
        reflectionDirection[0] * normalizedViewDirection[0] +
        reflectionDirection[1] * normalizedViewDirection[1] +
        reflectionDirection[2] * normalizedViewDirection[2];
    
    const specularIntensity = Math.max(0, dotRV);
    const specularComponent = Math.pow(specularIntensity, shininess);
    
    // 返回总光照强度(漫反射 + 镜面反射)
    return diffuseIntensity + specularComponent;
}
// 示例:计算Phong着色
const viewDirection = [0, 0, 1]; // 观察方向
const shininess = 32; // 光泽度参数
const phongIntensity = phongShading(surfaceNormal, lightDirection, viewDirection, shininess);
console.log("Phong光照强度:", phongIntensity);

法线贴图(Normal Mapping)

法线贴图是一种纹理技术,通过存储表面细节的法线信息来模拟复杂的表面细节,而不需要增加实际的几何复杂度。

法线贴图的原理

法线贴图使用 RGB 颜色来存储表面法线信息。在法线贴图中:

  • 红色通道存储法线的 X 分量
  • 绿色通道存储法线的 Y 分量
  • 蓝色通道存储法线的 Z 分量
// 从法线贴图颜色值还原法线向量
function decodeNormalFromTexture(rgbColor) {
    // rgbColor是一个包含R、G、B值的数组,范围从0到255
    const r = rgbColor[0] / 255;
    const g = rgbColor[1] / 255;
    const b = rgbColor[2] / 255;
    
    // 将颜色值从[0,1]范围转换到[-1,1]范围
    const normal = [
        r * 2 - 1,
        g * 2 - 1,
        b * 2 - 1
    ];
    
    // 归一化法线向量
    return normalize(normal);
}
// 示例:从法线贴图颜色值还原法线
const textureColor = [128, 128, 255]; // 典型的蓝色法线贴图颜色
const normalVector = decodeNormalFromTexture(textureColor);
console.log("还原的法线向量:", normalVector); // 输出: [0, 0, 1]

切线空间(Tangent Space)

法线贴图通常在切线空间中定义,这样可以在不同的表面方向上正确应用。切线空间由三个向量定义:

  • 法线向量(Normal):垂直于表面
  • 切线向量(Tangent):沿着纹理 U 方向
  • 副切线向量(Bitangent):沿着纹理 V 方向,由法线和切线叉乘得到
// 计算切线空间矩阵
function calculateTangentSpace(normal, tangent) {
    // 归一化输入向量
    const normalizedNormal = normalize(normal);
    const normalizedTangent = normalize(tangent);
    
    // 计算副切线
    const bitangent = crossProduct(normalizedTangent, normalizedNormal);
    
    // 返回TBN矩阵(切线-副切线-法线矩阵)
    return [
        normalizedTangent[0], normalizedTangent[1], normalizedTangent[2],
        bitangent[0], bitangent[1], bitangent[2],
        normalizedNormal[0], normalizedNormal[1], normalizedNormal[2]
    ];
}
// 示例:计算TBN矩阵
const surfaceNormal = [0, 0, 1]; // 表面法线
const surfaceTangent = [1, 0, 0]; // 表面切线
const tbnMatrix = calculateTangentSpace(surfaceNormal, surfaceTangent);
console.log("切线空间矩阵:", tbnMatrix);

总结

法线是计算机图形学中不可或缺的概念,它们在光照计算、表面渲染和物理模拟中起着关键作用。通过本文,你应该对法线有了更深入的理解,包括:

  • 法线的基本概念:法线是垂直于表面的向量,用于确定表面在空间中的朝向。
  • 法线的计算方法:可以通过向量叉乘计算面法线,通过平均相邻面法线计算顶点法线。
  • 法线在光照中的应用:法线是计算光照效果的基础,如兰伯特漫反射和 Phong 镜面反射。
  • 法线贴图技术:法线贴图通过存储表面细节的法线信息,在不增加几何复杂度的情况下模拟复杂表面细节。

下一步学习建议

  • 学习更高级的光照模型,如 Blinn-Phong 模型和 PBR(基于物理的渲染)
  • 探索其他法线相关的纹理技术,如视差贴图和浮雕贴图
  • 了解法线在实时渲染引擎(如 Unity、Unreal Engine)中的应用

SVG 图形路径与 Three.js 绘图路径的关联与差异

在 Web 图形绘制领域,SVG(Scalable Vector Graphics)和 Three.js 是两个常用的技术。SVG 主要用于二维矢量图形绘制,而 Three.js 则是用于创建和渲染三维计算机图形的 JavaScript 库。尽管它们的应用场景有所不同,但在图形路径的概念和实现上存在一定的关联,同时也有各自的特点。接下来,我们深入探讨 SVG 的图形路径和 Three.js 的绘图路径之间的关系。

一、路径的基本概念

1.1 SVG 路径

SVG 中的路径通过path元素来定义,使用一组指令和参数来描述图形的形状。这些指令包括移动到(M)、直线到(L)、水平直线到(H)、垂直直线到(V)、曲线到(C、S、Q、T)等,参数则用于指定坐标点和曲线的控制点。例如,下面这段代码绘制了一个简单的三角形:

<svg width="200" height="200">
  <path d="M 100 10 L 190 190 L 10 190 Z" stroke="black" stroke-width="2" fill="none" />
</svg>

在上述代码中,M 100 10表示将绘图起点移动到坐标(100,10),L 190 190绘制一条从当前点到(190,190)的直线,L 10 190再绘制一条到(10,190)的直线,最后Z表示闭合路径,将当前点与起点连接起来。

1.2 Three.js 路径

在 Three.js 中,路径的概念同样用于描述形状,不过主要用于创建二维图形,之后可以通过拉伸、旋转等操作将其转换为三维模型。Three.js 中的路径通过THREE.Path类来创建和管理。例如:

const path = new THREE.Path();
path.moveTo(0, 0);
path.lineTo(100, 0);
path.lineTo(100, 100);
path.lineTo(0, 100);
path.closePath();

上述代码先实例化了一个THREE.Path对象,然后使用moveTo方法设置起始点,lineTo方法绘制直线,最后通过closePath方法闭合路径,形成一个正方形路径。

二、两者的关联

2.1 基础绘图指令相似性

SVG 和 Three.js 在基础绘图指令上有明显的相似性。比如,SVG 中的M(移动到)指令对应 Three.js 中Path类的moveTo方法,都是用于设置绘图的起始位置;SVG 的L(直线到)指令和 Three.js 中Path类的lineTo方法功能一致,都是从当前点绘制一条直线到指定点。这种相似性使得熟悉 SVG 路径绘制的开发者能够快速上手 Three.js 中的路径创建。

2.2 路径数据的转换可能性

由于两者在路径描述上的相似性,SVG 的路径数据是可以转换为 Three.js 可用的路径数据的。可以通过解析 SVG 的d属性值,提取其中的指令和参数,然后对应调用 Three.js 中Path类的方法来重新构建路径。例如,使用 JavaScript 的字符串处理方法解析 SVG 的d属性:

const svgPathData = "M 100 10 L 190 190 L 10 190 Z";
const threePath = new THREE.Path();
const commands = svgPathData.split(/\s+/).filter(Boolean);
let currentCommand;
for (const command of commands) {
  if (/^[A-Za-z]$/.test(command)) {
    currentCommand = command;
  } else {
    const [x, y] = command.split(",").map(Number);
    if (currentCommand === "M") {
      threePath.moveTo(x, y);
    } else if (currentCommand === "L") {
      threePath.lineTo(x, y);
    }
  }
}

上述代码将 SVG 路径数据解析并转换为了 Three.js 的路径对象。

2.3 作为形状基础

无论是 SVG 还是 Three.js,路径都是构建复杂形状的基础。在 SVG 中,通过组合各种路径指令可以绘制出各种二维图形;在 Three.js 中,路径可以作为ExtrudeGeometry(拉伸几何体)、LatheGeometry(车床几何体)等三维几何体创建的基础形状,通过对路径进行三维操作生成复杂的三维模型。例如,使用 Three.js 的ExtrudeGeometry将之前创建的正方形路径拉伸成一个立方体:

const geometry = new THREE.ExtrudeGeometry(path, {
  depth: 20,
  bevelEnabled: false
});
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

三、两者的差异

3.1 维度与应用场景

SVG 主要用于二维图形的绘制和展示,适用于图标、图表、简单动画等场景;而 Three.js 专注于三维图形的创建和渲染,常用于游戏开发、3D 可视化、虚拟场景构建等。这导致了它们在路径处理上的侧重点不同,SVG 路径更关注二维平面上的形状精确绘制,Three.js 路径则更多作为三维建模的起点。

3.2 指令丰富度与复杂度

SVG 的路径指令更加丰富和细致,拥有多种曲线绘制指令(如三次贝塞尔曲线C、二次贝塞尔曲线Q等),可以精确控制曲线形状;相比之下,Three.js 的Path类提供的指令相对较少,主要集中在基础的直线和简单曲线绘制上。如果需要在 Three.js 中创建复杂曲线,可能需要通过多次调用简单指令或者借助数学计算来模拟。

3.3 与渲染系统的集成方式

SVG 是基于 HTML 和 CSS 的,其路径绘制和渲染直接由浏览器的渲染引擎处理,与网页的布局和样式系统紧密结合;而 Three.js 有自己独立的渲染系统,需要通过场景(Scene)、相机(Camera)和渲染器(Renderer)来完成图形的渲染,路径创建后还需要经过几何体和材质的设置以及添加到场景中等一系列步骤才能最终显示出来。

四、实际应用示例

4.1 使用 SVG 路径创建动态图表

在一个简单的柱状图示例中,我们可以使用 SVG 路径来绘制柱状条。假设我们有一组数据[10, 20, 15, 25],可以通过以下代码绘制柱状图:

<svg width="300" height="200">
  <g transform="translate(20, 20)">
    <rect x="0" y="180" width="50" height="-10" fill="blue" />
    <rect x="60" y="160" width="50" height="-20" fill="blue" />
    <rect x="120" y="170" width="50" height="-15" fill="blue" />
    <rect x="180" y="150" width="50" height="-25" fill="blue" />
  </g>
</svg>

这里虽然没有直接使用path元素,但也可以通过path元素结合直线指令来绘制更复杂的自定义形状的图表。

4.2 使用 Three.js 路径创建三维模型

下面我们使用 Three.js 路径创建一个螺旋楼梯模型。首先创建螺旋形状的路径:

const path = new THREE.Path();
const numSteps = 20;
const radius = 5;
for (let i = 0; i <= numSteps; i++) {
  const angle = (i / numSteps) * (2 * Math.PI);
  const x = radius * Math.cos(angle);
  const y = (i / numSteps) * 10;
  const z = radius * Math.sin(angle);
  if (i === 0) {
    path.moveTo(x, z);
  } else {
    path.lineTo(x, z);
  }
}

然后将路径拉伸成几何体并创建三维模型:

const geometry = new THREE.ExtrudeGeometry(path, {
  depth: 0.5,
  bevelEnabled: false
});
const material = new THREE.MeshBasicMaterial({ color: 0x8B4513 });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

通过以上内容,我们详细了解了 SVG 的图形路径和 Three.js 的绘图路径之间的关联与差异。无论是进行二维图形绘制还是三维模型创建,理解和掌握路径的概念和使用方法都至关重要。在实际项目中,可以根据具体需求灵活选择和运用这两种技术,充分发挥它们的优势。

以上文章全面剖析了两者绘图路径的联系与区别。若你觉得某些部分还需深入展开,或想补充其他内容,欢迎随时告诉我。

🚀一文看懂 npm 与 pnpm 的本质区别!不仅更快,甚至更安全!

你还在用 npm 吗?可能你已经踩过“幽灵依赖”的坑但没意识到。而今天,我们不仅要搞懂 npm 与 pnpm 的核心差异,还要快速上手 pnpm,享受它带来的飞一般的开发体验!

在日常开发中,你是否遇到过以下情况:

  • “某个包你明明没有安装,却能正常使用。”
  • “CI 测试莫名其妙挂了,本地一切正常。”
  • “升级依赖后突然报错,毫无头绪。”
  • “不仅安装依赖慢,node_modules 占用磁盘空间还大”

🎯 什么是 pnpm?它真的值得换吗?

pnpm 是一款速度飞快、节省磁盘空间、并彻底解决依赖管理问题的包管理器。

维度 npm pnpm(优势)
依赖结构 扁平结构,容易产生“幽灵依赖” 严格隔离依赖,根治幽灵依赖
安装速度 中等,重复下载依赖 极快,复用缓存,避免重复下载
磁盘空间 多项目重复安装,占用大 利用硬链接,节省50%以上磁盘空间
monorepo 支持 需额外工具(如 Lerna) 原生支持 Workspaces
Peer依赖冲突 运行时可能出错 安装时严格校验,提前发现冲突
迁移成本 - 命令兼容 npm,切换零成本

✅ 为什么 pnpm 更“安全”?看两个关键设计!

1. 使用“硬链接”,多项目共享依赖,磁盘更轻

pnpm 会把依赖缓存到统一的 .pnpm-store,再通过硬链接写入各项目的 node_modules

项目A、项目B 共用 react@18.2.0
→ 实际只下载一次,磁盘只占一份!

不仅安装快,还能在弱网/离线状态下秒装依赖(本地就存在)。

2. 安装阶段就校验 peerDependencies,早发现冲突

pnpm 默认启用 strict-peer-dependencies,防止不一致版本潜入项目中。举个例子:

"my-lib": {
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

如果你的项目用了 react@17,npm 会静悄悄放过,而 pnpm 会立即报错阻止安装,提前避免踩雷。

🔍 什么是“幽灵依赖”?你可能踩过而没意识到

幽灵依赖 ,指的是你在项目中使用了某个包,但它并没有出现在 package.json 中的 dependenciesdevDependencies 中。

这是 npm/yarn 的扁平化结构导致的副作用,例如:

// 你在项目中直接用到了 lodash
import _ from 'lodash';

但你的 package.json 并没有依赖声明:

"dependencies": {
  // 没有 lodash
}

项目还能跑?是因为 lodash 是某个间接依赖(如 webpack)的依赖,被 npm 扁平化安装到了 node_modules/ 根目录。但你换台电脑、CI 构建、升级依赖时,就可能直接 崩了

node_modules/
├─ webpack/
├─ lodash/  ← 本不该在这里,但 webpack 引入了它

而 pnpm 的结构则完全隔离每个包的依赖路径(沙箱隔离),避免这种“偷用”现象:

node_modules/
├─ .pnpm/
│  ├─ webpack@5.0.0/
│  │   └─ node_modules/
│  │       └─ lodash/

pnpm 常用命令速查表(几乎无学习成本)

场景 npm 命令 pnpm 命令 说明
安装所有依赖 npm install pnpm install 安装项目依赖
添加生产依赖 npm install lodash pnpm add lodash 默认加到 dependencies
添加开发依赖 npm install -D ts pnpm add -D ts 加到 devDependencies
移除依赖 npm uninstall lodash pnpm remove lodash 删除依赖
运行脚本 npm run build pnpm build 支持直接执行脚本名
清理缓存 npm cache clean --force pnpm store prune 清理缓存
查看缓存路径 npm config get cache pnpm store path 依赖缓存实际路径
查看依赖树 npm ls pnpm list 依赖树结构
更新依赖 npm update lodash pnpm update lodash 升级包到兼容版本
发布包 npm publish pnpm publish 完全兼容

pnpm 使用 pnpm-lock.yaml 代替 package-lock.json 锁文件。如何下载 pnpm:

npm install -g pnpm

项目迁移小贴士:统一团队用 pnpm

为了团队协作更顺畅,推荐用 only-allow 工具强制统一包管理器:

// package.json
"scripts": {
  "preinstall": "npx only-allow pnpm",
  "dev": "pnpm dev",
  "build": "pnpm build"
}

通过 only-allow 工具,开发或 CI 阶段都会强制使用 pnpm,避免混用 npm/yarn 导致的依赖差异或构建异常

总结

不信你试试:

pnpm install
pnpm list

感受一下安装速度和依赖结构的清晰度!
遇到 npm/yarn 依赖坑,也欢迎留言一起避坑~

❌