你的前端代码应该怎么写

理解前端框架的本质

前端在很大程度上能抽象成这两类功能。你的业务需求无非就是围绕着这两个点在打转。

  • 展示数据
  • 处理表单

而 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;
    },
  }  
}

可以看到,上面的代码更加直观,阅读也容易理解,出错也容易排查。

解耦模块间的依赖

在响应式编程中,数据流是单向的 ;而我们在开发项目过程中,模块中的引用也应该是单向的。 解耦模块间的双向依赖其实可以使用以下两种方式实现:

  1. 事件订阅模式
  2. 依赖注入模式

下面仅以事件订阅模式为例,展示如何解耦模块间的依赖。

例子

有一种很流行的设计,喜欢把网络请求模块和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模块,调试起来也会方便很多。这个思路可扩展各个不同模块之间的通信,这就解耦了不同模块间的依赖。

总结

前端开发的核心在于理解框架本质与代码组织方式。现代前端框架本质上是响应式的视图框架。为了编写高质量代码,建议遵循以下原则:

  1. 关注数据流:利用响应式编程思想,通过定义数据间的依赖关系(管道)来自动流转数据,而非手动调度更新。这能让代码更具声明式特征,减少副作用。
  2. 保持业务逻辑命令式 :虽然视图更新是响应式的,但对于线性的业务流程(如"点击按钮 -> 二次确认 -> 提交数据"),应保持命令式的逻辑编写方式。避免为了迎合框架而过度拆分状态(如设置多个 open 状态位),导致逻辑分散和难以追踪。
  3. 解耦模块依赖:使用订阅发布模式(Event Center)分离业务逻辑(如网络请求)与视图层。UI 只需响应状态变化,无需关心具体请求逻辑,从而降低耦合度,提升可维护性。

通过平衡声明式的数据流与命令式的业务逻辑,可以构建出更清晰、易维护且逻辑自洽的前端应用。

参考文献

  1. MVC XEROX PARC 1978-79
  2. MVP: Model-View-Presenter The Taligent Programming Model for C++ and Java
  3. Introduction to Model/View/ViewModel pattern for building WPF apps
相关推荐
电商API_180079052472 小时前
大麦网API实战指南:关键字搜索与详情数据获取全解析
java·大数据·前端·人工智能·spring·网络爬虫
康一夏2 小时前
CSS盒模型(Box Model) 原理
前端·css
web前端1232 小时前
React Hooks 介绍与实践要点
前端·react.js
我是小疯子662 小时前
JavaScriptWebAPI核心操作全解析
前端
小二·2 小时前
Python Web 开发进阶实战:全链路测试体系 —— Pytest + Playwright + Vitest 构建高可靠交付流水线
前端·python·pytest
貂蝉空大2 小时前
vue-pdf-embed分页预览解决文字丢失问题
前端·vue.js·pdf
满天星辰2 小时前
Typescript的infer到底怎么使用?
前端·typescript
ss2732 小时前
RuoYi-App 本地启动教程
前端·javascript·vue.js