前言
前端相比后台拥有更高的自由度,但这也意味着前端开发者更容易写出低质量的垃圾代码。由于前端技术的多样性和灵活性,开发者在实现功能和设计界面时有更多的选择和变化空间。为了确保项目的可维护性、可扩展性、可复用性和可读性,同时也为未来的需求变化做好准备。我们可以采用多种技术和方法来改进现有代码架构,提高代码质量和可维护性,从而编写出高质量的代码。
其中一项重要的开发原则便是高内聚低耦合,它强调模块内部的职责单一性和模块之间的解耦性。通过将相关的功能和逻辑组织在一起,使得代码更加清晰、易于理解和维护。在本文中,我将以深入浅出的方式详细介绍高内聚低耦合的技术原则,并提供实际的实践案例,以帮助我们更好地理解和应用该原则。
一、高内聚低耦合
高内聚低耦合是一种常见的编程原则,它可以帮助我们编写可维护和可扩展的代码。高内聚意味着将相关代码组织在一起,以便它们可以共同完成一个任务或实现一个功能。低耦合意味着将模块之间的依赖关系降至最低,以便更容易进行维护和修改。
- 高内聚:指将相关的功能和数据封装在一起形成模块或类,使得模块或类内部的各个元素紧密联系并协同工作,达到高度的内聚性。高内聚的代码结构意味着代码的各个组成部分之间的耦合度低,模块或类之间的联系和依赖清晰明了,使得代码更容易理解、维护和扩展。
- 低耦合:指将不同模块或类之间的联系和依赖降到最低,使得模块或类之间的关系松散,互相之间的影响最小化。低耦合的代码结构意味着代码的各个组成部分之间的联系和依赖关系简单、清晰明了,使得代码更容易维护、扩展和重构。
实现高内聚的同时可以降低模块或类之间的耦合度,实现低耦合的同时可以提高模块或类的内聚度。

前端开发中,实现高内聚和低耦合的方法包括依赖注入、模块化开发 、组件化架构 、单一职责原则 、发布-订阅模式 和面向接口编程等。通过采用这些方法,可以帮助我们实现高内聚和低耦合的代码结构,使得代码更加清晰、简洁和易于维护。这进一步提高了代码的可读性、可扩展性和可复用性,从而提高了软件开发的效率和质量。
二、实现技术与方法
为了实现高内聚低耦合,我们可以采用以下技术和方法:
2.1 单一职责
单一职责原则 (SRP:Single responsibility principle)又称单一功能原则,面向对象五个基本原则(SOLID)之一。它规定一个类应该只有一个发生变化的原因,这个原则的目的是将复杂的问题拆分为更小的问题,使代码更加可维护和可重用。
拿网站上的登陆和注册功能为例,我们可以将登录功能抽象成一个 Login 模块或类,该 Login 模块只负责用户登录的相关逻辑,比如验证用户名和密码、记录登录状态等。如果登录功能还包括了用户注册、找回密码等其他功能,那么这些功能应该分别封装成不同的模块或类,而不是混合在 Login 模块中。代码示例如下所示:
javascript
class Login {
constructor(username, password) {
this.username = username;
this.password = password;
}
validate() {}
recordLoginStatus() {}
}
javascript
class Register {
constructor(username, password, email) {
this.username = username;
this.password = password;
this.email = email;
}
validate() {}
createUser() {}
sendConfirmationEmail() {}
}
ini
const login = new Login('Brycen', '12345678');
if (login.validate()) {
login.recordLoginStatus();
}
const register = new Register('Brycen', '12345678', '[email protected]');
if (register.validate()) {
register.createUser();
register.sendConfirmationEmail();
}
上面的代码中,我们将登录和注册功能分别封装在了不同的类中。Login 类只负责登录相关的逻辑,而 Register 类只负责注册相关的逻辑。这样可以使得代码更加清晰、易于维护和测试。如果将这些功能混合在一起,代码会变得复杂、难以维护和测试。
根据上面的代码案例,我们可以将这两个功能分别封装成不同的组件,以符合单一职责原则。下面是一个使用React实现的简单的业务场景示例:
javascript
import React, { useState } from 'react';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleUsernameChange = (event) => {
setUsername(event.target.value);
};
const handlePasswordChange = (event) => {
setPassword(event.target.value);
};
const handleLogin = (event) => {
event.preventDefault();
/* ...... */
};
return (
<form onSubmit={handleLogin}>
<label>
<span>Username:</span>
<input type="text" value={username} onChange={handleUsernameChange} />
</label>
<label>
<span>Password:</span>
<input type="password" value={password} onChange={handlePasswordChange} />
</label>
<button type="submit">Login</button>
</form>
);
}
export default Login;
javascript
import React, { useState } from 'react';
function Register() {
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleUsernameChange = (event) => {
setUsername(event.target.value);
};
const handlePasswordChange = (event) => {
setPassword(event.target.value);
};
const handleEmailChange = (event) => {
setEmail(event.target.value);
};
const handleRegister = (event) => {
event.preventDefault();
/* ...... */
};
return (
<form onSubmit={handleRegister}>
<label>
<span>Username:</span>
<input type="text" value={username} onChange={handleUsernameChange} />
</label>
<label>
<span>Password:</span>
<input type="password" value={password} onChange={handlePasswordChange} />
</label>
<label>
<span>Email:</span>
<input type="email" value={email} onChange={handleEmailChange} />
</label>
<button type="submit">Register</button>
</form>
);
}
export default Register;
在这个示例中,Login 组件只负责显示登录表单,并处理用户输入的用户名和密码。Register 组件只负责显示注册表单,并处理用户输入的用户名、密码和邮箱。这两个组件之间的代码逻辑互不涉及。
在使用React实现单一职责原则时,需要注意以下几点:
- 组件的职责应该尽量单一,避免一个组件承担过多的功能和职责。如果一个组件承担了过多的职责,会导致组件的代码复杂度增加,难以维护和扩展。
- 组件的状态应该尽量局限在组件内部,避免将状态和逻辑分散在多个组件中。如果多个组件共享同一个状态,可以将状态提升到它们的共同父组件中,或者使用全局状态管理工具(如Redux)来进行管理。
- 组件之间的通信应该尽量通过属性传递(props),而不是直接访问其他组件的状态。如果组件之间需要共享状态,可以将状态提升到它们的共同父组件中,或者使用全局状态管理工具(如Redux)来进行管理。
- 组件的命名应该具有描述性,能够清晰地表达组件的职责和功能。
- 如果组件内部的职责过于复杂,可以将组件拆分成更小的组件,以便更好地遵循单一职责原则。
- 组件应该尽量避免直接修改外部状态,而是通过回调函数等方式向外部组件传递消息,以实现更好的组件复用性和可维护性。
- 组件的生命周期方法应该尽量只处理与组件渲染相关的逻辑,避免将过多的逻辑耦合到生命周期方法中。
- 组件的样式应该尽量与组件的功能和职责紧密相关,避免样式过于复杂和冗余,以提高代码的可读性和可维护性。
2.2 依赖注入
依赖注入(Dependency Injection,简称 DI)是一种软件设计模式,它通过将对象依赖关系的管理从对象本身移动到外部,从而实现了模块或组件之间的解耦。在前端开发中,依赖注入通常是通过将依赖对象作为参数传递给构造函数或方法来实现的,这种方式使得模块或组件更加灵活和可测试,减少了它们对具体实现的直接依赖。下面是一个使用依赖注入实现的简单示例:
javascript
// 定义一个依赖注入容器
class Container {
constructor() {
this.dependencies = {};
}
// 注册一个依赖项
register(name, dependency) {
this.dependencies[name] = dependency;
}
// 获取一个依赖项
get(name) {
if (!this.dependencies[name]) {
throw new Error(`${name} dependency not found`);
}
return this.dependencies[name];
}
}
javascript
// 定义一个服务
class Service {
constructor() {
this.message = "Hello, World!";
}
greet() {
console.log(this.message);
}
}
javascript
// 定义一个控制器,依赖于服务
class Controller {
constructor(service) {
this.service = service;
}
run() {
this.service.greet();
}
}
// 使用容器来注入依赖项
const container = new Container();
container.register("service", new Service());
container.register("controller", new Controller(container.get("service")));
// 运行控制器
const controller = container.get("controller");
controller.run();
案例中,我们创建了一个依赖注入容器 Container
对象,并定义了一个服务 Service
对象 和一个控制器 Controller
对象。控制器依赖于服务,我们在容器中注册服务和控制器,并使用容器来注入服务依赖项。这样做的好处是,在需要使用服务的地方,我们不需要手动创建服务实例,而是通过容器来获取,容器会自动创建并注入服务依赖项。
在 React 中,主要关注的是组件的渲染和更新,它本身并不直接提供原生的依赖注入功能。然而,我们可以借助第三方库或自定义解决方案来实现依赖注入的特性。React Context 是 React 提供的一种用于在组件树中跨层级传递数据的机制。我们可以利用这种方式现依赖注入,以便有效地管理组件之间的依赖关系,从而实现高内聚和低耦合的组件设计。以下是使用 React Context 实现依赖注入的示例:
javascript
// App.js
import React, { createContext, useContext } from 'react';
import { UserList } from './components/UserList.js';
import { UserService } from './services/user-service.js';
const userService = new UserService();
export const UserServiceContext = createContext(userService);
function Container() {
return (
<UserServiceContext.Provider value={userService}>
<UserList />
</UserServiceContext.Provider>
);
}
export default Container;
这个示例中,我们创建了一个UserServiceContext
上下文对象,并将userService
对象作为其值传递给 Provider
组件。这样我们就可以在组件树中的任何一个子组件中,通过useContext
钩子来获取userService
对象。 代码案例如下所示:
javascript
// UserList.js
import React, { useContext } from 'react';
import { UserServiceContext } from '../App.js';
export function UserList() {
const userService = useContext(UserServiceContext);
const users = userService.getUsers();
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
如此,我们就可以避免在组件之间显式地传递 props 或通过层层嵌套的组件层级来传递数据。通过创建一个 Context 对象,我们可以将数据或功能封装在上层组件中,并将其提供给下层组件使用,实现依赖注入的模式。
需要注意的是,使用 React Context 实现依赖注入,**存在一些性能上的开销。**主要原因是当上下文中的数据发生变化时,所有依赖该上下文的组件都需要重新渲染,这可能包括整个组件子树。因此,需要谨慎使用 Context,以避免不必要的性能开销。代码案例如下所示:
javascript
import React, { createContext, useContext, useMemo } from "react";
const UserNameContext = createContext(undefined);
const UserAddressContext = createContext(undefined);
function App() {
return (
<UserNameContext.Provider value="Brycen">
<UserAgeContext.Provider value="18">
<Header />
<Content />
</UserAgeContext.Provider>
</UserNameContext.Provider>
);
}
javascript
function Header() {
const userName = useContext(UserNameContext);
return (
<section>
<header>
<h1>{userName}</h1>
</header>
</section>
)
}
javascript
function Content() {
const userName = useContext(UserNameContext);
const userAddress= useContext(UserAddressContext );
return (
<section>
<div>
<p>{userName}</p>
<p>{userAddress}</p>
</div>
</section>
)
}
我们根据功能的不同将 UserContext 拆分成了 UserNameContext
和 UserAddressContext
,并在需要使用用户名和用户地址的组件中分别使用相应的 Context。这样做的好处是可以更细粒度地控制数据的传递和更新,提高了代码的可维护性和灵活性。
然而,有时候拆分得太细致也可能带来一些性能开销。为了进一步优化性能,我们可以结合使用 React.memo() 函数,对组件进行记忆化处理。React.memo() 是一个高阶组件,用于缓存组件的渲染结果,只有在组件的 props 发生变化时才会触发重新渲染。这样可以避免不必要的渲染,提升组件的性能。示例代码如下所示:
javascript
import React, { createContext, useContext, useMemo } from "react";
const UserContext = createContext(undefined);
function App() {
return (
<UserContext.Provider value={{
user: {
name: "Brycen",
address: "Earth"
}
}}>
<Header />
<Content />
</UserContext.Provider>
);
}
javascript
const UserInfo = memo((props) => {
const { user } = props;
return (
<div>
<p>{user.name}</p>
<p>{user.address}</p>
</div>
)
})
const Content1 = () =>{
const { user } = useContext(UserContext);
return (
<section>
<UserInfo user={user}/>
</section>
)
}
const Content2 = () =>{
const { user } = useContext(UserContext);
return useMemo(
() => (
<section>
<div>
<p>{user.name}</p>
<p>{user.address}</p>
</div>
</section>
),
[user]
);
}
为了避免性能开销,可以采取以下策略:
- 将应用程序状态的更新限制在尽可能小的区域内。这将减少有关上下文更改的通知。
- 将上下文对象中的数据尽可能的减少。上下文对象中的数据越多,需要更新的组件就越多。因此,只有在真正需要共享数据时才使用 Context。
- 将 Context 的数据拆分成多个部分。如果 Context 中的数据非常庞大,可以考虑将数据拆分成多个部分,分别使用不同的 Context 进行传递。这样可以避免在数据发生变化时重新渲染所有依赖该数据的组件。
- 使用
React.memo()
或shouldComponentUpdate()
来避免不必要的渲染。这些方法可以在组件的 props 发生变化时决定是否重新渲染组件。当组件依赖上下文数据时,可以使用useContext()
钩子来访问上下文数据,这样可以让 React 在仅当上下文数据发生变化时重新渲染组件。
2.3 模块化开发
模块化开发是种软件开发方法,通过将一个大型的系统或应用程序划分为多个独立、可重用的模块来提高开发效率和代码质量。每个模块都具有自己的功能和职责,并且能够与其他模块进行交互和组合,以实现系统的完整功能。
通过模块化开发,我们可以将复杂的系统拆分为多个相对独立的模块,每个模块负责特定的功能或业务逻辑。这样做有助于提高代码的可读性、可维护性和可测试性,降低代码的复杂性。此外,模块化开发还提供了更好的代码重用性,可以在不同的项目中复用已经开发和测试过的模块,减少了开发工作量和时间。
假设我们正在开发一个音乐播放器应用,其中包含了播放器控制模块 、音乐列表模块 和歌词显示模块 。如果我们不采用模块化开发,那么可能会将所有功能都写在一个文件中,导致代码冗长、难以维护。而采用模块化开发,我们可以将每个功能模块封装成独立的模块,从而实现高内聚低耦合的目标。具体的代码示例可能如下:
javascript
// 播放音乐
function play() {}
// 暂停音乐
function pause() {}
// 停止音乐
function stop() {}
export default { play, pause, stop }
javascript
// 加载音乐列表
function loadPlaylist() {}
// 添加歌曲到列表
function addSong(song) {}
// 从列表中移除歌曲
function removeSong(song) {}
export default { loadPlaylist, addSong, removeSong }
javascript
// 加载歌词
function loadLyrics(song) {}
// 显示歌词
function showLyrics(time) {}
export default { loadLyrics, showLyrics }
在上面的示例代码中,我们将播放器控制模块、音乐列表模块和歌词显示模块分别封装成了独立的模块,每个模块都有自己的功能和职责,并暴露了一些接口,供其他模块调用。在需要使用这些功能的地方,我们可以通过 import 引入相应的模块,然后调用其中的接口来实现相关的功能。
以React为例,下面是一个简单的业务场景示例,展示如何使用模块化开发:
javascript
// PlayerControls.js
import React from 'react';
const PlayerControls = ({ isPlaying, onPlay, onPause, onStop }) => {
return (
<div>
<button onClick={onPlay} disabled={isPlaying}>Play</button>
<button onClick={onPause} disabled={!isPlaying}>Pause</button>
<button onClick={onStop}>Stop</button>
</div>
);
};
export default PlayerControls;
javascript
// MusicList.js
import React from 'react';
const MusicList = ({ musicList, selectedMusic, onMusicSelect }) => {
return (
<ul>
{musicList.map((music, index) => (
<li key={index} className={selectedMusic === index ? 'selected' : ''} onClick={() => onMusicSelect(index)} >
<span>{music.title}</span>
<span>{music.artist}</span>
</li>
))}
</ul>
);
};
export default MusicList;
javascript
// LyricsDisplay.js
import React from 'react';
const LyricsDisplay = ({ lyrics }) => {
return (
<div>
{lyrics.map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
);
};
export default LyricsDisplay;
javascript
// PlayerPage.js
import React, { useState } from 'react';
import MusicList from './MusicList';
import LyricsDisplay from './LyricsDisplay';
import PlayerControls from './PlayerControls';
const PlayerPage = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [musicList, setMusicList] = useState([
{ title: 'Song 1', artist: 'Artist 1', url: 'song1.mp3', lyrics: ['Lyrics line 1', 'Lyrics line 2'] },
{ title: 'Song 2', artist: 'Artist 2', url: 'song2.mp3', lyrics: ['Lyrics line 1', 'Lyrics line 2', 'Lyrics line 3'] },
{ title: 'Song 3', artist: 'Artist 3', url: 'song3.mp3', lyrics: [] },
]);
const [selectedMusic, setSelectedMusic] = useState(null);
const handlePlay = () => {
setIsPlaying(true);
};
const handleStop = () => {
setIsPlaying(false);
setSelectedMusic(null);
};
const handlePause = () => {
setIsPlaying(false);
};
const handleMusicSelect = (index) => {
setIsPlaying(true);
setSelectedMusic(index);
};
const selectedMusicObj = selectedMusic !== null ? musicList[selectedMusic] : null;
const displayMusicLyrice = selectedMusicObj && selectedMusicObj.lyrics.length > 0
return (
<div>
{displayMusicLyrice && <LyricsDisplay lyrics={selectedMusicObj.lyrics} />}
<MusicList musicList={musicList} selectedMusic={selectedMusic} onMusicSelect={handleMusicSelect} />
<PlayerControls isPlaying={isPlaying} onPlay={handlePlay} onPause={handlePause} onStop={handleStop} />
</div>
);
};
export default PlayerPage;
在这个示例中,我们将播放器控制模块、音乐列表模块和歌词显示模块分别封装成了 PlayerControls
、MusicList
和 LyricsDisplay
三个独立的组件,并通过 props 传递数据和事件处理函数。在 PlayerPage
组件中,我们引入了这三个组件,并将它们组合在一起,从而实现了一个简单的音乐播放器应用。
需要注意的是,模块的大小并没有严格的限制,它可以是一个小的功能单元,也可以是一个更大的功能块。关键是模块要具备单一职责并且可以被独立使用和复用。我们来看一简单的代码案例。
javascript
function Container() {
return <div>Hello</div>;
}
function Entrance({ isRender }) {
return isRender ? <Container /> : null;
}
function UserInfoContext2() {
const isRender = false;
return (
<div>
<h1>User Information</h1>
<Entrance isRender={isRender} />
</div>
);
}
function UserInfoContext3() {
const isRender = true;
return (
<div>
<Entrance isRender={isRender} />
<h1>User Information</h1>
</div>
);
}
虽然 Entrance
函数的业务逻辑相对简单,但它仍然可以被视为一个模块,只要它具备以下特征:
- 可复用性:
Entrance
函数可以在不同的上下文中被复用,而不需要做大量的修改。 - 单一职责:
Entrance
函数只负责控制条件渲染,并不包含与其他业务逻辑强相关的代码。 - 可独立使用:
Entrance
函数可以作为一个独立的模块使用,不依赖于其他模块或组件。
这样的模块化设计可以提高代码的可维护性和可重用性,使得代码更易于理解和扩展。对于如何定义一个模块,通常有以下几个方面需要考虑:
- 可测试性 :模块应该具备良好的可测试性。一个模块应该能够独立地进行单元测试,而不需要依赖于其他模块或外部环境。模块的边界应该清晰定义,使得测试代码可以针对模块的特定功能进行编写和运行。
- 范围和职责:模块应该具备明确的功能和职责。一个模块可以涵盖一小部分的业务逻辑,只要它能够独立运作并解决特定的问题。模块的范围可以根据业务需求和代码组织的需要进行调整,可以是一个小的功能单元,也可以是一个更大的功能块。
- 可独立使用:一个模块应该是可独立使用的,不依赖于其他模块的具体实现细节。模块之间应该通过定义明确的接口进行通信,而不是直接依赖于其他模块的内部实现。
- 单一职责原则:模块应该遵循单一职责原则,即一个模块只负责一个明确的功能或解决一个具体的问题。这样做有助于模块的可理解性和可维护性,使得代码更易于测试、重用和扩展。
在实际开发中,是否将只负责一小部分的业务逻辑视为一个模块,取决于具体的项目需求和设计目标。在某些情况下,拆分业务逻辑到更小的模块可能会增加代码的灵活性和可维护性。然而,在其他情况下,可能更合适将多个相关的业务逻辑组合在一个模块中,以便更好地组织和管理代码。
React 中实现模块化开发需要注意以下几点:
- 在组件中使用 props 来传递数据和事件处理函数,避免组件之间的紧密耦合。
- 在使用第三方库或框架时,应该了解其提供的 API 和使用方式,并遵循其最佳实践。
- 尽量将功能单一的组件拆分成更小的组件,这有助于提高代码的可维护性和可重用性。
- 使用模块化的方式来组织代码,例如使用 ES6 的 import 和 export 语法来导入和导出组件和模块。
- 使用适当的命名约定来命名组件和文件,例如 PascalCase 命名约定来命名组件,具体案例后面会议介绍。
- 编写组件时,应该考虑组件的生命周期和状态管理,以便在不同的生命周期阶段和状态变化时执行相应的操作。
- 不要在组件内部直接修改 props,因为 props 应该被认为是只读的。如果需要修改 props 中的数据,应该通过父组件传递给子组件的回调函数来实现。
2.4 面向对象编程
面向对象编程(Object-Oriented Programming,简称 OOP)是一种常用的编程范式,它将现实世界中的事物抽象成一个个对象,并通过封装、继承和多态等特性来实现代码的高内聚低耦合。面向对象编程强调将数据和操作结合起来,通过创建对象、定义类和建立对象之间的关系来构建应用程序。
使用面向对象的编程方式,我们可以将复杂的系统划分为独立的对象,每个对象负责自己的特定功能和状态。对象之间通过消息传递来进行通信和交互,而不是直接操作彼此的内部数据。这种封装性和隔离性使得代码更加模块化,易于理解、扩展和维护。
以下是一个简单的业务场景,演示了如何使用面向对象编程来实现一个简单的学生信息管理系统:
javascript
class Student {
constructor(name, age) {
this.name = name;
this.age = age;
}
displayInfo() {
console.log(`Name: ${this.name}, Age: ${this.age}`);
}
}
javascript
class StudentManager {
constructor() {
this.students = [];
}
addStudent(student) {
this.students.push(student);
}
removeStudent(student) {
const index = this.students.indexOf(student);
if (index !== -1) this.students.splice(index, 1);
}
displayAllStudents() {
console.log('All Students:');
this.students.forEach((student) => student.displayInfo());
}
}
ini
// 创建学生管理器对象
const studentManager = new StudentManager();
// 添加学生
const student1 = new Student('Brycen 1', 18);
const student2 = new Student('Brycen 2', 18);
studentManager.addStudent(student1);
studentManager.addStudent(student2);
// 显示所有学生信息
studentManager.displayAllStudents();
// 删除学生
studentManager.removeStudent(student1);
// 再次显示所有学生信息
studentManager.displayAllStudents();
在这个示例中,我们定义了两个类 Student
和 StudentManager
,分别表示学生和学生管理器对象。每个学生对象都有自己的属性和方法,例如 name
、age
以及 displayInfo()
方法显示学生的详细信息。学生管理器对象则包括一个学生数组,以及 addStudent()
、removeStudent()
和 displayAllStudents()
等方法来添加、删除和显示学生信息。
通过使用 React,我们可以将学生信息管理系统表示为一个简单的组件树。每个组件都只关注自己的属性和方法,不需要关心其他组件的实现细节。下面是基于 React 实现了面向对象编程示例类似的功能示例:
javascript
// Student.js
import React from 'react';
import PropTypes from 'prop-types';
const Student = (props) => {
const { name, age } = props;
return (
<div>
<p>姓名: {name}</p>
<p>年龄: {age}</p>
</div>
);
};
export default Student;
javascript
// StudentManager.js
import React, { useState } from 'react';
import Student from './Student';
const StudentManager = () => {
const [students, setStudents] = useState([]);
const handleAddStudent = () => {
const newStudent = { name: 'Brycen', age: 18 };
setStudents([...students, newStudent]);
};
const handleRemoveStudent = (index) => {
setStudents((prevStudents) => {
const newStudents = [...prevStudents];
newStudents.splice(index, 1);
return newStudents;
});
};
const displayAllStudents = () => {
students.forEach((student) => console.log(student));
};
return (
<div>
<button onClick={handleAddStudent}>Add Student</button>
<button onClick={displayAllStudents}>Display All Students</button>
{students.map((student, index) => (
<div key={index}>
<Student {...student} />
<button onClick={() => handleRemoveStudent(index)}>Remove</button>
</div>
))}
</div>
);
};
export default StudentManager;
示例中,我们定义了Student
和StudentManager
组件来表示学生和学生管理器。我们使用useState
钩子来管理学生数组,handleAddStudent()
方法用于添加新的学生,handleRemoveStudent()
方法用于删除学生,displayAllStudents()
方法用于显示所有学生的信息。我们还使用了 map() 方法来遍历学生数组,并在每个学生对象上渲染 Student 组件,以便显示学生的详细信息和删除按钮。
在 React 中实现面向对象编程时,有一些需要注意的事项,以下是一些常见的注意事项:
- 用类定义组件:在 React 中,组件可以使用函数或类来定义。如果你想使用面向对象编程的思想来组织你的代码,建议使用类来定义组件,因为类具有封装和继承的特性,更适合于面向对象编程的思想。
- 遵循单一职责原则:在面向对象编程中,一个类应该只有一个职责,即只负责一个功能。同样地,在 React 中,一个组件也应该只有一个职责,只负责一个功能。
- 封装组件状态和方法:面向对象编程强调封装性,组件的状态和方法也应该被封装起来,以避免组件内部状态的混乱和不可预测性。使用 state 和 props 来封装组件的状态和方法,让组件具有更好的可维护性和可重用性。
2.5 面向接口编程
面向接口编程(Interface-Oriented Programming,简称IOP)也是一种编程范式。接口被视为对象之间进行交互和通信的契约,它定义了对象应该具有的方法、属性或行为。通过使用接口,我们可以定义一组共享的规范和约束,使得不同的对象可以按照这些规范进行交互,而不关心具体的实现细节。
实际上,面向接口编程可以作为面向对象编程的一种补充和扩展,面向接口编程的关键思想是依赖于接口而不依赖于具体的实现。通过编写面向接口的代码,我们可以将关注点从具体实现转移到接口的定义上,从而降低对象之间的耦合度。以下是一个简单的代码案例:
typescript
// 定义一个接口
interface ILogger {
log(message: string): void;
}
// 实现接口的具体类
class FileLogger implements ILogger {
log(message: string) {
// 将日志写入文件的具体实现
console.log(`[File logger] ${message}`);
}
}
class ConsoleLogger implements ILogger {
log(message: string) {
console.log(`[Console logger] ${message}`);
}
}
// 使用接口作为参数类型
function processLogger(logger: ILogger, message: string) {
logger.log(message);
}
// 创建具体的日志记录器实例
const fileLogger = new FileLogger();
const consoleLogger = new ConsoleLogger();
// 调用函数,传入不同的日志记录器
processLogger(fileLogger, "Error occurred.");
processLogger(consoleLogger, "Hello, World!");
其实说到底就是,我们开发时候多写 TS 声明,而不是面向 any 开发🤡
2.6 统一命名规范和代码风格
"统一命名规范和代码风格"是指在前端开发中,为了提高代码的可读性和可维护性,需要制定一套统一的命名规范和代码风格,以便于团队成员之间快速地理解和修改彼此的代码。
这个规范,我们可以参照业界比较认可的 BEM(Block-Element-Modifier)命名规范,它是一种常用的CSS类命名规范,用于创建可维护和可复用的样式代码。以下是BEM命名规范的基本原则和示例:
- 块(Block):代表一个独立的可重用的组件或模块。块应该具有描述性的名称,使用连字符(-)分隔单词。例如,一个导航栏可以被命名为 "navbar"。
xml
<div class="navbar"><!-- content --></div>
- 元素(Element):块内部的组成部分,只在特定块的上下文中有意义。元素的命名应该使用双下划线(_)来与块进行分隔。例如,导航栏中的菜单项可以被命名为 "navbar_item"。
ini
<div class="navbar">
<a class="navbar__item" href="#">Home</a>
<a class="navbar__item" href="#">About</a>
<a class="navbar__item" href="#">Contact</a>
</div>
- 修饰符(Modifier):用于修改块或元素的外观、状态或行为。修饰符的命名应该使用单下划线(_)来与块或元素进行分隔。例如,导航项的激活状态可以有一个修饰符 "navbar__item--active"。
ini
<div class="navbar">
<a class="navbar__item navbar__item--active" href="#">Home</a>
<a class="navbar__item" href="#">About</a>
<a class="navbar__item" href="#">Contact</a>
</div>
使用 BEM 命名规范可以提高样式的可读性和可维护性。它帮助开发者清晰地理解组件之间的关系,并减少样式冲突的可能性。但这种命名规范与 CSS Modules 结合时会存在一些冲突,为了解决样式命名的可读性与关联性,在 CSS Modules 中,通常使用下划线( )来连接单词,因此与BEM中使用的双下划线(__)进行元素分隔会导致命名冲突。结合实际情况出发,我将块与元素改为使用下划线( )来连接单词。
以一个简单的业务场景为例,假设我们正在开发一个电商网站,它包含一个商品列表和一个购物车组件。为了遵循统一的命名规范和代码风格,我们可以按照以下方式编写代码:
javascript
import React, { memo } from 'react';
import styles from './styles.module.css';
import ProductItem from './ProductItem';
function ProductsList(props) {
const { products, onAddToCart } = props;
return (
<div className={styles.products}>
<h2 className={styles.products_title}>Products List</h2>
<ul className={styles.products_list}>
{products.map((product) => (
<ProductItem key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</ul>
</div>
);
}
export default memo(ProductsList);
这个示例中,将组件的类名分为两个部分:块(products)、元素(title\list)。块表示组件的名称,元素表示组件内部的子元素,修饰符表示组件的状态或变化。同时,我们使用单一职责原则,将商品列表的显示 和商品项的显示分别封装成了两个组件。
javascript
import React, { memo } from 'react';
import styles from './styles.module.css';
function ProductItem(props) {
const { product, onAddToCart } = props;
const handleAddToCart = () => {
onAddToCart(product);
};
return (
<li className={styles.products_item}>
<div className={styles.products_item__header}>
<div className={styles.item_header__name}>
{product.name}
</div>
<div className={styles.item_header__date}>
{product.date}
</div>
</div>
<div className={styles.products_item__description}>{product.description}</div>
<button className={styles.products_item__button} onClick={handleAddToCart}>Add to Cart</button>
</li>
);
}
export default memo(ProductItem);
这个示例中,商品项的类名还是分为两个部分:块(products)、元素(item\header)、修饰符(header\name\date\description\button)。同时,我们使用单一职责原则,将商品项的显示 和添加到购物车的逻辑分别封装成了两个组件。
接下来我们还需要统一注释的风格以提高代码的可读性和可维护性。对于注释的规范,我们应该遵循以下原则:
- 外部的变量或函数应该使用块注释(/ * ... * /)来进行注释。这种注释风格通常用于对外部可见的代码元素,如暴露的接口、全局变量等。块注释可以提供清晰的注释结构和易于识别的标记。
- 内部的变量或函数可以根据情况而定,可以使用单行注释(//)或块注释(/ * ... * /)。对于内部的代码块,可以根据需要选择适当的注释方式。单行注释适合简短的注释,块注释适合多行的详细注释。
- 注释的目的是为了解释业务场景、代码逻辑或特定的实现细节,以提高代码的可读性和理解性。因此,我们应该避免添加无效注释,而将注释的焦点放在有意义的方面。
ini
【优化前】
// 是批量分享
const isBatchSharing = !meetingId;
// 禁用权限
const disabled = isBatchSharing && !isVip;
// 是主持人
const isHost = isBatchSharing || meetingRole === MEETING_ROLES.host;
【优化后】
/** 如果会议ID不存在,则说明是批量分享 */
const isBatchSharing = !meetingId;
/** 有主持人权限 - 对于批量分享,只有操作自己的会议或者在会议详情页中担任主持人角色的用户才有操作权限 */
const hasHostPermission = isBatchSharing || meetingRole === MEETING_ROLES.host;
/** 禁用操作权限 - 当批量分享且用户不是VIP时,禁用分享操作,要求用户升级为VIP */
const disabledPermission = isBatchSharing && !isVip;
/** 📌 监听模态框的开启事件 */
const listenOpenDialogEvt = useMemoizedFn((e) => {
const { meetingIds, calendarInvitationList } = e.detail;
// 开启弹窗
setOpen(true);
// 分享的会议ID列表
setShareMeetingIds(meetingIds);
// 组装可邀请的日历邮箱 - 批量分享不会拉取具体会议的日历事件,因此不存在可邀请的日历邮箱集,故默认取空数组
setCalendarInviteList(calendarInvitationList?.map((email) => ({ email })) || []);
});
在进行代码走查时,我们应该检查注释的格式和位置,确保注释准确描述了代码的意图和功能。另外,注释应该是清晰、简明的,避免过多的技术细节或无关信息,以提高代码的可读性。
通过统一命名规范和代码风格,我们可以让代码更加清晰、易于阅读和维护。团队中的每个成员都可以遵循相同的规范和风格,从而提高协作和合作的效率,减少代码冲突和出错的机会。
2.7 定期进行代码重构和优化
为了保证代码的质量和可维护性,我们需要定期进行代码重构和优化。代码重构 是指在不改变代码功能的情况下,通过改进代码结构、提高代码质量等方式来提高代码可读性和可维护性的过程。代码优化则是指通过改进代码性能、减少资源消耗等方式来提高代码质量的过程。
我们很容易局限在当时的业务场景、能力等,导致编写出不够优秀的代码,随着需求不断地迭代会暴露出越来越多的问题。因此,我们需要保持警觉并识别出可以进行代码重构和优化的机会。一旦有机会,我们应该针对代码中的问题和局限性进行改进,以提高代码的质量、可读性、可维护性和性能,并为未来的需求和变化做好准备。
三、开发前的准备工作
在前端开发时,走查UI设计结构是非常重要的工作,可以帮助开发者及时发现问题并及时解决,提高项目的整体质量。因此,在开发前的准备工作中,前端开发者需要仔细分析业务需求和设计原型稿,以了解业务逻辑和UI设计的具体要求,包括页面的结构、元素、交互、动效、响应式设计 等方面的内容,同时还需要关注需求的不确定性或不合理性等问题。如果开发者发现自己对需求或原型的理解与产品或设计的期望不一致时,应及时与相关人员沟通并确认最终结果。这样做可以确保开发过程中符合业务需求和设计原型的要求,同时提高项目的整体质量。
3.1 确认原型图的布局结构
确认页面的基本结构和元素后,需要进一步确定页面的布局结构。这包括页面容器的布局方式、网格系统的使用、各个容器之间的关系等。在确定布局结构时,需要考虑页面的层次结构、内容分布、可访问性、可维护性以及响应式设计等因素。同时,也需要注意页面的美观性和用户体验。
为了确保页面的可维护性和可扩展性,应该遵循一定的设计规范和标准,例如使用语义化的HTML标签、避免嵌套过深的标签、采用模块化的CSS和JavaScript等。此外,还应该考虑到不同设备的显示效果,为此可以采用响应式设计的方式来适应不同的设备和屏幕尺寸。通过确认原型图的布局结构,可以确保页面的基本框架和结构符合设计要求,为后续的开发工作提供基础支持,同时也可以优化用户体验和提高页面性能。
3.2 分析功能的交互和动效
接下来我们再进一步分析页面的功能交互和动效。这包括页面元素的交互方式、动画效果、过渡效果 等方面。你需要理解并记录下这些交互和动效的具体要求,例如按钮点击效果、表单验证效果、页面切换效果、下拉菜单效果等。同时,也需要考虑交互和动效对用户体验 和性能的影响,确保页面的交互流畅和性能优化。
在设计交互和动效时,应该遵循一定的设计原则和规范,例如避免过度使用动画效果、采用流畅自然的过渡效果、考虑到不同设备和网络环境的性能限制等。此外,还应该考虑到不同用户的需求和习惯,为此可以进行用户调研和测试,以确保交互和动效的设计符合用户的期望和需求。通过分析功能的交互和动效,可以为页面增添更多的交互性和趣味性,提高用户的使用体验和满意度,同时也可以让页面更具有吸引力和竞争力。
3.3 确认需求设计的合理性
在分析页面的各个方面要求后,你需要再次审视业务需求和设计原型,确认是否存在需求的不确定性或不合理性。如果发现这些问题,需要及时与团队和相关人员保持良好的沟通和协作,包括与产品经理、UI/UX设计师和后端开发人员等进行沟通,确保各个方面的需求和设计都被充分理解和考虑。同时,如有必要需要与团队成员时沟通和解决问题。这可以提高项目的整体效率和质量,确保开发过程中符合业务需求和设计原型的要求,同时也能够及时调整设计和开发计划,以便更好地满足项目的需求。
此外,在确认需求设计的合理性时,也应该考虑到可扩展性和可维护性等因素,例如在设计数据结构和接口时,需要考虑到后续的需求变化和数据处理的复杂性,采用合适的数据结构和接口设计,可以提高代码的可扩展性和可维护性,降低后续开发和维护的成本。除此之外,还需要关注代码的可读性和可测试性,采用清晰明了的代码结构和注释,可以提高代码的可读性和维护性。
3.4 业务组件的拆分和组合
在确认页面的基本结构和元素,并分析页面的功能交互和动效之后,你需要将相似的功能模块划分为一个独立的组件,使得每个组件的职责更加明确。这需要考虑组件的可复用性、可维护性和可扩展性,以及组件之间的关系和依赖。同时,也需要根据项目的需求和设计原型来确定组件的具体实现方式和样式。
通过分析UI设计原型的布局结构,你可以确定哪些元素应该被拆分成组件,并根据组件之间的关系和依赖来组合它们。在实际开发中,你可以使用现有的组件库或自己编写组件来实现业务需求,同时也需要进行充分的测试和调试,确保组件在不同场景下的兼容性和稳定性。划分业务组件可以帮助开发者更好地组织代码和资源,提高代码的可读性和可维护性,同时也可以减少重复代码和提高开发效率。
假设有一个电商网站,其中有一个商品列表页面 和一个购物列表页面。
商品列表页面中,你可以拆分出筛选组件 、排序组件 、商品列表组件 和商品列表项组件 。筛选组件 包括价格区间、品牌、分类等选项。排序组件 包括价格、销量、评价等排序方式。商品列表组件 包括商品列表、分页等。商品列表项组件包括商品名称、价格、图片等;
购物列表页面中,你可以拆分出购物车商品列表组件 和购物车商品项组件 。购物车商品列表组件 包括购物车商品列表、总价、结算等。购物车商品项组件包括商品名称、价格、数量、小计等。
这样一来,每个组件都可以在不同页面中重复使用,并且可以根据需要进行修改和扩展,而不会影响整个应用程序,例如商品列表页中的筛选和排序组件也可以用到购物列表页中。通过划分业务组件,你可以更好地组织代码和资源,提高代码的可读性和可维护性,同时也可以减少重复代码和提高开发效率。
3.5 使用 React 框架的特性
划分出业务组件后,可以根据业务需求和组件的功能,选择合适的 React 特性和技术来实现这些组件。实现业务的过程中,需要考虑以下几个方面:
-
组件的可复用性和可维护性
React 是一个组件化的框架,组件的可复用性和可维护性非常重要。在组件设计时,应该尽可能地避免组件之间的耦合,并将通用的逻辑抽象出来,封装成可复用的 Hook 或工具函数,以减少代码冗余。为了实现组件的可复用性和可维护性,可以将组件分为容器组件、UI组件和业务组件,这样可以使得组件的职责更加清晰明了,便于组件的维护和扩展。
- 容器组件:负责管理组件的状态和逻辑,通常是使用类组件实现。容器组件通过 props 将状态和逻辑传递给 UI 组件和业务逻辑组件,以实现UI、状态和业务逻辑的分离。容器组件一般不包含任何UI渲染逻辑,而是通过 props 将数据和事件处理函数传递给UI组件和业务逻辑组件。
- 业务组件**:**负责实现具体的业务功能,通常包含一些方法和函数。业务逻辑组件通过 props 接收容器组件传递的状态和逻辑数据,并对这些数据进行处理和操作,最终将处理后的数据传递给 UI 组件进行渲染。
- UI 组件**:**负责将容器组件和业务组件提供的数据或事件处理函数,转化为用户可见的UI展示。它们通常不包含业务逻辑,而是专注于UI展示和交互。
通过将组件分为容器组件、业务组件和UI组件,可以使得组件的职责更加清晰明了,便于组件的维护和扩展,同时可以提高组件的可复用性,以便于在不同的场景中进行复用。在实际开发中,可以根据具体的场景和需求,选择合适的组件类型进行组合和设计。
-
组件的数据流设计
React 中的数据流是单向的,从父组件流向子组件。在实现业务组件时,需要考虑组件所需的数据和状态,以及数据流的设计。为了避免状态散落在各个组件中,应该将状态尽可能地集中管理,使得数据流清晰明了。这样可以更方便地进行状态管理和测试,并且便于组件的拆分和复用。
如果需要在组件之间共享状态,可以使用 React 的
Context
或Redux
等状态管理库。Context
是一种跨组件层级共享数据的机制,可以将数据传递给组件树中的任何一个组件,而不必通过 props 一级一级地传递。但是,Context 不适合所有情况,因为它会破坏 React 的单向数据流模型,使得数据流变得不可预测。如果应用程序的状态较为复杂,可以考虑使用Redux等状态管理库。Redux 是一个可预测的状态容器,可以将应用程序的状态集中管理,并提供了一套完整的状态管理方案,包括状态更新、异步处理、中间件等。
按照正常的顺序来说,我们应该先查阅需求文档,对本次需求的整体目标和功能有清晰的理解之后,才应该去看设计的UI原型图。我个人其实更喜欢先看UI原型图,这样子我再看需求文档时就会有一个清晰的样子,可以更容易地理解需求🤡
总结
综上所述,遵循"高内聚低耦合"的开发原则,通过在开发前进行充分的沟通、分析和拆解,我们可以设计出负责不同职责的业务组件,并建立良好结构的数据流。这样做有助于构建出具备高可读性、易于维护和扩展的业务组件,从而提升项目的整体质量和开发效率。
创作团队
作者:Brycen
校对:Yuki