一、前端架构在做什么?
作为前端工程师,我们大部分时间是在写业务代码,实现展示内容和交互逻辑。随着应用不断变大,一些问题开始浮现:
- 前端权限控制逻辑散落在几十个页面组件中,更新逻辑时不仅要重复修改几十处代码,还得全量覆盖测试。
- 一个用户管理页面动辄上千行代码,集齐搜索栏、列表、表单于一身,迭代功能时不仅要翻看几百行的代码,还总是担心误改其它功能。
- 将所有支付方式的代码都写在一块,通过条件判断执行不同的支付逻辑。每新增一种支付方式都要修改支付页面的代码,可能影响存量的逻辑。
这都是典型的“意大利面”代码,严重降低了代码的可维护性。有良好可维护性的代码具备以下特点:
- 可读性:一段好的代码是能让新人或本人回头就能秒懂的。
- 可扩展性:新增功能不影响主体流程,支持可拔插。
- 可复用性:不是简单地“复制粘贴”,而是通过抽象解耦让代码安全、高效地被复用。
为了消除“意大利面”代码,我们从实践中沉淀出了两个核心原则和优化策略。
二、核心原则
1.单一职责
一个函数只做一件事。
一个组件只做一个功能。
一个模块只做一类功能,例如:页面、组件、逻辑、路由、请求、类型,或者用户、商品、订单。
2.开闭原则
对扩展开放,对修改关闭。采用合适的设计模式进行抽象解耦,提升可扩展性。
三、优化策略
1.组件化
对比原生HTML开发,现代框架(React、Vue)最大的进步之一就是引入了“组件化”思想。将复杂的用户界面拆分为多个独立的组件,每个组件有自己的UI和逻辑,提升了代码的可维护性。
组件化开发可分为两步:
- 第一步:拆分页面组件。遵循“单一职责”的原则,对页面组件进行功能拆分,例如搜索框和产品列表。
- 第二步:封装组件。好的组件应该具备可复用性和可扩展性。业务组件侧重于可扩展性,通用组件更关注可复用性。
最佳实践:
- 单向数据流:自上而下,父组件通过props传入数据,子组件通过回调函数通知父组件(不能直接修改父组件状态)。
- 展示/容器组件: 纯展示组件(无状态、无副作用)+ 容器组件(管理状态、请求、业务逻辑),逻辑与视图解耦。
- 明确接口定义:使用TypeScript定义Props接口,明确入参含义。
- 良好的设计模式:使用策略、观察者、装饰器等设计模式进行抽象解耦。
2.模块化
组件化是对页面的拆分,而模块化是对应用整体的拆分。根据技术分层、业务领域等维度将代码划分为多个独立的模块,模块之间有明确的边界,修改代码时互不干扰。
技术分层划分:
src/
├── components/ # 组件
├── hooks/ # hooks
├── utils/ # 工具函数
├── api/ # 接口请求
├── types/ # 类型定义
├── pages/ # 页面
├── router/ # 路由配置
└── assets/ # 静态资源
业务领域划分:
src/
├── components/ # 通用组件
├── hooks/ # 通用hooks
├── utils/ # 工具函数
├── api/ # 接口请求封装(拦截器/http客户端配置)
├── types/ # 通用类型
├── pages/
│ ├── goods/ # 商品业务域(所有商品相关代码)
│ │ ├── pages/ # 商品相关页面(列表/详情/编辑)
│ │ ├── components/ # 商品专属组件(不可复用)
│ │ ├── hooks/ # 商品专属hooks
│ │ ├── api/ # 商品专属接口
│ │ └── types/ # 商品专属类型
│ ├── order/ # 订单业务域
│ │ ├── pages/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── types/
│ └── user/ # 用户业务域
├── router/ # 路由配置
└── assets/ # 静态资源
技术分层相对简单,适合刚启动的小项目。随着项目的演进,页面个数变多了,每新增一个页面都要改动components、hooks、api等公共目录。这种情况下,需要进一步根据业务领域划分模块,把业务领域相关的components、hooks、api等放到领域的子目录下。
3.微前端
如果你的项目代码规模继续扩大,又要面临新的问题:1.多人开发协作,如何保证功能迭代的高效性和稳定性。2.应用体积大,如何提升性能和用户体验?
这种情况下,拆模块已经无法满足,要进一步考虑拆应用了。
1.单体架构的问题
多人开发协作面临着代码版本管理与合并冲突等问题,需要花费更多的时间和精力在协作上。另外多个模块共用一套部署环境和运行环境,只要其中一个模块有问题都有可能影响其它功能,影响了整体的稳定性。
所有功能都堆积在一个应用上,构建速度缓慢,资源加载延迟,严重影响用户体验(UX)和开发体验(DX)。
2.微前端架构
按照业务模块,将单体应用拆分为多个独立的微应用,独立开发、独立部署、独立运行,从架构层面优化了多人开发协作和巨石应用的性能和用户体验等问题。
4.抽象解耦
使用策略、观察者、装饰器等设计模式抽象通用逻辑,解耦具体实现。
策略模式:将算法的实现和算法的使用解耦
策略模式定义了一组算法,并且支持动态切换算法。通过对算法进行封装,将算法的实现和算法的使用解耦,实现新增算法不需要修改客户端。
应用场景:一个对象有某个行为,在不同场景中,该行为有不同的算法实现。
- 支付方式:用户选择不同支付方式,执行相应的支付逻辑。
- 表单校验:根据表单字段类型,执行相应的校验逻辑。
- 排序方式:根据选择的排序方式,执行相应的排序逻辑。
反例(可扩展性差):
const BadPaymentPage: React.FC = () => {
const [selectedType, setSelectedType] = useState<'wxjsapipay' | 'wxh5pay' | 'alipay' >('alipay');
const submitPay = () => {
if("wxjsapipay" === selectedType){
// 在微信内发起微信支付...
} else if("wxh5pay" === selectedType){
// 在手机浏览器发起微信支付...
} else if("alipay" === selectedType){
// 发起支付宝支付...
}
{/* 如需新增手机银行支付,必须在此添加新的条件块 */}
}
return (
<div>
<h2>选择支付方式</h2>
<div>
<div onClick={() => setSelectedType('alipay')}>
<span>支付宝</span>
</div>
<div onClick={() => setSelectedType('wxjsapipay')}>
<span>微信支付</span>
</div>
</div>
<button onClick={submitPay}>确认支付</button>
</div>
);
};
正例(策略模式):
1.定义支付策略接口
type PayStrategy = (param: PayParam) => Promise<void>;
type PayStrategyMap = Record<string, PayStrategy>;
2.支付方式策略的实现
- 在微信内发起微信支付
// 判断是否在微信客户端中
const isWeChat = () => {
const ua = navigator.userAgent.toLowerCase();
return ua.indexOf('micromessenger') !== -1;
};
// 获取 URL 中的 code(微信授权后返回)
const getCodeFromUrl = () => {
const params = new URLSearchParams(window.location.search);
return params.get('code');
};
// 获取 URL 中的 支付信息(微信授权后返回透传)
const getPayInfoFromUrl = () => {
const params = new URLSearchParams(window.location.search);
return params.get('payInfo');
};
// 微信网页授权跳转
const wxAuthorize = () => {
const params = getWxAuthorizeParams();
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?${params}`;
window.location.href = url;
};
// JSAPI 支付函数
const wxPay = async (data) => {
return new Promise((resolve, reject) => {
const getBrandWCPayRequest = () => {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
data,
(res) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
resolve()
} else {
reject(res.err_msg)
}
}
);
}
if (typeof WeixinJSBridge === 'undefined') {
// 等待WeixinJSBridge 就绪
document.addEventListener('WeixinJSBridgeReady', () => {
getBrandWCPayRequest();
});
} else {
getBrandWCPayRequest();
}
});
};
const wxJSAPIPayStrategy:PayStrategy = async (payParam) => {
if(!isWeChat()){
return;
}
try {
code = getCodeFromUrl();
if (!code) {
wxAuthorize(); // 获取微信网页授权code
return;
}
// 调用后台接口去微信下单,返回微信支付信息
const wxPayParam = await unifiedOrder(payParam,code);
// 调用微信支付 JSAPI...
await wxPay(wxPayParam);
// 支付成功,跳转到订单详情页
window.location.href = `/order-detail?orderId=${data.orderId}`;
} catch (error) {
onError(error as Error);
}
};
// 微信网页授权后重定向返回
(function wxAuthorizeCallback(){
const payInfo = getPayInfoFromUrl();
if(isWeChat() && payInfo){
wxJSAPIPayStrategy(JSON.parse(payInfo));
}
})();
export default wxJSAPIPayStrategy;
- 在手机浏览器发起微信支付
const wxH5PayStrategy:PayStrategy = async (payParam) => {
try {
// 调用后台接口去微信下单,返回微信支付跳转链接
const mwebUrl = await unifiedOrder(payParam);
window.location.href = mwebUrl;
} catch (error) {
onError(error as Error);
}
};
export default wxH5PayStrategy;
- 发起支付宝支付
const aliPayStrategy:PayStrategy = async (payParam) => {
try {
// 调用后台接口去支付宝下单,返回支付宝表单
const formHtml = await unifiedOrder(payParam);
// 创建支付宝隐藏表单
const container = document.createElement('div');
container.style.display = 'none';
container.innerHTML = formHtml;
document.body.appendChild(container);
const form = document.getElementById('alipay-submit') ||
document.forms['punchout_form'] ||
container.querySelector('form');
if (form) {
// 自动提交表单,页面会跳转到支付宝
form.submit();
}
} catch (error) {
onError(error as Error);
}
};
export default aliPayStrategy;
3.支付页面
const payStrategyMap:PayStrategyMap = {
"wxjsapipay": wxJSAPIPayStrategy,
"wxh5pay": wxH5PayStrategy,
"alipay": aliPayStrategy
}
const PaymentPage: React.FC = () => {
const [selectedType, setSelectedType] = useState<'wxjsapipay' | 'wxh5pay' | 'alipay' >('alipay');
const submitPay = () => {
const payStrategy = payStrategyMap[selectedType];
payStrategy();
}
return (
<div>
<h2>选择支付方式</h2>
<div>
<div onClick={() => setSelectedType('alipay')}>
<span>支付宝</span>
</div>
<div onClick={() => setSelectedType('wxjsapipay')}>
<span>微信支付</span>
</div>
</div>
<button onClick={submitPay}>确认支付</button>
</div>
);
};
export default PaymentPage;
观察者模式:将主题和观察者解耦
观察者模式定义了一种一对多的通知机制,当主题变化时,自动通知所有观察者进行更新。通过注册和通知机制,将主题和观察者解耦,实现新增观察者不需要修改主题。
前端应用开发是基于事件驱动的,观察者模式无处不在:
- 用户点击事件
button.addEventListener('click', handleClick);
- 定时器超时事件
setTimeout(function() {
console.log('2秒后执行');
}, 2000);
- 观察元素的可见性,实现懒加载。页面包含大量图表,为了提升首屏加载速度和避免占用过多的浏览器连接数资源,当图表即将进入视口时才加载资源。
// 创建 IntersectionObserver 实例
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0) {
// 当图表进入视口
}
});
}, {
rootMargin: '0px 0px 200px 0px', // 扩大视口范围:向下提前 200px 触发加载
threshold: 0.01 // 只要有 1% 进入视口就触发
});
// 开始观察图表
observer.observe(chartDom);
除了原生的浏览器事件,现代框架(React、Vue)内置的响应式系统本质也是观察者模式。
function MyButton() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<button onClick={handleClick}>
Clicked {count} times
</button>
);
}
React使用useState来保存状态,当调用setCount更新count的值,React会自动更新引用了count变量的button组件的文本。
<script setup>
// 响应式状态
const count = ref(0)
// 用来修改状态、触发更新的函数
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
Vue使用ref()来声明响应式状态,当更新count.value的值,Vue会自动更新引用了count变量的button组件的文本。
装饰器模式:通过“组合”扩展功能
装饰器模式允许在不修改原有代码的情况下,添加新功能。通过创建装饰器来包裹原有对象,动态组合扩展对象功能。
例如,将通用逻辑封装成高阶组件和高阶函数,用来增强组件功能和函数功能。
- 前端权限控制
import { useAuth } from ../context/AuthProvider;
const withAuth = (WrappedComponent, requiredPermissions, options = {}) => {
const { FallbackComponent = () => <div>无权限访问</div>, redirectTo } = options;
return function AuthWrapper(props) {
const { hasPermission } = useAuth();
const hasAccess = hasPermission(requiredPermissions);
if (!hasAccess) {
return redirectTo ? <Navigate to={redirectTo} /> : <FallbackComponent />
}
return <WrappedComponent {...props} />;
};
};
export default withAuth;
- 加载状态展示
const withLoading = (WrappedComponent, options = {}) => {
const {
LoadingComponent = () => <div>Loading...</div>,
} = options;
return function LoadingWrapper(props) {
const isLoading = props.loading;
if (isLoading) {
return <LoadingComponent />;
}
delete props['loading'];
return <WrappedComponent {...props} />;
};
};
export default withLoading;
- 防抖函数
function debounce(func, wait, immediate = false) {
let timeoutId;
let lastArgs;
let lastThis;
let result;
function later() {
timeoutId = null;
if (!immediate) {
result = func.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}
}
function debounced(...args) {
lastArgs = args;
lastThis = this;
if (immediate && !timeoutId) {
result = func.apply(this, args);
timeoutId = setTimeout(() => {
timeoutId = null;
}, wait);
} else {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(later, wait);
}
return result;
}
debounced.cancel = function() {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastArgs = lastThis = null;
};
return debounced;
}
5.状态管理
在原生HTML开发时代,你是如何实现用户交互的?你需要根据用户的输入编写操作DOM的代码。这也叫命令式编程,因为你必须“命令”每个元素,告诉浏览器如何更新界面。例如,当输入框为空时,禁用“提交”按钮,当用户输入时,启用“提交”按钮。
现代框架(React、Vue)提供了一种声明式UI编程范式,你不用直接操作DOM,相反地,你只需要声明在不同状态下要展示的内容。例如,“空白”状态(输入框为空)展示禁用“提交”按钮,“输入”状态(输入框不为空)展示启用“提交”按钮。当输入框的值变化时,根据值是否为空,设置状态为“空白”或“输入”。当状态变更后,框架会自动更新界面。
由此可见,在以状态为驱动的现代SPA应用开发,UI=render(state),状态管理的好坏直接决定了组件的渲染效率和维护成本。
组件状态
作用于组件内部,生命周期随组件创建和销毁而存在,管理方式为useState,例如表单输入、弹窗开关、加载。
共享状态
整个组件树(或子树)共享的数据,管理方式为useContext,例如用户权限、主题切换、组件库配置。
服务端状态
请求服务端数据的状态,也包括其它异步操作的状态。管理方式为React Query。
应用场景:
- 请求状态管理:加载、数据、错误状态。
- 请求重试:当请求失败时,自动重试(未超过最大连续重试次数)。
- 竞态条件:搜索框实时搜索,间隔1s触发两次请求,由于网络响应的顺序可能和请求的顺序不同,所以如果第二次的响应比第一次的慢,就会出现错误的结果。
- 并发去重:多个组件获取同一个下拉选项列表,对并发的请求去重,只触发一次请求;
- 乐观更新:给文章点赞,先更新UI,再后台fetch服务端更新,失败则回滚UI。
- 分页查询和无限查询。
URL 状态
保存在URL 参数、路由路径等状态。支持分享链接、刷新不丢失。例如,搜索筛选条件、选中的标签页。
可预测的数据流
在大型应用中,多个组件通过双向共享状态,会让数据流变成“意大利面式”的网状结构,导致状态变化不可预测,难以追踪和调试。
- 双向混乱
<template>
<div>
<select v-model="localAddress.city">
<option>北京</option>
<option>上海</option>
</select>
<input v-model="localAddress.district" placeholder="区县" />
</div>
</template>
<script>
export default {
props: ['value'],
data() {
return {
localAddress: this.value
};
},
watch: {
localAddress: {
deep: true,
handler(newVal) {
this.$emit('input', newVal); // 双向同步回父组件
}
}
}
};
</script>
<template>
<div>
<AddressEditor v-model="user.address" />
<button @click="resetAddress">重置地址</button>
</div>
</template>
<script>
export default {
data() {
return {
user: {
address: { city: '北京', district: '朝阳区' }
}
};
},
methods: {
resetAddress() {
this.user.address = { city: '北京', district: '朝阳区' };
}
}
};
</script>
上面的地址组件通过v-model双向绑定,当地址变化时无法追踪到是父组件还是地址组件本身修改了值。
- 单向数据流
数据通过 props传递给子组件,子组件执行回调通知父组件更新状态。在这个过程中数据流向永远是“自上而下”的,这让应用的状态变化变得可预测。
function UserForm() {
const [user, setUser] = useState({
address: { city: '北京', district: '朝阳区' }
});
const handleAddressChange = (newAddress) => {
setUser(prev => ({ ...prev, address: newAddress }));
};
const resetAddress = () => {
setUser(prev => ({
...prev,
address: { city: '北京', district: '朝阳区' }
}));
};
return (
<div>
<AddressEditor
address={user.address}
onAddressChange={handleAddressChange}
/>
<button onClick={resetAddress}>重置地址</button>
</div>
);
}
export default UserForm;
function AddressEditor({ address, onAddressChange }) {
const handleCityChange = (e) => {
const newAddress = {
...address,
city: e.target.value
};
onAddressChange(newAddress); // 向上传递新地址
};
const handleDistrictChange = (e) => {
const newAddress = {
...address,
district: e.target.value
};
onAddressChange(newAddress);
};
return (
<div>
<select value={address.city} onChange={handleCityChange}>
<option>北京</option>
<option>上海</option>
</select>
<input
value={address.district}
onChange={handleDistrictChange}
placeholder="区县"
/>
</div>
);
}
export default AddressEditor;
四、总结
实现代码的可维护性,是前端架构设计的核心内容之一,包括可读性、可扩展性、可复用性。为了消除“意大利面式”代码,架构设计要遵守“单一职责”和“开闭原则”两个核心原则,同时使用组件化、模块化、微前端、抽象解耦、状态管理等优化策略。