3、展示层设计:View层的职责边界、模板引擎的选择(JSX vs Template)、无状态组件与有状态组件的划分
展示层,说白了就是用户能看到的那一层。很多新手容易犯一个错误——把所有的逻辑都塞进 View 里。我见过一个项目,一个组件文件写了 800 行,里面混着 API 调用、数据格式化、路由跳转,最后连原作者自己都看不懂了。
那 View 层的职责边界到底在哪?我个人习惯用一句话来定义:View 只负责“怎么画”,不负责“画什么”和“为什么画”。数据从哪来、什么时候更新、用户操作后要做什么——这些都不该是 View 管的。
3.1 View 层的职责边界
先看一个反面教材。我在项目中遇到过这样的代码:
// 反面示例:View 层做了太多事
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// View 层直接发请求
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
// View 层还做了数据转换
const formatted = {
...data,
fullName: `${data.firstName} ${data.lastName}`,
age: calculateAge(data.birthday)
};
setUser(formatted);
setLoading(false);
});
}, [userId]);
// 渲染逻辑
if (loading) return <Spinner />;
return (
<div>
<h1>{user.fullName}</h1>
<p>年龄:{user.age}</p>
</div>
);
}
这个组件的问题在哪?它把数据获取、数据转换、状态管理、UI 渲染全揉在一起了。你想想看,如果换一个页面也要展示用户信息,是不是得再写一遍同样的逻辑?
正确的做法应该是这样:
// 正确示例:View 层只做渲染
function UserProfile({ user, loading }) {
if (loading) return <Spinner />;
return (
<div>
<h1>{user.fullName}</h1>
<p>年龄:{user.age}</p>
</div>
);
}
数据获取和转换交给 Service 层或 Store 层去做。View 层只接收已经处理好的数据,然后画出来。这样测试也方便——你只需要传不同的 props,看它渲染对不对就行。
View 层的核心原则:
- 只接收 props,不直接发请求
- 只做展示逻辑(循环、条件渲染),不做业务逻辑
- 不直接操作 DOM(除非是第三方库的 ref 场景)
- 不存储持久化状态(只存 UI 状态,比如折叠/展开)
3.2 模板引擎的选择:JSX vs Template
说到模板引擎,前端圈一直有两大阵营:JSX 派和 Template 派。我两个都用过,说说我的真实感受。
| 对比维度 | JSX(React) | Template(Vue/Angular) |
|---|---|---|
| 表达能力 | 完整 JS 能力,任意逻辑都能写 | 受限于指令系统,复杂逻辑需用计算属性 |
| 学习曲线 | 需要 JS 基础,上手稍难 | 类似 HTML,新手友好 |
| 类型安全 | 天然支持 TypeScript | 需要额外工具链支持 |
| 可测试性 | 组件即函数,测试简单 | 需要挂载实例,测试稍复杂 |
| 性能优化 | 手动控制渲染(memo、useMemo) | 自动依赖追踪,细粒度更新 |
我个人更倾向于 JSX。为什么?因为 JSX 本质上就是 JS。你想想看,在 JSX 里写条件渲染:
// JSX:直接用 JS 语法
{isLoggedIn ? <Dashboard /> : <Login />}
而在 Template 里:
<!-- Template:需要学指令 -->
<Dashboard v-if="isLoggedIn" />
<Login v-else />
JSX 不需要额外学一套模板语法。你会的 JS 知识,直接就能用在 JSX 里。但 Template 也有它的好处——更干净,更接近 HTML 直觉。Vue 的模板对设计师很友好,他们可以直接看懂。
我的建议:
如果你的团队以 JS 工程师为主,选 JSX。如果团队里有大量 HTML/CSS 背景的成员,Template 可能更合适。没有绝对的好坏,只有合不合适。
3.3 无状态组件与有状态组件的划分
这个划分,说白了就是“这个组件需不需要自己管数据”。
无状态组件(也叫展示组件、纯组件):只依赖 props,没有自己的状态。给它什么数据,它就画什么。就像一个打印机——你塞进一张纸,它给你印出内容。
// 无状态组件:纯展示
function UserCard({ name, avatar, onClick }) {
return (
<div className="card" onClick={onClick}>
<img src={avatar} alt={name} />
<span>{name}</span>
</div>
);
}
有状态组件(也叫容器组件、智能组件):自己管理状态,比如用户输入、展开/折叠、定时器等。
// 有状态组件:管理 UI 状态
function SearchBox({ onSearch }) {
const [keyword, setKeyword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(keyword);
};
return (
<form onSubmit={handleSubmit}>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<button type="submit">搜索</button>
</form>
);
}
那怎么划分?我有个简单的判断标准:问自己一个问题——这个状态如果去掉,组件还能正常工作吗?
比如一个按钮的 loading 状态,去掉它按钮还能点,只是没有反馈。那这个状态就是 UI 状态,可以放在组件里。但如果是用户信息,去掉它页面就空了,那这个状态应该由上层管理。
我曾经踩过的坑:
我曾经把一个表单的校验逻辑全放在 View 层。结果表单有 20 个字段,校验规则复杂,组件文件膨胀到 600 行。后来重构时,我把校验逻辑抽到独立的 hooks 里,View 层只负责绑定值和展示错误信息。代码瞬间清爽了。
记住:View 层只存 UI 状态,不存业务状态。业务状态交给 Store 或 Service 层。
3.4 实战中的划分策略
在实际项目中,我一般这样划分:
- 页面级组件:有状态,负责组合子组件、管理页面级 UI 状态(如 tab 切换、弹窗开关)
- 业务组件:尽量无状态,通过 props 接收数据和回调。如果必须用状态,只存 UI 状态
- 通用组件:纯无状态,比如 Button、Card、Modal 等
举个例子,一个用户列表页面:
// 页面级组件:有状态
function UserListPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []);
return (
<div>
<SearchBox onSearch={handleSearch} />
<UserTable users={users} loading={loading} />
</div>
);
}
// 业务组件:无状态
function UserTable({ users, loading }) {
if (loading) return <Spinner />;
return (
<table>
{users.map(user => (
<UserRow key={user.id} user={user} />
))}
</table>
);
}
// 通用组件:无状态
function Spinner() {
return <div className="spinner" />;
}
这样分层的好处很明显:
- UserTable 可以单独测试,传不同的 users 数据看渲染效果
- Spinner 可以复用到任何需要加载提示的地方
- 如果以后要换数据源,只需要改 UserListPage,其他组件不用动
记住这个原则:
能写成无状态的就写成无状态的。无状态组件更容易测试、更容易复用、更容易理解。只有当你确实需要管理 UI 状态时,才把它变成有状态组件。
嗯,展示层设计就聊到这。说白了就是一句话:View 层要“傻”一点。它越傻,你的项目就越容易维护。下一章我们会聊数据层设计,到时候你会看到,把数据逻辑从 View 层剥离出来后,整个项目结构会变得多清晰。