在房产信息类项目中,房源信息展示看起来只是把标题、户型、面积、价格填到页面上,真正落地时却会遇到一堆细节:面积到底存建筑面积还是占地面积,月租和总价能不能放在同一个字段里,价格用浮点数会不会出现精度问题,缺失字段应该显示空字符串还是“未提供”,同一套数据如何同时支持列表卡片、详情页和搜索筛选。只要其中一项处理不当,页面可能能打开,但排序、筛选、格式化和多语言会接连出问题。
这篇文章面向正在做房源信息展示模块的前端、后端和全栈开发者,也适合需要维护房产类小程序、后台管理系统或信息展示页的读者。内容会从一条房源标题中拆出可计算字段和展示字段,然后用 TypeScript、Zod 和纯函数模板做一个可运行的最小示例,覆盖数据校验、价格与面积格式化、卡片渲染、异常排查和生产环境注意事项。读完后,你可以把同样的字段建模思路用到自己的房源列表、详情页和数据接口中。
1. 房源信息展示为什么先要从字段建模开始
房源信息展示的第一反应通常是把标题、面积、价格直接塞进一个对象,页面需要什么就取什么。短时间看这样最快,但只要出现筛选、排序、单位切换、多语言或者后台录入校验,字符串字段就会变成负担。比如“3卧2卫”在页面上是展示文案,在筛选条件里却需要拆成 bedrooms 和 bathrooms 两个数值字段;“220平米”在卡片上是一个字符串,在排序和区间筛选中却必须是可比较的数字;“60000/月”和“890万”看起来都是价格,但一个是月租,一个是总价,交易类型完全不同。
字段建模的目的不是把数据结构设计得很复杂,而是把“给人看的文案”和“给程序计算的数据”分开。展示层可以继续输出“3卧2卫”“220 m²”这样的文案,但底层数据应该保留原始数值、单位和交易类型。这样列表页可以按面积排序,详情页可以按语言格式化,后台可以校验价格是否为正数,接口也可以在不破坏前端的情况下增加新字段。
1.1 从一条房源标题拆出展示字段和计算字段
一条典型的房源标题往往同时包含物业类型、楼层形态、户型、建筑面积、占地面积、位置标签、交易方式和价格。以输入材料中的标题结构为例,可以拆出下面这些字段。这里不保留具体地点和具体价格,只保留字段类型和建模方式,实际项目按自己的数据源调整。
| 字段名 | 类型 | 含义 | 是否可计算 | 错误配置的表现 |
|---|---|---|---|---|
id |
string | 房源唯一标识 | 否 | 列表 key 重复,详情页取错数据 |
title |
string | 后台录入的展示标题 | 否 | 直接拼接导致文案重复或不可控 |
propertyType |
enum | 独栋、联排、公寓等 | 否 | 筛选条件无法统一 |
floors |
number | 楼层数 | 是 | 用字符串存“单层”后无法比较 |
bedrooms |
number | 卧室数量 | 是 | 用“3卧”字符串导致筛选失效 |
bathrooms |
number | 卫生间数量 | 是 | 小数卫生间无法录入 |
indoorAreaSqm |
number | 建筑面积,平方米 | 是 | 与占地面积混用,排序错误 |
landAreaSqm |
number | 占地面积,平方米 | 是 | 缺失时被当成 0 |
transactionType |
enum | rent 或 sale |
否 | 月租和总价混在一起 |
priceAmount |
number | 价格数值 | 是 | 用浮点数导致精度问题 |
currency |
string | 币种代码 | 否 | 货币符号错误 |
priceUnit |
enum | month 或 total |
否 | 月租显示成总价 |
locationLabel |
string | 模糊位置文案 | 否 | 暴露精确门牌号 |
tags |
string[] | 标签 | 否 | 标签重复或为空 |
description |
string | 描述文本 | 否 | 直接插入 HTML 导致 XSS |
images |
object[] | 图片地址和 alt | 否 | 缺少 alt,图片加载失败无占位 |
visibility |
enum | 公开、审核中、下架 | 否 | 下架房源仍出现在列表 |
这张表里最容易被忽略的是 indoorAreaSqm 和 landAreaSqm 的区分。独栋房源可能同时有建筑面积和占地面积,页面如果只写“220平米”,用户无法判断这是室内面积还是土地面积。数据结构里必须保留两个字段,展示层再根据场景决定显示哪一个。另一个容易忽略的是 priceUnit。月租和总价不是同一个维度的价格,不能只靠 priceAmount 大小来猜。把交易类型和价格单位显式建模,后续筛选、排序和文案生成才有稳定依据。
1.2 面积、价格、卧室数为什么不能都当字符串
字符串字段在展示时很方便,但在计算时几乎一定会出问题。面积用字符串存,排序时会按字典序比较,“100”会排在“20”后面;价格用字符串存,筛选“100 万以上”时需要反复解析,解析失败还会产生 NaN;卧室数用“3卧”存,后台想筛选“3 卧及以上”只能做模糊匹配,匹配结果不可控。
价格还有一个更隐蔽的问题:浮点数。0.1 + 0.2 在 JavaScript 中不等于 0.3,金额计算使用浮点数会出现舍入误差。常见做法是用最小货币单位整数存储,例如分、铢的百分之一,或者使用十进制字符串配合精确计算库。示例中采用 priceMinor 整数加 currency 的方式,并在格式化时按币种小数位转换。真实项目还要维护一张币种小数位表,因为不同货币的最小单位并不都是两位小数。
面积也不建议用浮点数随意存储。如果业务只需要平方米整数,直接用整数;如果需要半平方米精度,可以存平方分米或平方厘米的整数。这样做的原因是避免 0.1 这类小数在多次转换后产生误差,同时也让数据库字段和接口字段的语义更明确。下面的反例展示了常见的错误写法:
json复制{
"title": "独栋单层 3卧2卫 220平米",
"area": "220平米",
"price": "60000/月",
"bedrooms": "3卧",
"bathrooms": "2卫"
}
这段 JSON 能直接渲染,但无法可靠排序、筛选和校验。更稳妥的结构是:
json复制{
"id": "listing_001",
"propertyType": "house",
"floors": 1,
"bedrooms": 3,
"bathrooms": 2,
"indoorAreaSqm": 220,
"landAreaSqm": 620,
"transactionType": "rent",
"priceMinor": 12000000,
"currency": "CNY",
"priceUnit": "month",
"locationLabel": "示例区域",
"visibility": "public"
}
这里的数值仅用于说明字段结构,不代表任何真实房源报价。关键点在于:页面需要的“3卧2卫”“220 m²”“每月价格”由格式化函数生成,而不是直接存成不可计算的字符串。
1.3 一条房源数据从录入到展示要经过哪些检查点
房源信息展示的最小可运行流程可以拆成五步:读取原始数据、运行时校验、单位与格式转换、模板渲染、结果验证。读取阶段只负责拿到 JSON 或接口响应;校验阶段检查必填字段、类型、枚举和业务约束;格式化阶段把数值转成用户可读文案;渲染阶段只消费已经校验过的数据;验证阶段检查正常输入和异常输入是否都能给出明确结果。
这个顺序很重要。如果先渲染再校验,错误数据会直接进入 HTML,排查时很难判断是接口问题、字段问题还是模板问题。如果格式化和渲染混在一起,价格单位、货币符号和缺失字段的处理会散落在多个模板里,后续修改容易漏改。把校验和格式化做成独立函数,可以让列表页、详情页和后台预览复用同一套规则。
注意:不要只验证页面能不能打开,还要验证价格单位、面积缺失、交易类型不匹配和非法枚举值这些异常分支。正常数据只能证明主流程可用,异常数据才决定上线后是否容易排查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 准备一个可运行的最小项目环境
下面用一个 Node.js + TypeScript 的最小项目演示房源数据校验、格式化和卡片渲染。选择这个组合的原因是依赖少、运行快,代码可以直接在本地执行,也方便把核心函数迁移到前端框架或后端服务中。实际项目可能使用 React、Vue、Java 或 Go,但字段建模、校验顺序和格式化思路是相通的。
2.1 技术栈、版本确认和目录结构
建议使用 Node.js 18 或更高版本,TypeScript 5.x,Zod 3.x。Zod 用来做运行时校验,因为 TypeScript 的类型检查只在编译期生效,接口返回的数据仍然需要在运行时验证。tsx 用来直接运行 TypeScript 文件,避免额外配置编译产物。版本不是绝对要求,落地前要先确认团队现有依赖版本,避免同一项目中出现多个校验库或重复的类型定义。
| 工具 | 建议版本 | 用途 | 检查方式 |
|---|---|---|---|
| Node.js | 18+ | 运行 TypeScript 示例 | node -v |
| TypeScript | 5.x | 类型检查 | npx tsc -v |
| Zod | 3.x | 运行时数据校验 | npm ls zod |
| tsx | 最新稳定版 | 直接运行 .ts 文件 |
npx tsx -v |
| Vitest | 可选 | 单元测试 | npx vitest -v |
项目目录可以保持简单:
text复制listing-demo/
├── package.json
├── tsconfig.json
├── data/
│ └── fixture.json
└── src/
├── schema.ts
├── format.ts
├── render.ts
└── main.ts
data/fixture.json 放示例数据,schema.ts 放校验规则,format.ts 放价格和面积格式化,render.ts 放 HTML 模板,main.ts 负责串联并输出结果。这个拆分不是为了让文件数量变多,而是让每一层只做一件事。排查时可以先看输入数据,再看校验结果,再看格式化输出,最后看 HTML 片段,定位范围会小很多。
2.2 依赖安装和 TypeScript 配置
先初始化项目并安装依赖:
bash复制mkdir listing-demo
cd listing-demo
npm init -y
npm install zod
npm install -D typescript tsx
npx tsc --init
package.json 中补充运行脚本,方便后续验证:
json复制{
"name": "listing-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.7.0"
}
}
tsconfig.json 至少保证模块解析和严格模式可用:
json复制{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src"]
}
这里的 strict 建议打开。房源字段里有大量可选值,严格模式会强迫开发者在读取 landAreaSqm 或 description 时处理 undefined,减少“页面上出现 undefined”的问题。resolveJsonModule 允许直接导入示例 JSON,方便本地验证。生产项目如果使用打包工具,moduleResolution 要按实际工具链调整,不要直接照搬。
2.3 示例数据和校验边界要先约定清楚
示例数据放在 data/fixture.json,只保留抽象字段:
json复制{
"id": "listing_001",
"propertyType": "house",
"floors": 1,
"bedrooms": 3,
"bathrooms": 2,
"indoorAreaSqm": 220,
"landAreaSqm": 620,
"transactionType": "rent",
"priceMinor": 12000000,
"currency": "CNY",
"priceUnit": "month",
"locationLabel": "示例区域",
"tags": ["独栋", "单层"],
"description": "用于演示字段校验和渲染的示例数据。",
"images": [
{
"url": "https://example.com/cover.jpg",
"alt": "房源外观示例图"
}
],
"visibility": "public"
}
这份数据里,priceMinor 是整数,currency 是三位币种代码,transactionType 和 priceUnit 分开存储。landAreaSqm 允许缺失,因为不是所有房源都有独立占地数据。description 和 images 也可能为空。校验规则要明确哪些字段必填、哪些可选、哪些字段之间存在联动关系。比如交易类型是 rent 时,价格单位应该是 month;交易类型是 sale 时,价格单位应该是 total。这种联动关系只靠 TypeScript 类型表达不了,需要在运行时校验中处理。
注意:示例中的金额、面积和图片地址只用于演示程序结构,不代表真实房源信息。实际项目要使用自己的数据源,并确认字段单位和币种含义。
3. 实现房源数据校验与格式化
数据进入渲染层之前,先经过一次运行时校验,可以把很多页面异常挡在前面。校验不只是检查“有没有值”,还要检查类型、范围、枚举和字段之间的业务关系。格式化则负责把数值转成用户能读懂的文案。两者分开后,列表页和详情页可以共享同一套规则,不会出现列表显示“每月”,详情页显示“总价”的情况。
3.1 用 Zod 定义运行时校验规则
src/schema.ts 中定义房源结构和业务约束:
typescript复制import { z } from "zod";
export const ListingSchema = z
.object({
id: z.string().min(1),
propertyType: z.enum(["house", "townhouse", "apartment"]),
floors: z.number().int().positive(),
bedrooms: z.number().int().nonnegative(),
bathrooms: z.number().positive().multipleOf(0.5),
indoorAreaSqm: z.number().positive(),
landAreaSqm: z.number().positive().optional(),
transactionType: z.enum(["rent", "sale"]),
priceMinor: z.number().int().positive(),
currency: z.string().length(3),
priceUnit: z.enum(["month", "total"]),
locationLabel: z.string().min(1),
tags: z.array(z.string()).default([]),
description: z.string().optional(),
images: z
.array(
z.object({
url: z.string().url(),
alt: z.string().min(1)
})
)
.default([]),
visibility: z.enum(["public", "reviewing", "offline"])
})
.superRefine((value, ctx) => {
if (value.transactionType === "rent" && value.priceUnit !== "month") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["priceUnit"],
message: "租赁房源的价格单位必须是 month"
});
}
if (value.transactionType === "sale" && value.priceUnit !== "total") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["priceUnit"],
message: "出售房源的价格单位必须是 total"
});
}
if (
value.landAreaSqm !== undefined &&
value.landAreaSqm < value.indoorAreaSqm
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["landAreaSqm"],
message: "占地面积不应小于建筑面积,请确认字段单位"
});
}
});
export type Listing = z.infer<typeof ListingSchema>;
这段代码有几处关键点。priceMinor 使用整数,避免金额浮点误差;bathrooms 使用 multipleOf(0.5),兼容半个卫生间;currency 只校验长度为 3,没有写死币种列表,便于扩展;superRefine 处理交易类型和价格单位的联动关系。最后一个校验是占地面积不得小于建筑面积,这不是绝对业务规则,但能帮助发现“把平方米和平方英尺混用”这类常见错误。实际项目中,如果存在建筑面积大于占地面积的特殊场景,要按业务定义调整或删除这条规则。
3.2 价格和面积格式化要集中在一处
src/format.ts 负责输出文案:
typescript复制import type { Listing } from "./schema.js";
export function formatPrice(listing: Listing, locale = "zh-CN"): string {
const amount = listing.priceMinor / 100;
const formatted = new Intl.NumberFormat(locale, {
style: "currency",
currency: listing.currency,
maximumFractionDigits: 2
}).format(amount);
return listing.priceUnit === "month" ? `${formatted}/月` : formatted;
}
export function formatArea(areaSqm: number | undefined, locale = "zh-CN"): string {
if (areaSqm === undefined) {
return "面积未提供";
}
const number = new Intl.NumberFormat(locale, {
maximumFractionDigits: 1
}).format(areaSqm);
return `${number} m²`;
}
export function buildDisplayTitle(listing: Listing): string {
const parts = [
listing.propertyType === "house" ? "独栋" : "",
listing.floors === 1 ? "单层" : `${listing.floors}层`,
`${listing.bedrooms}卧${listing.bathrooms}卫`,
formatArea(listing.indoorAreaSqm)
].filter(Boolean);
return parts.join(" ");
}
formatPrice 中默认按两位小数处理,这是示例简化。真实项目要维护币种最小单位表,不能假设所有币种都是两位小数。formatArea 对 undefined 做了明确降级,返回“面积未提供”,而不是让模板输出 undefined m²。buildDisplayTitle 用数组过滤空值再拼接,避免出现连续空格或开头空格。标题拼接只用于展示,不替代原始标题字段;如果后台已经录入完整标题,应优先使用后台标题,并把自动拼接作为兜底。
3.3 缺失字段和多语言文案要有统一降级策略
房源数据经常缺字段。占地面积可能没有,描述可能为空,图片可能还没上传。模板里直接写 listing.landAreaSqm 会在页面上出现 undefined,直接写 listing.description 可能渲染出空白区域。更稳妥的方式是给每个可选字段定义降级文案,并在格式化层统一处理。多语言项目可以把降级文案放进字典,而不是散落在模板字符串里。
typescript复制const messages = {
"zh-CN": {
areaMissing: "面积未提供",
descriptionMissing: "暂无描述",
imageMissing: "暂无图片"
},
"en-US": {
areaMissing: "Area not provided",
descriptionMissing: "No description",
imageMissing: "No image"
}
} as const;
export function getMessage(
locale: keyof typeof messages,
key: keyof (typeof messages)["zh-CN"]
): string {
return messages[locale][key] ?? messages["zh-CN"][key];
}
这段字典只演示结构。实际项目可以使用 i18n 库,但要保证同一个字段在不同页面使用同一套 key。否则列表页写“暂无描述”,详情页写“没有描述”,用户会感觉信息不一致,排查文案问题时也要到处搜索。另一个细节是文本转义:如果描述字段允许用户输入,渲染到 HTML 前必须转义,不能直接把字符串拼进模板。
4. 渲染房源卡片与详情页
渲染层应该只消费已经校验和格式化过的数据。卡片负责摘要信息,详情页负责完整字段分组,两者共用格式化函数,但不要共用过度复杂的模板。下面用纯函数返回 HTML 字符串,目的是让示例可以直接在 Node.js 中运行。实际项目可以替换成 React 组件、Vue 模板或服务端模板,字段分组和降级策略保持一致即可。
4.1 卡片组件只输出摘要字段
src/render.ts 中先定义转义函数和卡片渲染函数:
typescript复制import type { Listing } from "./schema.js";
import { buildDisplayTitle, formatArea, formatPrice } from "./format.js";
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export function renderListingCard(listing: Listing): string {
const title = escapeHtml(buildDisplayTitle(listing));
const location = escapeHtml(listing.locationLabel);
const price = escapeHtml(formatPrice(listing));
const indoorArea = escapeHtml(formatArea(listing.indoorAreaSqm));
const landArea = escapeHtml(formatArea(listing.landAreaSqm));
return `
<article class="listing-card" data-id="${escapeHtml(listing.id)}">
<h3>${title}</h3>
<p class="location">${location}</p>
<dl>
<div><dt>建筑面积</dt><dd>${indoorArea}</dd></div>
<div><dt>占地面积</dt><dd>${landArea}</dd></div>
<div><dt>价格</dt><dd>${price}</dd></div>
</dl>
</article>
`.trim();
}
卡片只输出标题、位置、面积和价格,不输出完整描述和全部图片。这样列表加载时不需要把大段文本和图片数组全部塞进首屏。data-id 用于前端事件绑定,但也要转义,避免特殊字符破坏属性。escapeHtml 是最基础的防护,生产环境建议使用成熟模板引擎的自动转义能力,不要在每个模板里手写转义。如果描述字段允许富文本,还要经过白名单过滤,不能只做 HTML 实体转义。
4.2 详情页按信息类型分组,而不是按数据库字段顺序
详情页字段多,如果按数据库字段顺序排列,用户会看到一串零散信息。更合理的分组是:基本信息、面积信息、交易信息、位置与配套、描述与图片。下面的映射表可以作为字段分组参考:
| 分组 | 包含字段 | 展示方式 | 注意事项 |
|---|---|---|---|
| 基本信息 | propertyType、floors、bedrooms、bathrooms |
标签或短文本 | 枚举值要映射成用户可读文案 |
| 面积信息 | indoorAreaSqm、landAreaSqm |
数值加单位 | 缺失时显示降级文案,不要显示 0 |
| 交易信息 | transactionType、priceMinor、currency、priceUnit |
格式化价格 | 月租和总价不能混用 |
| 位置信息 | locationLabel |
模糊位置文案 | 不要展示精确门牌号和业主联系方式 |
| 描述与图片 | description、images |
段落、图片列表 | 文本转义,图片提供 alt 和占位 |
| 状态字段 | visibility |
后台或内部页面 | 公开页面只展示 public 数据 |
分组之后,详情页模板可以只遍历当前分组,不需要关心字段来源。后台新增字段时,先判断它属于哪个分组,再决定是否需要公开。隐私字段尤其要单独控制:业主电话、精确坐标、门牌号、内部备注不应该进入公开接口。即使前端不渲染,也不要把这些字段塞进公开响应里,否则查看网络请求就能看到。
4.3 图片、地图和外链要处理失败场景
图片列表不能假设每张图都能加载成功。模板中至少要有 alt,前端还要有占位图和加载失败处理。下面的详情页片段演示了图片和描述的基本结构:
typescript复制export function renderListingDetail(listing: Listing): string {
const title = escapeHtml(buildDisplayTitle(listing));
const description = listing.description
? `<p>${escapeHtml(listing.description)}</p>`
: "<p>暂无描述</p>";
const images = listing.images.length
? listing.images
.map(
(image) =>
`<img src="${escapeHtml(image.url)}" alt="${escapeHtml(
image.alt
)}" loading="lazy" />`
)
.join("")
: "<p>暂无图片</p>";
return `
<section class="listing-detail">
<h2>${title}</h2>
<p>${escapeHtml(formatPrice(listing))}</p>
<p>建筑面积:${escapeHtml(formatArea(listing.indoorAreaSqm))}</p>
<p>占地面积:${escapeHtml(formatArea(listing.landAreaSqm))}</p>
${description}
<div class="gallery">${images}</div>
</section>
`.trim();
}
loading="lazy" 可以减少首屏图片请求,但首屏主图通常不建议懒加载。图片地址如果是外部链接,还要考虑域名白名单、防盗链和加载失败占位。地图组件只展示模糊位置,不要直接使用精确经纬度。外链要加 rel="noopener noreferrer",避免新窗口打开时的安全问题。这些细节看起来和字段建模无关,但它们决定了房源信息展示是否稳定、是否泄露不该公开的信息。
注意:渲染层不要直接读取原始接口对象。先校验、再格式化、最后渲染,可以让错误更早暴露,也能避免模板里出现大量
undefined判断。
5. 运行验证与常见问题排查
示例写完后,要验证正常数据、边界数据和异常数据。正常数据证明主流程可用,边界数据检查缺失字段和单位转换,异常数据检查校验规则是否能给出明确错误。排查时不要一上来就改模板,先确认输入数据、字段类型、单位和交易类型,再检查格式化和渲染。
5.1 正常输出和异常输出分别是什么样
src/main.ts 负责读取数据、校验、格式化和渲染:
typescript复制import fs from "node:fs";
import { ListingSchema } from "./schema.js";
import { renderListingCard } from "./render.js";
const raw = JSON.parse(
fs.readFileSync(new URL("../data/fixture.json", import.meta.url), "utf8")
);
const result = ListingSchema.safeParse(raw);
if (!result.success) {
console.error("房源数据校验失败:");
console.error(result.error.flatten());
process.exit(1);
}
console.log(renderListingCard(result.data));
运行命令:
bash复制npm run dev
正常情况下会输出一段 HTML 卡片,包含标题、位置、建筑面积、占地面积和格式化后的价格。异常情况下,例如把 transactionType 改成 rent,同时把 priceUnit 改成 total,会看到类似下面的错误结构:
json复制{
"formErrors": [],
"fieldErrors": {
"priceUnit": ["租赁房源的价格单位必须是 month"]
}
}
这类错误比页面上的 NaN 更容易定位。再比如把 priceMinor 改成负数,会得到 priceMinor 字段的数值范围错误;把 currency 改成两位字符,会得到长度错误;把 landAreaSqm 改得比 indoorAreaSqm 小,会得到单位确认错误。每个错误都应该能对应到具体字段和具体规则,而不是只返回“数据不合法”。
5.2 常见问题要按现象、原因、检查、处理四步排查
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
价格显示 NaN |
把字符串直接参与除法,或字段名写错 | 打印原始数据,检查 priceMinor 类型 |
先用 Zod 校验为整数,再格式化 |
| 面积排序结果不对 | 面积存成字符串,按字典序排序 | 查看接口返回类型和排序字段 | 改为数值字段,单位统一为平方米 |
| 月租显示成总价 | transactionType 和 priceUnit 没有联动校验 |
检查两个枚举值是否匹配 | 在 superRefine 中增加组合校验 |
页面上出现 undefined |
可选字段没有降级文案 | 搜索模板中的可选字段读取 | 在格式化层统一返回“未提供” |
| 货币符号错误 | 只根据语言猜币种,或币种代码写错 | 检查 currency 和 locale |
币种单独存字段,格式化时显式传入 |
| 标题重复或空格异常 | 直接拼接多个可能为空的字符串 | 检查标题拼接函数的过滤逻辑 | 用数组过滤空值后 join |
| 描述中的 HTML 被执行 | 直接把用户输入插入模板 | 检查是否调用转义函数 | 文本转义,富文本走白名单过滤 |
| 下架房源仍出现在列表 | 查询条件漏掉 visibility |
检查接口查询和缓存 key | 公开接口只返回 public 数据 |
这张表里的问题大多不是框架 bug,而是字段语义没有统一。排查时建议按顺序检查:输入数据是否正确、字段类型是否符合预期、单位是否统一、交易类型和价格单位是否匹配、格式化函数是否被正确调用、模板是否做了转义、缓存是否返回了旧数据。这个顺序从数据源头到展示结果,能避免在模板层反复试错。
5.3 用类型检查和最小测试固定住关键规则
至少保留两个验证命令:
bash复制npm run typecheck
npm run dev
typecheck 检查编译期类型问题,dev 检查运行时校验和渲染结果。如果项目引入 Vitest,可以给格式化函数补充几个最小测试:价格为整数时输出是否正确、面积为 undefined 时是否返回降级文案、租赁房源价格单位错误时是否抛出校验错误。测试不需要覆盖所有 UI 细节,但要覆盖价格、面积、交易类型和缺失字段这四类高风险逻辑。
typescript复制import { describe, expect, it } from "vitest";
import { ListingSchema } from "./schema.js";
import { formatArea, formatPrice } from "./format.js";
describe("listing formatting", () => {
it("formats missing area with fallback text", () => {
expect(formatArea(undefined)).toBe("面积未提供");
});
it("rejects rent listing with total price unit", () => {
const result = ListingSchema.safeParse({
id: "x",
propertyType: "house",
floors: 1,
bedrooms: 3,
bathrooms: 2,
indoorAreaSqm: 220,
transactionType: "rent",
priceMinor: 100,
currency: "CNY",
priceUnit: "total",
locationLabel: "示例区域",
visibility: "public"
});
expect(result.success).toBe(false);
});
});
测试中的数值只用于验证逻辑。实际项目要把测试数据放在独立 fixture 中,避免和示例数据混在一起。测试断言也不要只写 toBeTruthy(),要写清楚期望的具体文案或具体错误字段,这样失败时才有排查价值。
6. 生产环境的最佳实践与扩展方向
学习环境中跑通一个卡片渲染不代表可以上线。生产环境还要考虑配置外置化、数据版本、缓存、权限、日志、图片资源和多语言。房源信息展示往往不是孤立页面,它会和搜索、筛选、详情、后台审核、图片存储、地图服务一起工作,任何一个环节的字段定义不一致,都会在页面上放大成用户可见的问题。
6.1 配置外置化、数据版本和迁移策略
币种、语言、面积单位、价格单位、字段开关都不应该散落在代码里。建议把这些配置放到环境配置或数据库中,并在接口响应中带上 schemaVersion。当字段结构变化时,可以先兼容旧版本,再逐步迁移。下面的对比表可以帮助区分学习环境和生产环境的关注点:
| 关注点 | 学习环境 | 生产环境 |
|---|---|---|
| 数据来源 | 本地 JSON | 数据库、接口、缓存 |
| 校验 | 单个示例即可 | 接口入参、后台录入、批量导入都要校验 |
| 币种 | 固定一个币种 | 多币种配置,按币种小数位格式化 |
| 面积单位 | 统一平方米 | 明确单位来源,避免平方米和平方英尺混用 |
| 缺失字段 | 控制台能看到即可 | 统一降级文案,记录缺失率 |
| 图片 | 本地占位图 | 对象存储、尺寸裁剪、加载失败占位 |
| 权限 | 不区分 | 公开、审核中、下架、内部字段隔离 |
| 日志 | console.log |
结构化日志,记录房源 id 和校验错误字段 |
| 回滚 | 手动改文件 | 配置版本化,接口和模板可回滚 |
数据版本不要只在数据库里加字段,还要考虑前端缓存和 CDN 缓存。房源详情页如果被缓存,后台修改价格后用户可能仍看到旧价格。可以在缓存 key 中加入更新时间或版本号,并在后台修改后主动刷新。价格和状态这类敏感字段,建议不要做过长缓存。
6.2 性能、可访问性和缓存要一起考虑
列表页常见性能问题是图片过大和字段过多。卡片只返回摘要字段,图片使用合适尺寸,首屏主图不要懒加载,非首屏图片再懒加载。格式化函数可以复用 Intl.NumberFormat 实例,避免在长列表中反复创建。筛选和排序尽量在数据库或接口层完成,不要把所有房源拉到前端再排序。
可访问性方面,图片要有 alt,卡片标题使用合适的标题层级,价格和面积用语义化标签。颜色不能作为唯一状态提示,下架或审核中要有文本说明。移动端要注意长标题换行和价格宽度,避免布局被长文本撑破。
注意:生产环境的公开接口只返回公开字段。精确坐标、业主联系方式、内部备注即使前端不渲染,也不应该出现在响应里。
6.3 一份可复用的房源信息展示检查清单
上线前可以按下面的清单逐项检查:
- 数据字段:
id、户型、面积、交易类型、价格、币种、价格单位、位置、状态是否齐全。 - 单位检查:建筑面积和占地面积是否使用同一单位,价格是否使用最小货币单位整数。
- 联动检查:租赁房源的价格单位是否为月,出售房源的价格单位是否为总价。
- 缺失检查:可选字段是否有降级文案,页面是否还会出现
undefined。 - 格式化检查:价格、面积、日期是否按当前语言输出,货币符号是否正确。
- 安全检查:描述、标题、位置、图片 alt 是否转义,富文本是否走白名单。
- 隐私检查:公开接口是否排除了业主联系方式、精确坐标和内部备注。
- 状态检查:下架、审核中的房源是否被列表和详情接口过滤。
- 图片检查:是否有尺寸限制、加载失败占位和
alt。 - 缓存检查:价格和状态变更后缓存是否能及时失效。
- 日志检查:校验失败时是否记录房源 id、错误字段和错误原因。
- 回滚检查:配置、模板和接口版本是否可以独立回滚。
这套检查清单可以直接放进发布流程。房源信息展示的技术难点通常不在页面本身,而在字段语义、单位、精度、隐私和异常分支。把字段建模、运行时校验、格式化、渲染和排查链路固定下来,后续增加筛选、地图、多语言或后台审核时,改动范围会小很多。下一步可以尝试给示例增加一个按面积和价格排序的列表接口,再补一组缺失字段和非法交易类型的测试,用实际数据验证这套结构是否足够稳定。
