你的前端代码应该怎么写

理解前端框架的本质

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

  • 展示数据
  • 处理表单

而 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
相关推荐
小园子的小菜11 分钟前
Netty 核心组件 Channel、ChannelHandler 与简易 RPC 架构实战详解
网络协议·rpc·架构
Csvn9 小时前
WASM
前端
修己xj10 小时前
我用 AI 给自己写了一个 Markdown 编辑器,然后把它开源了
前端
ZhengEnCi10 小时前
GPT-6 Astra 爆改 Qwen 权重真有那么神吗:权重空间编程 0.016% 参数的真相
算法·架构
FRing0311 小时前
vue2干货总结2(v-bind、v-for、v-model、指令修饰符、v-bind操作class和style)
开发语言·前端·vue.js·vue2
幸运小猿11 小时前
模拟慢上传-处理超时重试image 丢失线上问题
前端·javascript·html
码流子12 小时前
移动收费怎么做?手持终端+App+收费中心的架构实践
java·开发语言·架构
moshuying12 小时前
网购能买Token了!
前端
h_654321013 小时前
echarts实现柱状图加一条横线
前端·echarts