理解前端框架的本质
前端在很大程度上能抽象成这两类功能。你的业务需求无非就是围绕着这两个点在打转。
- 展示数据
- 处理表单
而 Angular、React 和 Vue 这些视图框架的出现,则是可以让你用一种更加有效的方式来组织代码。接着, 自然而然的,。组件库可以让你更加高效地实现业务需求。理解了这些就会发现,做业务需求无非就是在做填空题。
让我们回顾历史,可以发现,前端(泛指各种客户端和浏览器端)框架的发展方向大致是这样。
- 无框架:20世纪60年代,技术员们直接使用系统提供的API绘图,并逐渐形成一套开发GUI雏型系统。
- MVC:Trygve Reenskaug 于 Xerox PARC 工作时,发现了这种模式。他把应用程序分成了三个部分:模型(Model)、视图(View)和控制器(Controller)。模型负责管理应用程序的数据和业务逻辑,视图负责展示数据给用户,控制器负责处理用户输入并更新模型和视图。主要贡献是提供了关注点分离,这极大的影响了后续软件开发的模式。MVC弊端在于,容易形成巨大且难以维护的 Controller 逻辑。
- MVP:在 MVC 的基础上,IBM 的 Mike Potel 在1996年的一篇论文中提出 MVP 概念,其核心是,把视图和模型完全分离,引入了 Presenter 层,你可以直接理解为 Presenter 对象持有了视图对象并主动更新视图,这解耦了视图层和模型层。
- MVVM:2005年,微软的 John Gossman 发布 WPF(Windows Presentation Foundation)时,提出了 MVVM 架构模式。MVVM 是对 MVP 的改进,主要区别在于引入了 ViewModel 层来处理业务逻辑和用户交互。ViewModel层负责处理视图层的事件和更新模型层的数据,把视图层和模型层完全分离的同时,也不耦合具体的视图和模型。
- Reactive MVVM:2010年,微软的 Reactive Extensions(Rx)库被引入,为 MVVM 架构引入了响应式编程和声明式视图的概念。这让 ViewModel 从框架中脱离出来,使得开发者能脱离具体的视图框架而独立开发,同时也使得 ViewModel 更加灵活和可测试,也能把这种思想引入到其他语言中,比如 RxJS, RxJava等。
- Flux MVVM :2014年,Facebook 发布了 Flux 架构模式,它是一种基于单向数据流的架构模式。Flux 架构模式强调将应用程序的状态存储在一个单一的数据源中,并且只能通过特定的方式来更新状态。
从上面的发展路径可以看到,大家会倾向于寻找一种能够分离数据和视图的架构设计模式。而大家发现,从 MVC、MVP 再到 MVVM,与其主动操纵GUI框架的接口去同步数据层,倒不如把数据层的状态变成响应式的,让视图层通过一个中间层来获取对应的状态变更。让大家从繁琐的 GUI 操作中解放出来,专注于业务逻辑的实现。
从上面的历史发展来看,现代前端框架的本质就是一种响应式的视图框架。而基于这些框架编写的代码,你需要关注以下这么几个点,来保证你的代码质量。
关注数据流
如果你有装修房子的水电的经验(或者没有经验也没问题),只要你思考过怎么装修,那么装修步骤就会是这样。
- 在蓝图上设计好管道走向。
- 然后施工时,按照蓝图上的路线来进行管道安装工作。
- 最后,通电、通水,检查管道是否畅通。
这其实跟软件开发很类似。因为在响应式编程中,也是有管道(pipeline) 这个概念的。你可以把数据看作是水,而管道就是用来控制水的流向。所以,在开发时,我们需要关心的是管道应该如何架设,这也是响应式编程的核心思路。
举个例子,假设我们需要实现一个简单的"输入搜索并显示结果"的功能。
关注"操作"的写法(命令式): 你需要在输入事件中手动调度所有相关的数据更新。
typescript
// 外部数据源
const data = ['apple', 'banana', 'orange', 'peach'];
const [searchTerm, setSearchTerm] = useState('');
const [filteredData, setFilteredData] = useState([]);
const [hasNoResults, setHasNoResults] = useState(true);
function onSearch(text: string) {
// 1. 手动更新搜索词
setSearchTerm(text);
// 2. 手动计算搜索结果
const results = data.filter(item => item.includes(text));
// 如果这里漏了,UI 就不会更新
setFilteredData(results);
// 3. 手动计算是否为空
setHasNoResults(results.length === 0);
}
关注"数据流"的写法(声明式/响应式): 你只需要定义数据之间的依赖关系(管道),数据会自动流转。
typescript
// 外部数据源
const data = ['apple', 'banana', 'orange', 'peach'];
// 1. 水源 (Source)
const [searchTerm, setSearchTerm] = useState('');
// 2. 管道 (Pipeline) - 只要源头变了,结果自然会变
const filteredData = useMemo(() => {
return data.filter(item => item.includes(searchTerm));
}, [searchTerm, data]);
// 3. 管道 (Pipeline) - 基于上游数据的进一步衍生
const hasNoResults = useMemo(() => {
return filteredData.length === 0;
}, [filteredData]);
// 视图层直接消费这些数据即可
业务逻辑应当是命令式代码
业务逻辑应该是命令式的,而在的如今的前端开发中,很多时候我们使用了声明式的代码来编写逻辑,导致产生一种难以名状的奇怪状态写法。例如,在React中,我们需要实现一个二次确认的提交表单的功能,一般来说,会写成这样。
tsx
function SomeForm() {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({
email: '',
address: '',
});
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
setFormData(data);
setOpen(true);
};
const realSubmit = async () => {
await HttpClient.post('/some-form', formData);
};
return (
<Fragment>
<form onSubmit={submit}>
{/* some form fields */}
<Button type="submit">提交</Button>
</form>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>确认提交</DialogTitle>
<DialogContent>
<DialogContentText>
确认提交表单吗?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>取消</Button>
<Button onClick={() => realSubmit()}>确认</Button>
</DialogActions>
</Dialog>
</Fragment>
)
}
真的,很不直观。声明式代码不应该把单一逻辑处理处理成多个状态的切换,上面的做法太丑陋了。尽管我们通过 ViewModel 把视图层的命令式代码替代掉,但并不意味着你处处都需要用命令式代码。再说一遍,业务逻辑需要是命令式的代码。
tsx
function SomeForm() {
const { dialog, contextHolder } = useDialog();
const [formData, setFormData] = useState({
email: '',
address: '',
});
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const confirm = await dialog.confirm('确认提交表单吗?');
if (!confirm) {
return;
}
const data = Object.fromEntries(new FormData(event.target));
setFormData(data);
await HttpClient.post('/some-form', formData);
};
return (
<Fragment>
<form onSubmit={submit}>
{/* some form fields */}
<Button type="submit">提交</Button>
</form>
{contextHolder}
</Fragment>
)
}
至于里面的 useDialog,我使用 Promise.withResolvers() 写了个参考写法。
tsx
interface PromiseResolvers<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
function useDialog() {
const [open, setOpen] = useState(false);
const resolversRef = React.useRef<PromiseResolvers<boolean>>(null);
const handleClose = () => {
setOpen(false);
if (!resolversRef.current) {
return;
}
resolversRef.current.resolve(false);
resolversRef.current = null;
}
const handleConfirm = () => {
setOpen(false);
if (!resolversRef.current) {
return;
}
resolversRef.current.resolve(true);
resolversRef.current = null;
}
return {
contextHolder: (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>确认提交</DialogTitle>
<DialogContent>
<DialogContentText>
确认提交表单吗?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>取消</Button>
<Button onClick={handleConfirm}>确认</Button>
</DialogActions>
</Dialog>
),
confirm: (message: string) => {
setOpen(true);
resolversRef.current = Promise.withResolvers();
return resolversRef.current.promise;
},
}
}
可以看到,上面的代码更加直观,阅读也容易理解,出错也容易排查。
解耦模块间的依赖
在响应式编程中,数据流是单向的 ;而我们在开发项目过程中,模块中的引用也应该是单向的。 解耦模块间的双向依赖其实可以使用以下两种方式实现:
- 事件订阅模式
- 依赖注入模式
下面仅以事件订阅模式为例,展示如何解耦模块间的依赖。
例子
有一种很流行的设计,喜欢把网络请求模块和UI模块结合在一起。
tsx
// user-info.page.ts
import { HttpService } from '@/http-service';
function UserInfoPage() {
const [formData, setFormData] = useState({
email: '',
address: '',
});
const submit = async () => {
await HttpService.post('/user-info', formData);
};
//...
return (
<div>
{/*...*/}
<button onClick={submit}>提交</button>
</div>
);
}
// ./http.service.ts
import axios from 'axios';
import { Message } from '@/ui';
async function post(url, data) {
try {
const response = await axios.post(url, data);
if (response.status !== 200) {
throw response;
}
Message.success('提交成功');
return response.data;
} catch (error) {
if (error && error.status === 403) {
Message.error('提交失败,权限不足');
navigate('/login');
} else {
Message.error("提交失败");
}
throw error;
}
}
export const HttpService = {
post,
}
当用户点击提交按钮时,数据被发送,并且会提示用户提交成功或者失败;当用户token失效时,会自动跳转到登录页。这种写法把网络请求和UI逻辑耦合了起来。在依赖层面看来,已经开始有点 bad-smell 了。
如何解决呢?实际上,网络请求不应该知道具体UI的逻辑。网络请求模块只需要提供一个事件系统,处理请求中不同的状态。UI模块只需要订阅这些事件,就可以知道本次请求的状态。
tsx
// event-center.ts
// 事件中心
type Event = (...args: any[]) => void;
function createEventCenter() {
const subjects = new Map<string, Event[]>();
function subscribe(eventName: string, fn: Event) {
let fns = subjects.get(eventName);
if (!fns) {
fns = [];
subjects.set(eventName, fns);
}
fns.push(fn);
return () => {
const index = fns.indexOf(fn);
if (index === -1) {
return;
}
fns.splice(index, 1);
}
}
function emit(eventName: string, ...args: any[]) {
const fns = subjects.get(eventName);
if (fns) {
fns.forEach(fn => fn(...args));
}
}
return {
emit,
subscribe,
}
}
http请求模块引入上面的事件中心。
typescript
// http-client.ts
// 事件中心
const eventCenter = createEventCenter();
async function post(url, data) {
try {
const response = await axios.post(url, data);
if (response.status !== 200) {
throw response;
}
eventCenter.emit('REQUEST_SUCCESS', response.data);
return response.data;
} catch (error) {
if (error && error.status === 403) {
eventCenter.emit('NO_PERMISSION', response.data);
} else {
eventCenter.emit('REQUEST_ERROR');
}
throw error;
}
}
function subscribe(eventName: string, fn: Event) {
eventCenter.subscribe(eventName, fn);
}
export const HttpService = {
post,
subscribe,
}
最后,UI模块订阅来自网络模块的事件。
typescript
// app.ts
import { HttpClient } from '@/http-client';
import { Message } from '@/ui';
function App() {
React.useEffect(() => {
const subs = [
HttpClient.subscribe('NO_PERMISSION', () => {
navigate('/login');
}),
HttpClient.subscribe('REQUEST_ERROR', () => {
Message.error("提交失败");
}),
HttpClient.subscribe('REQUEST_SUCCESS', (data) => {
Message.success("提交成功");
}),
];
return () => {
subs.forEach(sub => sub());
}
}, []);
// rest code ...
}
这样,UI模块就只需要订阅来自网络模块的事件。而网络模块也依赖UI模块,调试起来也会方便很多。这个思路可扩展各个不同模块之间的通信,这就解耦了不同模块间的依赖。
总结
前端开发的核心在于理解框架本质与代码组织方式。现代前端框架本质上是响应式的视图框架。为了编写高质量代码,建议遵循以下原则:
- 关注数据流:利用响应式编程思想,通过定义数据间的依赖关系(管道)来自动流转数据,而非手动调度更新。这能让代码更具声明式特征,减少副作用。
- 保持业务逻辑命令式 :虽然视图更新是响应式的,但对于线性的业务流程(如"点击按钮 -> 二次确认 -> 提交数据"),应保持命令式的逻辑编写方式。避免为了迎合框架而过度拆分状态(如设置多个
open状态位),导致逻辑分散和难以追踪。 - 解耦模块依赖:使用订阅发布模式(Event Center)分离业务逻辑(如网络请求)与视图层。UI 只需响应状态变化,无需关心具体请求逻辑,从而降低耦合度,提升可维护性。
通过平衡声明式的数据流与命令式的业务逻辑,可以构建出更清晰、易维护且逻辑自洽的前端应用。