代码风格和变量命名问题
1. 过于依赖三元选择符
代码语言:txt复制{curTab === TABSTYPE.ORDERSONG ? (
<SongSheet />
) : curTab === TABSTYPE.PLAYSONG ? (
<PlayList />
) : curTab === TABSTYPE.SEARCHSONG ? (
<Search searchKey={searchKey} />
) : null}
这样连续使用三元选择符并不利于理解,并且如果有更多的类型,会导致过长的三元判断,可以使用map
替换:
const renderComponent = {
[TABSTYPE.ORDERSONG]: <SongSheet />,
[TABSTYPE.PLAYSONG]: <PlayList />,
[TABSTYPE.SEARCHSONG]: <Search searchKey={searchKey} />,
},
return renderComponent[curTab];
2. 变量命名问题
代码语言:txt复制<div>
{
listA.map(item => {
return (
<ul>
{
listB[item.id].map(item => {
return <p>{item.name}</p>
})
}
</ul>
)
})
}
</div>
上述是经过简化后的代码,最重要的问题就是在嵌套的循环中,都使用item
作为当前循环项的变量名,虽然因为作用域的问题,上述代码可以正常执行,但是对于其它同学的阅读或者维护的话是很容易出错的,最好是每个循环都使用含义明确的单词作为当前项的变量名。
SOLID
SOLID
原则大家都很熟悉了:
- 单一职责原则(SRP)
- 开放封闭原则(OCP)
- 里氏替换原则(LSP)
- 接口隔离原则(ISP)
- 依赖倒置原则(DIP)
我们看一下这些原则如何在前端项目中实践
单一职责原则(SRP)
单一职责原则的定义是每个类应该只有一个职责, 也就是只做一件事。这个原则是最容易解释的,因为我们可以简单地将其理解为 “每个功能 / 模块 / 组件都应该只做一件事”。
这是最容易遵守但是在开发过程中又很容易忽略的一项,很多时候的开发为了尽快交付,就将很多逻辑放在同一个文件中,甚至出现一个页面只有一个文件的情况,虽然在开发过程中,这样做减少了组件拆分和组件间通信的工作量,但是对于组件拆分粒度不够的话,后期维护是很难受的。组件粒度不够细可以分为两个方面:过长的模板和过多的逻辑代码
过长的模板
代码语言:txt复制export const PlayList: React.FC = memo(() => {
const { baseInfo, playList, settings, isOwner } = useSelector((state: AppStore) => ({
baseInfo: state.pages.baseInfo,
playList: state.song.playList || [],
settings: state.song.settings,
isOwner: state.pages.isOwner,
}));
const { interactComponentInfo } = useContext(RoomContext);
const dispatch = useDispatch();
const handlePlayType = (type: emSongPlayType) => {
// API请求的逻辑
};
const handleDelAllSong = () => {
// API请求的逻辑
};
return (
<>
<div className="song_top">
<h4 className="song_top__name">已点歌单</h4>
{settings?.iSongPlayType === emSongPlayType.EM_KTV_SONG_PLAY_TYPE_PLAY_IN_ORDER ? (
<TouchableOpacity
onPress={() => {
handlePlayType(emSongPlayType.EM_KTV_SONG_PLAY_TYPE_RANDOM_IN_ORDER);
}}
>
<span className="song_top__item">
<svg className="icon_order icon_svg__m">
<use xlinkHref="#svg_order" />
</svg>
<span>顺序播放</span>
</span>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
handlePlayType(emSongPlayType.EM_KTV_SONG_PLAY_TYPE_PLAY_IN_ORDER);
}}
>
<span className="song_top__item">
<svg className="icon_shuffle icon_svg__m">
<use xlinkHref="#svg_shuffle" />
</svg>
<span>随机播放</span>
</span>
</TouchableOpacity>
)}
{playList.length && isOwner ? (
<TouchableOpacity
onPress={() => {
handleDelAllSong();
}}
>
<span className="song_top__item">
<svg className="icon_delete_line icon_svg__m">
<use xlinkHref="#svg_delete_line" />
</svg>
<span>一键清空</span>
</span>
</TouchableOpacity>
) : null}
</div>
<div className="pop_song__bd">
{playList.length ? (
<ul className="qui_media qui_media__song">
{playList.map((item, index) => {
return <PlayItem key={item.strPlaySongId} item={item} index={index} total={playList.length} />;
})}
</ul>
) : (
<Empty />
)}
</div>
</>
);
});
这个文件在调整前其实也只有不到120行,不算是一个庞然大物,但是仅从return
的tsx
来看已经能看出一些问题:
1. L20-L46,可以看出这里是想做一个“顺序播放”和“随机播放”的播放类型切换按钮,但是这里却将相同的结构写了两遍;
2. 将播放类型按钮和一键清空按钮的逻辑都放在了一起,如果之后需要加其它的功能按钮,还将代码向这个文件中堆的话,最终这个文件将会膨胀成几百上千行而极度难以维护;
3. 第三点是现在有一个需求,当父组件的状态为只读时,需要把这个播放类型按钮直接隐藏,如果直接在当前代码上改,那只能是在L20和L46外再包一层判断;虽然很简单,但是代码的可读性又会差一些;而且如果之后还有更多状态判断,这里的逻辑会继续膨胀
因此,对于这个文件,可以将各个功能按钮分别拆分到各自的组件中;例如将播放类型按钮及相关逻辑拆到PlayMode
组件中,一键清空按钮及相关逻辑拆到ClearList
组件中,虽然代码行数相加之后达到了147行,比拆分之前多了20 行;这其实也是可以理解的,因为在这三个文件中,会有相同的import
代码,以及因为使用到了相同的store
变量从而在各自的组件中都需要引入,这样的代码量增加对于更大的文件来说可能会更明显,但我们用一定量的代码行数增加换来的却是更清晰的代码结构和更易维护修改的逻辑,那么这也是值得的。
过长的逻辑
对于一些交互很重的页面,不知不觉就会把逻辑都堆在入口页面,即便将页面中的一些显而易见的组件拆了出去,但是入口页面仍然可能堆积了很多例如接口请求,事件注册等逻辑。
对于这个问题,建议写代码时时刻牢记“单一职责原则”,无论是哪个文件,都应该只做一件事;建议从以下几个角度考虑:
- 将功能较多的大型组件拆分为较小的组件;
- 将与组件功能无关的代码或功能独立的代码提取到单独的函数中;
- 将有联系的功能提取到自定义 Hooks 中。
第一点很好理解,例如:
代码语言:txt复制return baseInfo ? (
<Provider value={{ interactComponentInfo, setInteractComponentInfo }}>
<RoomLayer ref={roomLayerRef} />
{/* 头部标题栏 */}
<Header generateSnapshot={generateSnapshot} />
{/* 播放歌曲miniBar */}
<SingMiniBar />
{/* 操作bar */}
<ToolBar />
{/* 语音列表 */}
<PlayerVoiceList />
{/* 点歌 */}
<OrderSong />
{/* 更多面板 */}
<MorePanel />
{/* 房间成员 */}
<MemberPanel />
{/* 申请人员 */}
<ApplyUserList />
{/* 好友列表 */}
<FriendList />
{/* 表情面板 */}
<EmojiPanel />
{/* 房间设置 */}
{isOwner ? <RoomSettings /> : null}
{/* 对话列表 */}
<ChatList />
{/* 房间内弹幕 */}
<Danmaku />
{/* 性能面板 */}
<PerformanceSettings />
<ComponentLoader condition={showPage.target === ShowPage.Gift}>
<Gift />
</ComponentLoader>
</Provider>
) : null;
在这个页面中,功能很多,每个功能组件都被拆分出去独立管理,并且这些组件中会继续拆分成更细粒度的组件。
后两点很容易被忽略,也是我们项目代码迅速膨胀的最重要的原因,例如一个列表页:
代码语言:txt复制const RoomList = () => {
const [roomList, setRoomList] = useState([]);
useEffect(() => {
const requestList = async () => {
const response = await fetch('/some-api')
setRoomList(response)
}
requestList()
}, [])
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return (
<ul>
{roomList.filter(room => !room.isOfficial && room.lastActivityAt <= weekAgo ).map(room =>
<li key={room.id}>
<img src={room.roomBackground} />
<p>{room.title}</p>
<small>{room.online}</small>
</li>
)}
</ul>
)
}
这个组件看上去内容不多,但仍然做了不少事情:获取数据、过滤数据、渲染数据等。
首先只要同时使用了useState
和useEffect
,就可以将其提取到自定义hook中:
// 获取数据
const useRequestList = () => {
const [roomList, setRoomList] = useState([]);
useEffect(() => {
const requestList = async () => {
const response = await fetch('/some-api')
setRoomList(response)
}
requestList()
return { roomList }
}, [])
}
// 业务组件
const RoomList = () => {
const { roomList } = useRequestList();
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return (
<ul>
{roomList.filter(room => !room.isOfficial && room.lastActivityAt <= weekAgo ).map(room =>
<li key={room.id}>
<img src={room.roomBackground} />
<p>{room.title}</p>
<small>{room.online}</small>
</li>
)}
</ul>
)
}
现在useRequestList
只关心获取数据。在组件return的模板里,我们看到先做了一次过滤过滤,然后再去遍历渲染,在代码量少的情况下这样做问题不大;但在我们的项目中,即便是模板代码,也很容易就到一百行甚至更多的量,因此对于模板来说,也建议提取到单独的组件中维护:
// 模板
const RoomItem = ({ room }) => {
return (
<li>
<img src={room.roomBackground} />
<p>{room.title}</p>
<small>{room.online}</small>
</li>
)
}
// 业务组件
const RoomList = () => {
const { roomList } = useRequestList();
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return (
<ul>
{roomList.filter((room, index) => !room.isOfficial && room.lastActivityAt <= weekAgo ).map(room =>
<RoomItem key={index} room={room}}
)}
</ul>
)
}
在上述业务组件中对数据的过滤其实也是相对独立的逻辑,可以在单独的函数中处理:
代码语言:txt复制// 业务组件
const processRoomList = (roomList) => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return roomList.filter((room, index) => !room.isOfficial && room.lastActivityAt <= weekAgo )
}
const RoomList = () => {
const { roomList } = useRequestList();
return (
<ul>
{processRoomList(roomList).map(room => <RoomItem key={index} room={room}})}
</ul>
)
}
现在代码比较简单了,但对于业务组件来说,只需要获取数据和渲染数据,其它的逻辑可以在hook中处理,最终,我们的代码变成了:
代码语言:txt复制// 获取数据
const useRequestList = () => {
const [roomList, setRoomList] = useState([]);
useEffect(() => {
const requestList = async () => {
const response = await fetch('/some-api')
setRoomList(response)
}
requestList()
return { roomList }
}, [])
}
// 模板
const RoomItem = ({ room }) => {
return (
<li>
<img src={room.roomBackground} />
<p>{room.title}</p>
<small>{room.online}</small>
</li>
)
}
// 过滤活跃的房间
const processRoomList = (roomList) => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return roomList.filter((room, index) => !room.isOfficial && room.lastActivityAt <= weekAgo )
}
const useRoomList = () => {
const { roomList } = useRequestList();
const activeRoom = useMemeo(() => {
if (!roomList) return [];
return processRoomList(roomList);
}, [roomList]);
}
// 业务组件
const RoomList = () => {
const { roomList } = useRoomList();
return (
<ul>
{roomList.map(room => <RoomItem key={index} room={room}})}
</ul>
)
}
最终,这个简单的组件被拆分成了多个功能独立的hooks和子组件,整体逻辑更为清晰,对之后的扩展和增加功能也更好兼容。
开放封闭原则(OCP)
开放封闭原则指出 “一个软件实体(类、模块、函数)应该对扩展开放,对修改关闭”。开放封闭原则主张以一种允许在不更改源代码的情况下扩展组件的方式来构造组件。
在一个场景中,Header
组件是公共的,但是右侧的操作是不同的:
const Header = () => {
const { pathname } = useRouter()
return (
<header>
<Logo />
<Actions>
{pathname === '/dashboard' && <Link to="/events/new">Create event</Link>}
{pathname === '/' && <Link to="/dashboard">Go to dashboard</Link>}
</Actions>
</header>
)
}
const HomePage = () => (
<>
<Header />
<OtherHomeStuff />
</>
)
const DashboardPage = () => (
<>
<Header />
<OtherDashboardStuff />
</>
)
现在又新增了一个页面,这个页面在使用Header
组件时,右侧的操作又不一样了,可能是没有Actions
,此时在引用Header
组件时还需要对其内部实现做修改。这样的动作会使Header
组件与其使用的上下文紧密耦合并且违背了开放封闭原则。
其实,Header
组件并不关心Actions
渲染什么,可以将其抛给调用方自定义:
const Header = ({ children }) => (
<header>
<Logo />
<Actions>
{children}
</Actions>
</header>
)
const HomePage = () => (
<>
<Header>
<Link to="/dashboard">Go to dashboard</Link>
</Header>
<OtherHomeStuff />
</>
)
const DashboardPage = () => (
<>
<Header>
<Link to="/events/new">Create event</Link>
</Header>
<OtherDashboardStuff />
</>
)
这样修改后,Header
内部就不存在耦合的变量逻辑,可以随意组合使用组件并且不会改到内部逻辑。
遵循开放封闭原则,可以减少组件之间的耦合,使它们更具可扩展性和可重用性。
里氏替换原则(LSP)
里氏替换原则可以理解为对象之间的一种关系,子类型对象应该可以替换为超类型对象。这个原则严重依赖类继承来定义超类型和子类型关系,但它在 React 中可能不太适用,因为我们几乎不会处理类,更不用说类继承了。虽然远离类继承会不可避免地将这一原则转变为完全不同的东西,但使用继承编写 React 代码会使代码变得糟糕(React 团队不推荐使用继承)。
接口隔离原则(ISP)
根据接口隔离原则的说法,客户端不应该依赖它不需要的接口。
假设有一个视频列表组件:
代码语言:txt复制 type Video = {
title: string duration: number coverUrl: string }
type Props = {
items: Array<Video>
}
const VideoList = ({ items }) => {
return (
<ul>
{items.map(item =>
<Thumbnail
key={item.title}
video={item}
/>
)}
</ul>
)
}
// Thumbnail
type Props = {
video: Video
}
const Thumbnail = ({ video }: Props) => {
return <img src={video.coverUrl} />
}
Thumbnail
很简单,但也有一个问题:它的参数是完整的Video
对象,但在内部却只使用了coverUrl
一个字段。如果想在直播列表中也复用VideoList
组件,而直播定义了LiveStream
类型:
type LiveStream = {
name: string previewUrl: string }
在更新VideoList
组件之后:
type Props = {
items: Array<Video | LiveStream>
}
const VideoList = ({ items }) => {
return (
<ul>
{items.map(item => {
if ('coverUrl' in item) {
return <Thumbnail video={item} />
} else {
// 直播组件,该怎么写?
}
})}
</ul>
)
}
直播组件想调用Thumbnail
就很困难了,因为Video
和LiveStream
不兼容,他们使用不同的字段来保存缩略图。Thumbnail
的参数设计导致组件难以复用。下面对Thumbnail
进行重构:
type Props = {
coverUrl: string
}
const Thumbnail = ({ coverUrl }: Props) => {
return <img src={coverUrl} />
}
这样,VideoList
在调用Thumbnail
的时候也很方便了:
type Props = {
items: Array<Video | LiveStream>
}
const VideoList = ({ items }) => {
return (
<ul>
{items.map(item => (
<Thumbnail coverUrl={'coverUrl' in item ? item.coverUrl : item.previewUrl} />
))}
</ul>
)
}
接口隔离原则主张最小化系统组件之间的依赖关系,使它们的耦合度降低,从而提高可重用性。
依赖倒置原则(DIP)
依赖倒置原则指出 “要依赖于抽象,不要依赖于具体”。换句话说,一个组件不应该直接依赖于另一个组件,而是它们都应该依赖于一些共同的抽象。这里,“组件” 是指应用程序的任何部分,可以是 React 组件、函数、模块或第三方库。
有一个LoginForm
组件,在提交表单时间用户数据发送到API:
import api from '~/common/api'
const LoginForm = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (evt) => {
evt.preventDefault()
await api.login(email, password)
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit">Log in</button>
</form>
)
}
LoginForm
组件直接引用了api
模块,因此二者之间是紧密耦合的。这种依赖关系会导致其中一个组件的更改会影响到另一个组件,因此需要打破这种耦合:
首先从LoginForm
组件中删除对api
模块的直接引用,而是通过props
传入需要的回调函数:
type Props = {
onSubmit: (email: string, password: string) => Promise<void>
}
const LoginForm = ({ onSubmit }: Props) => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (evt) => {
evt.preventDefault()
await onSubmit(email, password)
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit">Log in</button>
</form>
)
}
LoginForm
不再依赖于api
模块,向接口提交数据的逻辑是通过onSubmit
参数抽象出来的,由调用方决定该逻辑的具体实现。
同时,还可以创建一个ConnectedLoginForm
组件将表单提交逻辑委托给api
模块:
import api from '~/common/api'
const ConnectedLoginForm = () => {
const handleSubmit = async (email, password) => {
await api.login(email, password)
}
return (
<LoginForm onSubmit={handleSubmit} />
)
}
ConnectedLoginForm
现在可以看做api
和LoginForm
之间的粘合剂,但它们本身是保持独立的,这样对二者的修改就不会影响到对方。
依赖倒置原则旨在最小化应用程序不同组件之间的耦合。
最小化
是SOLID
原则中反复出现的关键词,其实最小化
也是组件化开发的重要思想,从最小化单个组件的职责范围到最小化他们之间的依赖关系等。
hooks的使用
在项目中,hooks的使用也并不够规范,例如最近遇到一个问题是:没有对变量进行useMemo
的包裹,导致每次都创建了canvas
,最后在ios上直接导致黑屏
修改前:
代码语言:txt复制const gradient = (fromColor: string, toColor: string, width: number, height: number) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.globalAlpha = 0.9;
// 第三个参数有值就是横向渐变,第四个有值就是纵向渐变
const grd = ctx.createLinearGradient(getRenderSize(width, startX), getRenderSize(height, startY), width, height);
grd.addColorStop(0, fromColor);
grd.addColorStop(1, toColor);
ctx.fillStyle = grd;
ctx.fillRect(startX, startY, getRenderSize(width, startX), getRenderSize(height, startY));
return PIXI.Texture.from(canvas);
};
export const Mask = ({ cover }: { cover: PIXI.Texture }) => {
const { width, height } = useContext(LyricsState);
const color = useMemo(() => {
const total = maskColor.length;
const color = maskColor[Math.floor(Math.random() * total)];
return color;
}, [cover]);
if (!cover) return null;
return <Sprite texture={gradient(color.from, color.to, width, height)} zIndex={EScreenLyricsZIndex.ALBUM_MASK} ></Sprite>;
};
修改后:
代码语言:txt复制export const Mask = memo(
({ cover }: { cover: PIXI.Texture }) => {
const { width, height } = useContext(LyricsState);
const color = useMemo(() => {
const total = maskColor.length;
const color = maskColor[Math.floor(Math.random() * total)];
return color;
}, [cover]);
const gradient = useCallback((fromColor: string, toColor: string, width: number, height: number) => {
console.log("gradient=======") // [1]
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.globalAlpha = 0.9;
// 第三个参数有值就是横向渐变,第四个有值就是纵向渐变
const grd = ctx.createLinearGradient(getRenderSize(width, startX), getRenderSize(height, startY), width, height);
grd.addColorStop(0, fromColor);
grd.addColorStop(1, toColor);
ctx.fillStyle = grd;
ctx.fillRect(startX, startY, getRenderSize(width, startX), getRenderSize(height, startY));
return PIXI.Texture.from(canvas);
}, []);
if (!cover) return null;
return <Sprite texture={gradient(color.from, color.to, width, height)} zIndex={EScreenLyricsZIndex.ALBUM_MASK} ></Sprite>;
},
(prev, next) => prev.cover === next.cover,
);
对React hooks的使用,很重要的一点就是对所有的变量和函数都应该有相应的依赖导致其变化和调用,如果仅依靠框架对代码的处理,很容易因为频繁渲染而导致爆发问题。
环境变量污染
由于我们的项目是在APP中运行的,因此会有很多和客户端交互的场景,但是在开发环境下,都是在浏览器中运行代码,此时并没有客户端提供的bridge api
,为了使项目在浏览器中正常运行,我们经常会判断代码当前的运行环境,例如:
function enterRoom() {
return new Promise((resolve, reject) => {
if (__DEV__) {
resolve(0)
} else {
bridge.invoke({....})
}
});
}
这是一段真实的业务代码的简化,并且项目中有40 处bridge api
的调用,大约有一半的地方判断了当前的执行环境,这有两个问题:
a. 如果有更多需要判断环境的条件,例如在测试环境和正式环境有不同的逻辑,那么需要改到一二十处地方且很容易改漏
代码语言:txt复制b. 虽然我们大部分的开发时间在浏览器上,但仍有可能想在真机上看一下开发环境是否能正常运行,这样可以避免反复向测试环境发版本而浪费时间,此时虽然是在真机上运行,但环境变量`__DEV__`仍是为`true`,这导致即便已经在真机上运行,但并没有真正调用`bridge api`,如果是自己写的代码,可能很快会反应到,但如果是其他同学接手代码或者是调试别的功能,会因为没有真正调用`bridge api`导致的异常表现花费大量时间debug,前段时间就因为这个问题导致花费了一天多的时间debug,最终才发现是有一个`bridge api`完全没调用,这个过程中还耽误了另外两位同学不少时间帮忙debug。
因此对类似的环境变量判断必须在第一次遇到的时候就需要对相关逻辑进行抽离,而向上述的判断,抽离后仅仅是一个10行代码的工具函数:
代码语言:txt复制export const bridgeProxy = (base: any, options: IBridgeProxyOptions | boolean = false) => {
// 只在开发环境下的桌面浏览器中判断是否拦截
if (__DEV__ && isDesktop()) {
if (options === true) return;
(options as IBridgeProxyOptions)?.interceptor?.();
return;
}
bridge.invoke(base);
};
之后再需要调用bridge api
,就直接使用bridgeProxy
函数,如果不需要判断环境,那么和之前调用bridge.invoke
时毫无区别,只有需要做环境判断并拦截时,才需要传入相应的配置。
小结
在组件化开发下,一定要关注最小粒度,可以是组件的最小粒度,也可以是依赖关系的最小粒度,也可以是逻辑的最小粒度;并且要注意如何在代码能最大程度复用的情况下来设计组件,避免将各种各样的逻辑冗余在同一个组件中;同时也需要关心一些基础的代码风格问题以及框架的使用问题;在项目的架构和组织组件上都需要经常思考和优化。