
1. 一个普通前端的半年从“搬砖”到“造轮子”的转变去年下半年我给自己定了个小目标不再只是被动地接需求、写业务代码而是主动去探索一些能让自己兴奋起来的东西。作为一个在中小厂摸爬滚打了四五年的“普通前端”我太熟悉那种状态了——每天和产品经理 battle 需求和 UI 核对像素在无尽的表单、列表和弹窗中循环。技术栈无非是 React、Vue 那套加上一堆配置繁琐的构建工具。不是说业务开发没价值而是时间久了那种纯粹的、因为解决了一个技术难题或做出一个酷炫效果而带来的快乐越来越少了。我意识到如果不想被日复一日的重复性工作消磨掉热情就必须主动给自己找点“乐子”或者说找点“折腾”的理由。这半年我尝试了一种被称为“vibe coding”的编码方式。这个词最近在前端圈挺火简单说它不是指某个具体的技术而是一种状态和心态跟着感觉走为了兴趣和创造快乐而编码过程轻松愉悦结果往往能带来惊喜。它更像是开发者的“心流”体验重点在于探索、实验和实现自己想法的过程本身而不是为了完成 KPI 或赶工期。我的四个小项目就是在这样的心态下诞生的。它们的技术栈不约而同地围绕Next.js、TypeScript和Supabase展开这并非偶然而是因为这套组合拳在当前前端“全栈化”的趋势下能最大程度地降低折腾的门槛让我能把精力集中在创意和实现上而不是在环境配置和基础设施上纠缠不休。接下来我就把这半年“ vibe coding ”的收获、踩过的坑以及一些具体的思考毫无保留地分享给你。2. 项目一用 Next.js App Router Supabase 构建个人足迹地图第一个项目灵感来源于我的旅行习惯。我喜欢拍照但照片散落在手机和各个云盘时间一长就忘了具体在哪拍的。我就想能不能做一个私人的、可视化的足迹地图这个想法很简单但涉及前端展示、后端数据存储和地图服务集成是个不错的全栈练手项目。2.1 技术选型为什么是 Next.js App Router 和 Supabase当时 Next.js 13 的 App Router 已经稳定我决定用它而不是传统的 Pages Router。原因有三一是想彻底学习一下 React Server ComponentsRSC和 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.infertypeof 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(): PromiseArrayPostMeta { 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 NonNullabletypeof 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少了个 eTypeScript 会在编辑器中立刻用红色波浪线标出错误而不是等到运行时才在浏览器控制台看到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 有时会返回很长的句子有时会把标签用中文返回格式混乱。经过几次迭代我才打磨出上面代码中那个相对清晰的提示词明确角色“你是一个专业的博客标签生成助手”。明确任务“生成3到5个最相关、最通用的技术标签”。规定格式“标签应该使用英文小写单词多个单词用连字符连接”并给出例子。规定输出“只返回标签本身用逗号分隔不要有任何其他解释”。这样“调教”之后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 useRefHTMLCanvasElement(null); const [isDrawing, setIsDrawing] useState(false); const [context, setContext] useStateCanvasRenderingContext2D | 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.MouseEventHTMLCanvasElement) { 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.MouseEventHTMLCanvasElement) { 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就会触发一次数据库插入和一次网络请求。如果画得很快每秒会产生数十甚至上百个请求这对数据库和网络都是巨大压力而且会导致笔画在别人屏幕上更新得断断续续。优化方案批处理与节流。前端节流我不再在每次mousemove时都发送请求而是将一段时间内的笔画点收集到一个数组里。使用requestAnimationFrame或setTimeout进行节流比如每100毫秒将收集到的点作为一个“笔画段”发送出去。这大大减少了请求数量。数据结构优化将一条连续的笔画定义为一个由多个点组成的路径path: {x, y}[]而不是无数条短线。这样一次插入就能代表用户一笔画出的整条线数据量更小逻辑也更清晰。后端确认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”的心态去实现它。踩坑、填坑、最终看到作品跑起来的那一刻那种成就感是无可替代的。这或许就是对抗技术焦虑和职业倦怠的最好方式。