photo of common kingfisher flying above river

Next体验:丝滑的全栈体验

明确了可行性验证这个第一性原理之后,就需要管控各类成本,包括学习的成本和时间成本,在这点的基础上,Next是一个比较好入门,甚至完成可行性验证的方案。

上一篇文章我们简单过了一下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的用法提示

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


 

svg标签

乍一看好像没有漏什么,实际上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&lt;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 提供了免费的服务器部署,人称赛博菩萨,首先访问并登陆官网,进行注册。

vercel官网

选择 import project,通过关联Github(事先需要将项目推到Github上),并且可以导入仓库(可全选或者自选指定的仓库),我这里已经导入过了,看下导入之后的页面:


 

vercel导入

点击下一步

vercel编译设置

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


 

vercel环境变量

同时也可以设置环境变量和一些密钥。

点击deploy,下一步。如果编译顺利的话,vercel会提供一个子域名,当然后续我们也可以自行解析域名。vercel给我提供的域名 是 converter-file ,读者可自行查看。


 

vercel部署成功

全栈思考和总结

我对于软件第一性原则的理解

第一性原则最早是由亚里士多德提出,它强调每个系统都存在一个最基本的原理,是构建知识体系的基础。

埃隆·马斯克推崇第一性原理,认为它是一种从根本原理出发,剔除干扰因素,理性思考问题的方法,可以帮助人们理清复杂的思维,从根源构建。

独立开发者的第一性原理是可行性验证,在明确第一性原理之后,才能辨别各类噪音,生态的完善,能减少在噪音上的精力开销。

明确了可行性验证这个第一性原理之后,就需要管控各类成本,包括学习的成本和时间成本,在这点的基础上,Next是一个比较好入门,甚至完成可行性验证的方案。

Next是站在历史风口上,解决了一些需求,而这个需求是真实的、关乎于时间金钱的需求,因此掩盖了它很多的缺点,使得成为风头正俏的方案。

后续需要补足的短板

这个项目后续的更新计划:

  • i18n配置,以英语区为主
  • 夜间模式的样式还需要整改
  • 完善服务端的校验和异常处理
  • 好点的域名
  • CDN加速

最后贴上项目的地址,感兴趣的读者可以down下来玩一下,或者自己做个有意思的项目,感谢观看!

file-conventer 项目

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注