前端架构:稳定的和不稳定的依赖关系
许多组件(像React、Vue、Angular等库的)都使用了实用库的功能。
让我们考虑一个React组件,它显示所提供文本的字数。
import words from 'lodash.words';
function CountWords({ text }: { text: string }): JSX.Element {
const count = words(text).length;
return (
<div className="words-count">{count}</div>
);
}
组件CountWords 使用库lodash.words 来计算字符串text 中的字数。
CountWords 组件对lodash.words 库有依赖性。
使用依赖关系的组件得益于代码的重用:你只需导入必要的库并使用它。
然而,你的组件可能需要为不同的环境(客户端、服务器端、测试环境)提供不同的依赖性实现。在这种情况下,直接导入一个依赖关系是有风险的。
正确设计依赖关系是构建前端应用程序的一项重要技能。创建一个好的设计的第一步是识别稳定和不稳定的依赖关系,并相应地处理它们。在这篇文章中,你将会发现如何做。
目录
1.稳定的依赖关系
让我们回顾一下介绍中的示例组件CountWords 。
import words from 'lodash.words';
function CountWords({ text }: { text: string }): JSX.Element {
const count = words(text).length;
return (
<div className="words-count">{count}</div>
);
}
组件CountWords ,无论在什么环境下都会使用同一个库lodash.words :无论是在客户端,还是在服务器端运行(如果你实现了服务器端渲染),甚至是在运行单元测试时。
同时,lodash.words 是一个简单的实用函数。
const arrayOfWords = words(string);
words 函数的签名在未来不会有太大变化。
因为被依赖的组件总是使用一种依赖的实现,而且这种依赖在未来不会改变--这种依赖被认为是稳定的。
稳定的依赖关系的例子是像lodash 、ramda 这样的实用库。
此外,JavaScript语言本身也提供了:
- 实用函数,如
Object.keys()。Array.from() - 基元和对象的方法,像
string.slice(),array.includes()。object.toString()
该语言提供的所有内置函数也被认为是稳定的依赖。你可以安全地使用它们并直接依赖它们。
然而,除了稳定的依赖外,有些依赖在某些情况下可能会发生变化。这种不稳定的依赖关系必须与稳定的依赖关系分开,并以不同的方式设计。
让我们在下一节中看看什么是不稳定的依赖关系。
2.不稳定的依赖关系
考虑一个也支持服务器端渲染的前端应用程序。你的任务是实现一个用户登录页面。
当用户第一次加载登录页面时,会显示一个登录表单。如果用户在登录表格中输入了正确的用户名和密码并点击提交,那么你就会创建一个cookieloggedIn ,其值为1 。
只要用户已经登录(cookieloggedIn 被设置,并且其值为1 ),就会显示一条信息'You are logged in' 。否则,就直接显示登录表单。
在设定了应用程序的要求后,让我们来讨论潜在的实现方式。
为了确定是否设置了loggedIn cookie,你必须考虑应用程序的运行环境。在客户端,你可以从document.cookie 属性访问cookie,而在服务器端,你需要读取HTTP请求头cookie 。
Cookie管理是一个不稳定的依赖关系,因为组件会根据环境选择具体的实现:客户端或服务器端。
一般来说,如果满足以下任何一个条件,该依赖关系就是不稳定的:
- 依赖关系需要为应用程序设置运行时环境(网络访问、Web 服务、文件系统)。
- 依赖关系正在开发中
- 依赖关系有非决定性的行为(随机数生成器,访问当前日期等)。
一个不稳定的依赖性的例子是,如前所述,cookie管理库在客户端和服务器端有不同的实现方式。另一个易失性依赖的例子是访问数据库的库或访问网络的获取库。
即使是仍在开发中的依赖,或者是你在未来可能会改变的替代解决方案,也可能是不稳定的。
区分易失性依赖的一个好的经验法则是分析你对依赖它的组件进行单元测试的容易程度。如果这个依赖关系需要大量的设置仪式和模拟来进行测试(例如,一个获取库需要模拟网络请求),那么它很可能是一个易变的。
2.1 一个糟糕的设计
你的组件不应该直接导入易失性依赖项。
但是我们故意犯这个错误
import { cookieClient } from './libs/cookie-client';
import { cookieServer } from './libs/cookie-server';
import LoginForm from 'Components/LoginForm';
export function Page(): JSX.Element {
const cookieManagement = typeof window === 'undefined'
? cookieServer : cookieClient;
if (cookieManagement.get('loggedIn') === '1') {
return <div>You are logged in</div>;
} else {
return <LoginForm />
}
}
为什么以这种方式实现cookie管理的不稳定性依赖性是一个问题呢?让我们来看看。
- 与所有依赖关系实现的紧密耦合。组件
Page直接依赖于cookieClient和cookieServer的实现。 - 对环境的依赖性。每次你需要cookie管理库时,你都要调用表达式
typeof window === 'undefined',以确定应用程序是运行在客户端还是服务器端,并根据cookie管理实现进行选择。 - *不必要的代码。*客户端捆绑包要包括
cookieServer库,这在客户端并不使用。反之亦然,在服务器端也是如此。 - 困难的测试。
Page组件的单元测试将需要大量的模拟,如设置window变量和模拟。document.cookie
是否有更好的设计?让我们来看看!
2.2 一个更好的设计
做一个更好的设计来处理不稳定的依赖关系需要更多的工作,但结果是值得的。
这个想法包括应用依赖反转原则,将Page 组件与cookieClient 和cookieServer 解耦。相反,让我们使Page 组件依赖于一个抽象的接口Cookie 。
首先,让我们定义一个接口Cookie ,描述一个cookie库应该实现哪些方法。
// Cookie.ts
export interface Cookie {
get(name: string): string | null;
set(name: string, value: string): void;
}
现在让我们定义React上下文,它将容纳cookie管理库的具体实现。
// CookieContext.tsx
import { createContext } from 'react';
import { Cookie } from './Cookie';
export const CookieContext = createContext<Cookie>(null);
CookieContext 将依赖性注入到 Page组件中。
// Page.tsx
import { useContext } from 'react';
import { Cookie } from './Cookie';
import { CookieContext } from './CookieContext';
import { LoginForm } from './LoginForm';
export function Page(): JSX.Element {
const cookie: Cookie = useContext(cookieContext);
if (cookie.get('loggedIn') === '1') {
return <div>You are logged in</div>;
} else {
return <LoginForm />
}
}
Page 组件唯一知道的是Cookie 接口,仅此而已。该组件与如何访问cookie的实现细节是脱钩的。
Page 组件并不关心它得到的是什么具体实现。唯一的要求是,注入的依赖关系要符合 Cookie
接口。
cookie管理库的必要实现是由客户端和服务器端的引导脚本设置的。
以下是你如何在客户端组成cookie管理依赖
// index.client.tsx
import ReactDOM from 'react-dom';
import { Page } from './Page';
import { CookieContext } from './CookieContext';
import { cookieClient } from './libs/cookie-client';
ReactDOM.hydrate(
<CookieContext.Provider value={cookieClient}>
<Page />
</CookieContext.Provider>,
document.getElementById('root')
);
和在服务器端:
// index.server.tsx
import express from 'express';
import { renderToString } from 'react-dom/server';
import { Page } from './Page';
import { CookieContext } from './CookieContext';
import { cookieServer } from './libs/cookie-server';
const app = express();
app.get('/', (req, res) => {
const content = renderToString(
<CookieContext.Provider value={cookieServer}>
<Page />
</CookieContext.Provider>
);
res.send(`
<html>
<head><script src="./bundle.js"></script></head>
<body>
<div id="root">
${content}
</div>
</body>
</html>
`);
})
app.listen(env.PORT ?? 3000);
挥发性依赖的具体实现是在靠近应用程序的引导(或主)脚本的地方组成的。这个地方被称为组成根。
良好的易失性依赖设计的好处。
- 松散的耦合。 组件
Page,不依赖于依赖关系的所有可能的实现。 - 不受实现细节和环境的影响。该组件不关心它是在客户端还是在服务器端运行。
- 依赖于稳定的抽象。该组件只依赖于一个抽象的接口
Cookie - 易于测试。 该组件只知道接口,你可以通过使用上下文注入假的实现来轻松测试这样的组件。
2.3 注意增加的复杂性
改进后的设计需要更多的活动部件:一个描述依赖关系的新接口和注入依赖关系的方法。
所有这些活动部件都会增加复杂性。所以你应该仔细考虑这种设计的好处是否超过了增加的复杂性。
2.4 注入机制
在前面的例子中,React上下文注入了具体的实现--React上下文只是其中一个可能的选择。
同样的,你可以使用props来注入实现。
// Page.tsx
import { Cookie } from './Cookie';
import { LoginForm } from './LoginForm';
interface PageProps {
cookie: Cookie;
}
export function Page({ cookie }: PageProps): JSX.Element {
if (cookie.get('loggedIn') === '1') {
return <div>You are logged in</div>;
} else {
return <LoginForm />
}
}
然而,如果使用volatile dependencies的组件在组件层次结构的深处(即远离Composition Root),你可能最终会陷入props的钻研。React上下文虽然需要更多的设置(上下文对象,useContext() 钩子),但没有这个问题。
3.3.总结
你的前端应用程序的组件可以使用大量的库。
其中一些库,如lodash ,甚至是内置的JavaScript的工具,都是稳定的依赖,你的组件可以自由地直接依赖它们。
然而,有时组件需要的依赖关系可能会发生变化,要么是在运行期间,要么是取决于环境,要么是其他原因导致的变化。这些依赖关系属于不稳定的类别。
好的设计使组件不直接依赖易变的依赖,而是依赖一个稳定的接口(通过使用依赖反转原则)来描述依赖,然后允许依赖注入机制(如React context)提供具体的依赖实现。