前端开发者半年实践:从Next.js全栈到AI集成与实时协作
2026/8/22 3:52:00 网站建设 项目流程

1. 一个普通前端的半年:从“搬砖”到“造轮子”的转变

去年下半年,我给自己定了个小目标:不再只是被动地接需求、写业务代码,而是主动去探索一些能让自己兴奋起来的东西。作为一个在中小厂摸爬滚打了四五年的“普通前端”,我太熟悉那种状态了——每天和产品经理 battle 需求,和 UI 核对像素,在无尽的表单、列表和弹窗中循环。技术栈?无非是 React、Vue 那套,加上一堆配置繁琐的构建工具。不是说业务开发没价值,而是时间久了,那种纯粹的、因为解决了一个技术难题或做出一个酷炫效果而带来的快乐,越来越少了。我意识到,如果不想被日复一日的重复性工作消磨掉热情,就必须主动给自己找点“乐子”,或者说,找点“折腾”的理由。

这半年,我尝试了一种被称为“vibe coding”的编码方式。这个词最近在前端圈挺火,简单说,它不是指某个具体的技术,而是一种状态和心态:跟着感觉走,为了兴趣和创造快乐而编码,过程轻松愉悦,结果往往能带来惊喜。它更像是开发者的“心流”体验,重点在于探索、实验和实现自己想法的过程本身,而不是为了完成 KPI 或赶工期。我的四个小项目,就是在这样的心态下诞生的。它们的技术栈不约而同地围绕Next.jsTypeScriptSupabase展开,这并非偶然,而是因为这套组合拳在当前前端“全栈化”的趋势下,能最大程度地降低折腾的门槛,让我能把精力集中在创意和实现上,而不是在环境配置和基础设施上纠缠不休。接下来,我就把这半年“ vibe coding ”的收获、踩过的坑以及一些具体的思考,毫无保留地分享给你。

2. 项目一:用 Next.js App Router + Supabase 构建个人足迹地图

第一个项目灵感来源于我的旅行习惯。我喜欢拍照,但照片散落在手机和各个云盘,时间一长就忘了具体在哪拍的。我就想,能不能做一个私人的、可视化的足迹地图?这个想法很简单,但涉及前端展示、后端数据存储和地图服务集成,是个不错的全栈练手项目。

2.1 技术选型:为什么是 Next.js App Router 和 Supabase?

当时 Next.js 13 的 App Router 已经稳定,我决定用它而不是传统的 Pages Router。原因有三:一是想彻底学习一下 React Server Components(RSC)和 Server Actions 这套新范式;二是 App Router 基于文件系统的路由、布局和流式渲染,对于这种个人项目来说,结构更清晰,开发体验更现代。至于后端和数据库,我直接选择了Supabase。对于一个独立开发者来说,自建后端服务器和维护数据库太沉重了。Supabase 提供了开箱即用的 Postgres 数据库、即时 API、认证、存储甚至实时订阅功能,它基于 PostgreSQL,这意味着我可以用熟悉的 SQL 去操作,同时它提供的 JavaScript/TypeScript 客户端又极其易用。最关键的是,它有慷慨的免费额度,足够支撑个人项目。

地图服务方面,我选择了 Mapbox,因为它比 Google Maps 更灵活,定制化程度高,且对于非商业用途也比较友好。

2.2 核心实现:从照片 EXIF 解析到地图渲染

这个项目的核心流程是:用户上传照片 -> 后端解析照片的 EXIF 数据(特别是 GPS 经纬度) -> 将位置信息存入 Supabase -> 前端从 Supabase 读取数据并用 Mapbox GL JS 渲染到地图上。

第一步,处理文件上传。我使用 Next.js 的 Server Actions 来处理上传。在app/api/upload/route.ts中,接收 FormData,将图片文件存储到 Supabase Storage 中。这里有个坑:直接在前端用exifr库解析图片,对于大图或数量多的图片,会阻塞主线程。我的解决方案是,在后端 Server Action 中进行解析。我写了一个parseExif的服务器端函数,使用exifr读取图片 Buffer,提取 GPS 信息。

// app/actions/upload-photo.ts ‘use server‘; import { createClient } from ‘@supabase/supabase-js‘; import exifr from ‘exifr‘; export async function uploadPhoto(formData: FormData) { const file = formData.get(‘photo‘) as File; if (!file) throw new Error(‘No file uploaded‘); // 1. 将文件转换为Buffer用于解析EXIF const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); let latitude, longitude; try { const exifData = await exifr.parse(buffer); if (exifData?.latitude && exifData?.longitude) { latitude = exifData.latitude; longitude = exifData.longitude; } } catch (error) { console.warn(‘Failed to parse EXIF:‘, error); } // 2. 上传文件到Supabase Storage const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!); const fileExt = file.name.split(‘.‘).pop(); const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${fileExt}`; const { data: uploadData, error: uploadError } = await supabase.storage .from(‘photos‘) .upload(fileName, file); if (uploadError) throw uploadError; // 3. 将元数据(包括位置)存入Supabase Database const { error: dbError } = await supabase .from(‘photo_marks‘) .insert({ storage_path: uploadData.path, latitude, longitude, title: formData.get(‘title‘) as string, taken_at: new Date().toISOString(), }); if (dbError) throw dbError; }

第二步,数据获取与渲染。在展示页面,我使用@supabase/ssr包在服务器组件中直接查询数据,这样数据获取是安全的,并且有利于 SEO。我在app/map/page.tsx中直接获取数据,然后通过 props 传递给一个客户端交互组件来渲染地图。

// app/map/page.tsx import { createClient } from ‘@supabase/supabase-js‘; import MapContainer from ‘@/components/MapContainer‘; export default async function MapPage() { const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!); const { data: marks, error } = await supabase .from(‘photo_marks‘) .select(‘*‘) .order(‘taken_at‘, { ascending: false }); if (error) { // 处理错误 } // 将数据传递给客户端组件 return <MapContainer initialMarks={marks || []} />; }

MapContainer是一个客户端组件(‘use client‘),内部使用 Mapbox。这里的关键点是,地图库和交互逻辑必须在客户端执行,但初始数据由服务器提供,这完美契合了 RSC 的模式。

踩坑心得:Supabase 的 RLS(行级安全)策略需要仔细配置。最初我直接用了匿名密钥,发现前端查询不到数据,因为默认 RLS 是开启的,禁止所有操作。我需要在 Supabase 控制台为photo_marks表创建策略,例如允许所有用户读取数据:CREATE POLICY “允许所有人读取” ON photo_marks FOR SELECT USING (true);。对于插入操作,可以限制为已认证用户,或者像我的个人项目一样,通过 Server Action 使用具有更高权限的服务角色密钥来绕过 RLS,但后者需要妥善保管密钥。

2.3 项目复盘:全栈初体验的得与失

这个项目让我第一次完整跑通了一个从前端到数据库的闭环。收获最大的是对 Next.js App Router 数据流(服务器组件获取数据 -> 传递给客户端组件交互)的理解,以及对 Supabase 这种 BaaS(后端即服务)效率的惊叹。它让我意识到,在云服务如此成熟的今天,个人开发者完全可以把复杂的后端运维交给专业平台,自己专注于业务逻辑和用户体验。

不足的地方在于,初期对 TypeScript 的使用还比较肤浅,很多类型是any或者简单的接口。随着项目复杂,类型定义变得混乱。这为第二个项目埋下了改进的伏笔。

3. 项目二:开发一个类型安全的博客内容管理器

在第一个项目后,我对 TypeScript 的渴求增强了。正好我想维护一个技术博客,但不想用现成的 Hexo 或 Hugo,觉得定制化不够。于是,第二个“vibe coding”项目诞生了:一个为自己量身打造、类型安全的内容管理系统(CMS),用于管理我的博客文章。

3.1 架构设计:基于文件系统的内容层

我不想把文章内容存到数据库里,因为 Markdown 文件本身便于版本管理(Git),也方便本地编辑。所以架构设计为:博客内容以.md.mdx文件形式存放在项目content/posts目录下,前端通过 Node.js 文件系统 API 读取、解析并渲染。

核心挑战是如何为这些文件内容提供强大的类型提示和验证。我选择了zod这个 TypeScript 模式验证库,它可以通过模式定义(schema)来推断出 TypeScript 类型,并且能在运行时进行数据验证。

首先,我定义了一个文章元数据的模式:

// lib/schemas/post.ts import { z } from ‘zod‘; export const PostMetaSchema = z.object({ title: z.string().min(1), slug: z.string().regex(/^[a-z0-9-]+$/), // 只允许小写字母、数字和连字符 date: z.string().datetime(), // ISO 8601 日期字符串 tags: z.array(z.string()).default([]), excerpt: z.string().optional(), published: z.boolean().default(false), }); export type PostMeta = z.infer<typeof PostMetaSchema>;

然后,每篇博客文章在文件顶部用 YAML Front Matter 定义元数据,后面是正文。

--- title: “我的类型安全博客实践” slug: “my-type-safe-blog” date: “2024-03-15T10:00:00.000Z” tags: [“typescript”, “nextjs”, “zod”] published: true excerpt: 本文介绍了如何使用Zod和Next.js构建类型安全的博客系统。 --- 这里是文章的正文内容,支持 **Markdown** 语法。

3.2 内容读取与验证:实现类型安全的 API

接下来,我需要一个工具函数来读取content/posts目录下的所有文件,解析 Front Matter,并用zod验证元数据,确保其符合预期格式。

// lib/posts.ts import fs from ‘fs/promises‘; import path from ‘path‘; import matter from ‘gray-matter‘; // 用于解析Front Matter import { PostMetaSchema, type PostMeta } from ‘./schemas/post‘; const postsDirectory = path.join(process.cwd(), ‘content/posts‘); export async function getAllPosts(): Promise<Array<PostMeta & { content: string }>> { const fileNames = await fs.readdir(postsDirectory); const posts = await Promise.all( fileNames .filter((fileName) => fileName.endsWith(‘.mdx‘)) .map(async (fileName) => { const slug = fileName.replace(/\.mdx$/, ‘‘); return await getPostBySlug(slug); }) ); // 按日期排序,只返回已发布的文章 return posts .filter((post): post is NonNullable<typeof post> => post !== null && post.meta.published) .sort((a, b) => new Date(b.meta.date).getTime() - new Date(a.meta.date).getTime()); } export async function getPostBySlug(slug: string): Promise<{ meta: PostMeta; content: string } | null> { try { const fullPath = path.join(postsDirectory, `${slug}.mdx`); const fileContents = await fs.readFile(fullPath, ‘utf8‘); const { data, content } = matter(fileContents); // 解析出data(Front Matter)和content(正文) // 关键步骤:使用Zod验证并解析Front Matter数据 const validationResult = PostMetaSchema.safeParse(data); if (!validationResult.success) { console.error(`文件 ${slug}.mdx 的Front Matter验证失败:`, validationResult.error.format()); return null; // 验证失败,返回null或抛出错误 } const meta = validationResult.data; return { meta, content }; } catch (error) { console.error(`读取文章 ${slug} 失败:`, error); return null; } }

在 Next.js 页面中,我就可以安全地使用这些数据了。由于getAllPosts返回的类型是确定的,我在组件中获得完美的类型提示和自动补全。

// app/blog/page.tsx import { getAllPosts } from ‘@/lib/posts‘; export default async function BlogPage() { const posts = await getAllPosts(); // posts 的类型是 (PostMeta & { content: string })[] return ( <div> <h1>博客文章</h1> <ul> {posts.map((post) => ( <li key={post.meta.slug}> <h2>{post.meta.title}</h2> <time>{new Date(post.meta.date).toLocaleDateString()}</time> <p>{post.meta.excerpt}</p> {/* 拥有完整的类型安全 */} </li> ))} </ul> </div> ); }

3.3 深度体验:TypeScript 与 Zod 带来的开发愉悦感

这个项目让我深刻体会到“类型安全即文档”的含义。以前写工具函数,总要翻看之前的代码或者靠记忆来知道返回的数据结构。现在,只要把鼠标悬停在getAllPosts函数上,IDE 就会清晰地告诉我它返回什么。如果我不小心写错了属性名,比如post.meta.titl(少了个 e),TypeScript 会在编辑器中立刻用红色波浪线标出错误,而不是等到运行时才在浏览器控制台看到undefined

Zod的运行时验证更是锦上添花。有一次我手动修改了一篇博客的 Front Matter,不小心把date字段写成了15 March 2024这种非 ISO 格式。在开发时,getPostBySlug函数通过safeParse检测到了这个错误,并在控制台给出了清晰的错误信息,精确指出了date字段不符合datetime格式。这避免了错误数据被渲染到页面上导致布局错乱或白屏。

这种“编码时预防错误,运行时捕获错误”的双重保障,极大地提升了开发效率和代码质量。它让“vibe coding”的“vibe”更好了,因为我不再需要时刻担心隐蔽的数据格式问题,可以更专注于内容创作和 UI 交互。

4. 项目三:探索 Next.js 与 AI 的轻量级结合——智能标签生成器

第三个项目,我想玩点更“时髦”的。AI 无疑是当下的热点,但我不想做那种复杂的聊天机器人或图像生成。我希望它能切实解决我一个痛点:给博客文章自动打标签。手动为每篇文章想标签很费神,而且不统一。于是,一个基于 AI 的智能标签生成器成了我的新玩具。

4.1 技术方案:为什么选择 Vercel AI SDK 和 OpenAI?

市面上 AI API 很多,我选择 OpenAI 的 ChatGPT 模型(gpt-3.5-turbo),主要是因为它性价比高,对于文本处理任务足够强大,且 API 稳定易用。在集成方式上,我没有直接使用openainpm 包,而是选择了Vercel AI SDK。原因在于,这个 SDK 由 Next.js 的创建公司 Vercel 维护,与 Next.js 生态(特别是 App Router)集成度极高,它抽象了流式响应(Streaming)的处理,让实现类似 ChatGPT 的打字机效果变得非常简单。

我的目标是在博客文章编辑页面,提供一个按钮,点击后可以将文章摘要或部分内容发送给 AI,让它返回一个建议的标签列表。

4.2 实现流式 AI 响应:从 API 路由到前端渲染

首先,我在 App Router 下创建了一个 API 端点:app/api/generate-tags/route.ts。这个端点接收文章内容,调用 OpenAI API,并以流的形式返回 AI 的响应。

// app/api/generate-tags/route.ts import { OpenAIStream, StreamingTextResponse } from ‘ai‘; // 来自 Vercel AI SDK import OpenAI from ‘openai‘; // 创建 OpenAI 客户端实例 const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY!, }); export async function POST(req: Request) { const { content } = await req.json(); // 构建给AI的提示词(Prompt) const prompt = ` 你是一个专业的博客标签生成助手。请根据以下博客文章的内容摘要,生成3到5个最相关、最通用的技术标签。 标签应该使用英文小写单词,多个单词用连字符连接,例如 “react-hooks“, “nextjs-app-router“。 请只返回标签本身,用逗号分隔,不要有任何其他解释。 文章内容摘要: ${content} `; // 调用 OpenAI API,并请求流式响应 const response = await openai.chat.completions.create({ model: ‘gpt-3.5-turbo‘, stream: true, // 关键:启用流式传输 messages: [{ role: ‘user‘, content: prompt }], temperature: 0.7, // 控制创造性,0.7比较平衡 }); // 使用 Vercel AI SDK 的工具将响应转换为流 const stream = OpenAIStream(response); // 返回一个流式响应 return new StreamingTextResponse(stream); }

在前端,我创建了一个客户端组件TagGenerator.tsx。它包含一个文本框用于输入文章摘要,一个按钮来触发请求,以及一个区域来展示流式返回的标签。

// components/TagGenerator.tsx ‘use client‘; import { useState } from ‘react‘; import { useCompletion } from ‘ai/react‘; // Vercel AI SDK 的 React Hook export function TagGenerator() { const [inputText, setInputText] = useState(‘‘); // useCompletion hook 封装了流式请求的所有逻辑 const { completion, isLoading, handleSubmit, error } = useCompletion({ api: ‘/api/generate-tags‘, }); const onSubmit = (e: React.FormEvent) => { e.preventDefault(); handleSubmit({ data: { content: inputText } }); }; return ( <div> <form onSubmit={onSubmit}> <textarea value={inputText} onChange={(e) => setInputText(e.target.value)} placeholder=“粘贴你的博客文章摘要...“ rows={4} /> <button type=“submit“ disabled={isLoading}> {isLoading ? ‘AI思考中...‘ : ‘生成标签‘} </button> </form> {error && <div>出错了: {error.message}</div>} {/* 直接展示流式返回的文本,会有打字机效果 */} <div> <strong>建议标签:</strong> {completion || ‘(等待生成...)‘} </div> </div> ); }

useCompletion这个 Hook 太方便了。它自动处理了发送 POST 请求、接收流式数据、拼接数据并更新completion状态的全过程。我几乎没写什么网络请求代码,就实现了流畅的“打字机”效果。

4.3 成本控制与提示词工程:让 AI 更“听话”

玩 AI API,成本是必须考虑的因素。gpt-3.5-turbo 价格已经很亲民,但为了防止意外(比如死循环调用),我做了两件事:一是在 Vercel 环境变量中设置 API 密钥,并利用 OpenAI 平台自身的用量监控和预算设置。二是在前端做了简单的防重复提交,在isLoading时为按钮添加disabled属性。

提示词(Prompt)的编写是成败的关键。最初的提示词很简单:“请为以下文章生成标签”。结果 AI 有时会返回很长的句子,有时会把标签用中文返回,格式混乱。经过几次迭代,我才打磨出上面代码中那个相对清晰的提示词:

  1. 明确角色:“你是一个专业的博客标签生成助手”。
  2. 明确任务:“生成3到5个最相关、最通用的技术标签”。
  3. 规定格式:“标签应该使用英文小写单词,多个单词用连字符连接”,并给出例子。
  4. 规定输出:“只返回标签本身,用逗号分隔,不要有任何其他解释”。

这样“调教”之后,AI 返回的结果就非常规整了,比如nextjs, typescript, zod, fullstack-development,我只需要用split(‘,‘)就能轻松处理。这个项目让我体验到,将 AI 能力以微服务的形式嵌入到现有应用中是如此简单,它不是一个遥不可及的黑科技,而是一个可以随手拿来解决具体问题的强大工具。

5. 项目四:构建一个实时协同的简易白板工具

最后一个项目,我想挑战一下实时交互。看到 Figma、Canva 那种多人在线协作的体验觉得很酷,就想自己能否用最简单的技术实现一个基础版。我的目标是:一个网页白板,多个用户可以同时在上面画线,并且能实时看到彼此的笔画。

5.1 实时通信选型:WebSocket 与 Supabase Realtime 的抉择

实现实时性,绕不开 WebSocket。我可以直接用ws库在 Node.js 上自建 WebSocket 服务器,但这意味着要管理另一个服务,处理连接、广播、断线重连等问题,对于个人项目负担较重。这时,我又想起了Supabase,因为它提供了一个Realtime功能,可以监听数据库的变更(Insert, Update, Delete),并通过 WebSocket 推送给订阅的客户端。

我的设计是:白板上的每一条笔画(包含起点、终点、颜色、粗细等数据)都作为一条记录存入 Supabase 的drawings表。当任何客户端画了一笔并插入数据时,Supabase Realtime 会通知所有其他订阅了该表变更的客户端。客户端收到通知后,拉取新的笔画数据并渲染到自己的画布上。

这避免了自建 WebSocket 服务器的复杂性,也无需处理直接的点对点通信,所有状态都以数据库为“单一数据源”。

5.2 前端实现:Canvas 绘图与实时订阅

前端核心是 HTML5 Canvas。我创建了一个Whiteboard客户端组件。

// components/Whiteboard.tsx ‘use client‘; import { useEffect, useRef, useState } from ‘react‘; import { createClient } from ‘@supabase/supabase-js‘; interface Drawing { id: string; startX: number; startY: number; endX: number; endY: number; color: string; width: number; created_at: string; } export default function Whiteboard() { const canvasRef = useRef<HTMLCanvasElement>(null); const [isDrawing, setIsDrawing] = useState(false); const [context, setContext] = useState<CanvasRenderingContext2D | null>(null); const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!); // 初始化Canvas上下文 useEffect(() => { const canvas = canvasRef.current; if (canvas) { const ctx = canvas.getContext(‘2d‘); if (ctx) { ctx.lineCap = ‘round‘; ctx.lineJoin = ‘round‘; setContext(ctx); // 加载历史笔画 loadExistingDrawings(ctx); } } }, []); // 订阅Supabase Realtime useEffect(() => { const channel = supabase .channel(‘realtime-drawings‘) // 频道名 .on( ‘postgres_changes‘, { event: ‘INSERT‘, // 只监听插入事件 schema: ‘public‘, table: ‘drawings‘, }, (payload) => { // 当有新的笔画插入时,在画布上绘制它 const newDrawing = payload.new as Drawing; drawLineOnCanvas(newDrawing); } ) .subscribe(); // 组件卸载时取消订阅 return () => { supabase.removeChannel(channel); }; }, [supabase]); const loadExistingDrawings = async (ctx: CanvasRenderingContext2D) => { const { data, error } = await supabase.from(‘drawings‘).select(‘*‘).order(‘created_at‘); if (!error && data) { data.forEach((drawing: Drawing) => drawLineOnCanvas(drawing, ctx)); } }; const drawLineOnCanvas = (drawing: Drawing, ctxParam?: CanvasRenderingContext2D) => { const ctxToUse = ctxParam || context; if (!ctxToUse) return; ctxToUse.beginPath(); ctxToUse.strokeStyle = drawing.color; ctxToUse.lineWidth = drawing.width; ctxToUse.moveTo(drawing.startX, drawing.startY); ctxToUse.lineTo(drawing.endX, drawing.endY); ctxToUse.stroke(); }; const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => { if (!context || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const startX = e.clientX - rect.left; const startY = e.clientY - rect.top; setIsDrawing(true); // 记录起点,这里简化处理,实际需要更复杂的路径记录 context.beginPath(); context.moveTo(startX, startY); }; const handleMouseMove = async (e: React.MouseEvent<HTMLCanvasElement>) => { if (!isDrawing || !context || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const endX = e.clientX - rect.left; const endY = e.clientY - rect.top; // 1. 在本地画布上画线 context.lineTo(endX, endY); context.stroke(); // 2. 将这条线发送到服务器(Supabase) const newDrawing = { startX: context.lastX || endX - 1, // 简化逻辑,实际应从状态中获取起点 startY: context.lastY || endY - 1, endX, endY, color: context.strokeStyle as string, width: context.lineWidth, }; // 插入数据库,这将触发Realtime推送 const { error } = await supabase.from(‘drawings‘).insert([newDrawing]); if (error) console.error(‘插入笔画失败:‘, error); }; const handleMouseUp = () => setIsDrawing(false); return ( <canvas ref={canvasRef} width={800} height={600} style={{ border: ‘1px solid #ccc‘ }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} /> ); }

5.3 遇到的挑战与优化:性能与实时性的平衡

这个简易实现跑起来后,我立刻发现了问题:性能瓶颈和网络风暴。每当鼠标移动(onMouseMove),就会触发一次数据库插入和一次网络请求。如果画得很快,每秒会产生数十甚至上百个请求,这对数据库和网络都是巨大压力,而且会导致笔画在别人屏幕上更新得断断续续。

优化方案:批处理与节流。

  1. 前端节流:我不再在每次mousemove时都发送请求,而是将一段时间内的笔画点收集到一个数组里。使用requestAnimationFramesetTimeout进行节流,比如每100毫秒将收集到的点作为一个“笔画段”发送出去。这大大减少了请求数量。
  2. 数据结构优化:将一条连续的笔画定义为一个由多个点组成的路径(path: {x, y}[]),而不是无数条短线。这样一次插入就能代表用户一笔画出的整条线,数据量更小,逻辑也更清晰。
  3. 后端确认:Supabase Realtime 非常可靠,但为了更好的用户体验,我可以在前端本地先渲染笔画(乐观更新),等收到服务器广播确认后,再做一个最终同步,防止网络延迟导致的显示不一致。

这个项目让我对实时应用的复杂性有了初步认识。它不仅仅是建立一条 WebSocket 连接那么简单,更涉及到数据同步策略、冲突解决(如果两个人同时画同一个位置怎么办?)、离线恢复等一系列问题。虽然我的实现非常基础,但这个过程让我理解了 Figma 这类工具背后技术的冰山一角,也体会到了“实时”二字所代表的技术深度。

6. 半年“vibe coding”给我的核心启示

回过头看这半年,四个项目都不大,甚至有些粗糙,但它们带给我的成长远超在业务代码里写一百个表单。技术栈上,我从一个对 Next.js App Router 和 Server Components 懵懂的开发者,变成了能熟练运用它们构建全栈应用的人。从畏惧后端数据库操作,到能自如运用 Supabase 这样的工具快速实现想法。更重要的是,我重新找回了编码的乐趣。

“vibe coding”的精髓不在于项目有多宏大,技术有多高深,而在于保持好奇心和动手能力。它可以是周末花几个小时验证一个想法,也可以是用新技术重构一个自己的小工具。在这个过程中,你必然会遇到问题,然后去搜索、阅读文档、调试、解决。这个过程本身就是最好的学习。

对于和我一样的“普通前端”,我的建议是:不要只把自己局限在“前端”的范畴里。现代前端开发的全栈化趋势越来越明显。Next.js、Nuxt.js 这些框架已经为我们铺好了路。像 Supabase、AppWrite 这样的 BaaS 降低了后端门槛。AI API 让智能功能触手可及。我们有能力,也应该去探索更完整的解决方案。从一个具体的、自己感兴趣的小点子出发,用“vibe coding”的心态去实现它。踩坑、填坑、最终看到作品跑起来的那一刻,那种成就感是无可替代的。这或许就是对抗技术焦虑和职业倦怠的最好方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询