上一篇文章我们简单过了一下Next的体验,但是没有发挥出其特点,依旧停留在Web库的范围内。这一篇会继续实践Next的生态,挖掘它的独特魅力。
这篇文章我会做一个文件上传的全栈应用,并完成部署上传。
应用分析和工具链
工具链我采用尽可能轮椅、兼容性更好的方式,心智负担更低,尽可能”丝滑”。
执行安装命令
npx create-next-app@latest file-converter
创建完毕之后,tailwindcss等已经自动集成,不需要额外安装,将对应的tailwind.config.js更改就行,具体参照之前文章的代码 Next初体验,做一个轮盘小游戏 。
这里推荐一个校验工具,是一位烘焙高手推荐给我的,叫 biome,文档参照如下biome官网。
biome解决了什么问题?
我在之前的前端工程化文章里提到过,前端由于其特性导致各玩各的,即便是eslint这样的大佬工具(开发者同时也是前端红宝书的作者),都没办法满足大多数需求,检查指标严格了,开发体验极速下降,像在坐牢,检查松了,还不如不上,我见到大多数的项目都是属于妥协性配置,没有起到工程化“副驾驶”的作用。前端工程化其一:小公司有必要实施吗?
biome是属于较好平衡这一点的工具,既没有让开发者束手束脚,也能够起到提示监督作用。
首先vscode的插件安装biome,如下:
在项目根目录下创建 biome.json文件,如下

{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
再将vscode中的设置调整,使得默认的格式化、校验工具以biome为准,在.vscode下创建extensions.json、settings.json,内容如下
/** extensions.json **/
{
"recommendations": ["biomejs.biome"]
}
/**settings.json**/
{
"editor.defaultFormatter": "biomejs.biome",
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.autoIndent": "none"
}
}
我们配置好了typescript、javascript、json等格式化,以biome为准,yaml的缩进则是自己配置,一个tab = 两个空格。
为什么说它是恰到好处,可以举个例子:

这个错误提示是没有子元素的标签应该使用自闭合标签,这样可以告诉React渲染层,该DOM节点没有子元素,减少虚拟DOM的比较层级。
这一点可能还不够硬核,再来看两个例子:

提示我们使用for…of 代替foreach,for of的操作天然的支持异步await语法糖、性能更优、可读性更好;foreach则是访问数组函数,具有隐含的回调。具体细节可以参照MDN或者查询AI MDN for of 异步语法

乍一看好像没有漏什么,实际上svg标签需要补充 aria-label字段,作用同 img 标签的 alt 即便是一个空的 alt都行。
这是因为:在资源加载不出来的情况下补足一个提示、能够在屏幕阅读器上为视障读者描述内容,如果不补足这个字段,谷歌会在SEO上给予惩罚。
在该重要的点告警、无关紧要的点默默支持,这是我理想中的工具链支持。
应用分析过程
我想做的是一个文件格式转换的应用,后续再集成例如pdf翻译、pdf裁切等功能。
我采用的是app router的方式,src/app下的文件自动会读入到路由内,会按照Next的规则组织路由。
结构如下:

api下的路径会自动编译成路由,注意,这里的Route具体是指后端的路由,大多数的后端框架都有路由工具,并且具有网络层和控制层;
在Next下,Route的职能相当于控制层和网络层二合一。lib 的目录下,我放置了服务层services,具体用于业务逻辑。components则是前端组件,views下作为前端页面渲染。
编码过程
思路和工具已经简单介绍了,具体的实现逻辑,我调一些重点来讲,首先就是组件、上传的实现。
"use client"
import { dropzoneStyles } from "@/app/styles/global";
import UploadIcon from "./icon";
/**tsx上传组件**/
export interface DropzoneProps {
id: string;
onFileChange: (files: FileList | null) => void;
accept?: string;
multiple?: boolean;
className?:string;
disabled?:boolean
}
const ConverterUpload: React.FC<DropzoneProps> = ({
id,
onFileChange,
accept = "*/*",
multiple = false,
className = "",
disabled = false
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onFileChange(e.target.files);
};
return (
<div className={`${dropzoneStyles.container} ${className}`}>
<input
id={id}
name={`${id}-input`}
type="file"
className={dropzoneStyles.input}
onChange={handleChange}
accept={accept}
multiple={multiple}
/>
<label htmlFor={id} className={dropzoneStyles.label}>
<span className={dropzoneStyles.iconContainer}>
<UploadIcon />
</span>
<span className={dropzoneStyles.text}>
Drag & drop or <span className={dropzoneStyles.highlightText}>upload a file</span>
</span>
</label>
</div>
);
};
export default ConverterUpload;
上传组件是TSX做的,定义一个基本类型,支持拖拽的方式,设置一些常规的参数,由于tailwindcss的样式写起来太长了,我把它封装成一个ts类型来导入使用。
/*** 文件上传并转换 */
import { useRef, useState } from "react";
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
type UploadResponse<T = any> = {
data: T;
status: number;
};
type UploadOptions = {
url: string;
filedName?: string; // 表单字段名,默认files
headers?: Record<string, string>;
onProgress?: (progress: number) => void;
};
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
export function useFileUpload<T = any>() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [progress, setProgress] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const upload = async (
files: FileList | File[],
options: UploadOptions,
): Promise<UploadResponse<T>> => {
abortControllerRef.current = new AbortController();
setIsLoading(true);
setError(null);
setProgress(0);
try {
const formData = new FormData();
const filesArray = Array.isArray(files) ? files : Array.from(files);
for (const file of filesArray) {
formData.append(options.filedName || "files", file);
}
const response = await fetch(options.url, {
method: "POST",
body: formData,
signal: abortControllerRef.current.signal,
headers: options.headers,
});
// 处理响应
if (!response.ok) {
throw new Error(`http error! status:${response.status}`);
}
const data = (await response.json()) as T;
return {
data,
status: response.status,
};
} catch (err) {
const error = err instanceof Error ? err : new Error("Upload failed");
setError(error);
throw error;
} finally {
abortControllerRef.current = null;
setIsLoading(false);
}
};
// 进度条上传
const uploadWithProgress = async (
files: FileList | File[],
options: UploadOptions,
): Promise<UploadResponse<T>> => {
setIsLoading(true);
setError(null);
setProgress(0);
return new Promise<UploadResponse<T>>((resolve, reject) => {
const formData = new FormData();
const filesArray = Array.isArray(files) ? files : Array.from(files);
for (const file of filesArray) {
formData.append(options.filedName || "files", file);
}
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.open("POST", options.url);
// 设置自定义头部
if (options.headers) {
for (const [key,value] of Object.entries(options.headers)) {
xhr.setRequestHeader(key, value);
}
}
// 进度事件
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
setProgress(percent);
options.onProgress?.(percent);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve({
data: JSON.parse(xhr.responseText) as T,
status: xhr.status,
});
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Network error"));
xhr.onabort = () => reject(new Error("Upload cancelled"));
xhr.send(formData);
}).finally(() => {
setIsLoading(false);
xhrRef.current = null;
});
};
// 取消上传
const abort = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setError(new Error("upload cancelled by user"));
}
};
return { upload,uploadWithProgress, abort, isLoading, progress, error };
}
上传的内容也是常规的代码,包含上传、带进度的上传,对外暴露中断信息、加载信息、进度、和错误监控。
这里biome不建议使用Any类型,我为了图方便这里没有详细定义类型,采用了忽略规则。
fetch原生不支持进度条监控,fetch基于promise来做,并且所需存储极小,我现在基本都用fetch来做网络请求了。
接下来做服务端的部分。
/**app/api/converter/route.ts**/
import { ImageConverterService } from "@/lib/services/image-converter.service";
import { NextResponse } from "next/server";
import z from "zod";
const schema = z.object({
file: z
.instanceof(File)
.refine((file) => file.type === "image/webp", { message: "仅支持webp格式" })
.refine((file) => file.size < 5 * 1024 * 1024, {
message: "文件大小不能超过5MB",
}),
});
const converter = new ImageConverterService();
export async function POST(req: Request) {
try {
const formData = await req.formData();
const { file } = schema.parse(Object.fromEntries(formData));
// 转换buffer
const buffer = Buffer.from(await file.arrayBuffer());
// 转换服务
const convertedUrl = await converter.webpToJpeg(buffer);
return NextResponse.json({
original: file.name,
convertedUrl,
});
} catch (err) {
return NextResponse.json({ error: "Invalid file format" }, { status: 400 });
}
}
最简化的服务就是这么简单。zod是Node轻量序列化工具,用这个感觉像PHP序列化方式类似(直观层面来说)。
CRUD/RESTful风格的api只需要写对应的函数就行,这里将文件参数序列化并返回新的文件。
接下来编写services的实现:
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import sharp from "sharp";
import path from "node:path";
export class ImageConverterService {
private readonly outputDir = path.join(process.cwd(), "public/converted");
constructor() {
// 确保输出目录存在
fs.mkdir(this.outputDir, { recursive: true });
}
/**
* 将webp转换为jpg
* @param fileBuffer 上传文件的buffer
* @returns 转换后的文件路径,相当于public
*
*/
async webpToJpeg(fileBuffer: Buffer): Promise<string> {
try {
const outputFilename = `${randomUUID()}.jpg`;
const outputPath = path.join(this.outputDir, outputFilename);
await sharp(fileBuffer).jpeg({ quality: 80 }).toFile(outputPath);
return `/converted/${outputFilename}`;
} catch (err) {
throw new Error(
`转换失败: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* 清理临时文件
*/
async cleanup(fileUrl: string) {
const filename = path.basename(fileUrl);
await fs.unlink(path.join(this.outputDir, filename));
}
}
格式化转换使用了sharp,这是Node最流行的文件处理工具,Node的特点在于高并发和IO上的特性,很适合用来做文件处理。
当然我的服务现在还是有问题的,后续可以增加额外的功能:队列上传文件、定时清理过期文件、异常处理、验证处理等。目前只是具备一个基础的模型,做完一个最小化的全栈项目可能一天都不要。
部署特性
vercel 提供了免费的服务器部署,人称赛博菩萨,首先访问并登陆官网,进行注册。
选择 import project,通过关联Github(事先需要将项目推到Github上),并且可以导入仓库(可全选或者自选指定的仓库),我这里已经导入过了,看下导入之后的页面:

点击下一步

这里可以自己设置编译命令和输出目录,本质上是vercel在背后做了一套CICD的工作,我们可以不必再自己搭一套。

同时也可以设置环境变量和一些密钥。
点击deploy,下一步。如果编译顺利的话,vercel会提供一个子域名,当然后续我们也可以自行解析域名。vercel给我提供的域名 是 converter-file ,读者可自行查看。

全栈思考和总结
我对于软件第一性原则的理解
第一性原则最早是由亚里士多德提出,它强调每个系统都存在一个最基本的原理,是构建知识体系的基础。
埃隆·马斯克推崇第一性原理,认为它是一种从根本原理出发,剔除干扰因素,理性思考问题的方法,可以帮助人们理清复杂的思维,从根源构建。
独立开发者的第一性原理是可行性验证,在明确第一性原理之后,才能辨别各类噪音,生态的完善,能减少在噪音上的精力开销。
明确了可行性验证这个第一性原理之后,就需要管控各类成本,包括学习的成本和时间成本,在这点的基础上,Next是一个比较好入门,甚至完成可行性验证的方案。
Next是站在历史风口上,解决了一些需求,而这个需求是真实的、关乎于时间金钱的需求,因此掩盖了它很多的缺点,使得成为风头正俏的方案。
后续需要补足的短板
这个项目后续的更新计划:
- i18n配置,以英语区为主
- 夜间模式的样式还需要整改
- 完善服务端的校验和异常处理
- 好点的域名
- CDN加速
最后贴上项目的地址,感兴趣的读者可以down下来玩一下,或者自己做个有意思的项目,感谢观看!