本指南涵盖了我们如何在 Sentry
编写前端代码, 并特别关注 Sentry 和 Getsentry 代码库。它假设您使用的是 eslint-config-sentry 概述的 eslint
规则;因此,这里不会讨论由这些 linting
规则强制执行的代码风格。
前端代码库当前位于 sentry
中的 src/sentry/static/sentry/app
和 getentry
中的 static/getsentry
下。(我们打算在未来与 static/sentry
保持一致。)
dataScrubbingEditModal
、dataScrubbingAddModal
),而是使用像 dataScrubbing/editModal
这样的名称。index.(j|t)?(sx)
在文件夹中有一个
index
文件提供了一种隐式导入主文件而不指定它的方法
index
文件的使用应遵循以下规则:
examples
、avatar
、idBadge
)。入口点组件应该是 index
文件。index.(j|t)?(sx)
文件,如果文件夹包含在应用程序的其他部分使用的组件,与入口点文件无关。(即,actionCreators
,panels
)index
文件。更倾向于导入单个组件。新组件在需要访问 this
时使用 class
语法,以及类字段+箭头函数方法定义。
class Note extends React.Component {
static propTypes = {
author: PropTypes.object.isRequired,
onEdit: PropTypes.func.isRequired,
};
// 请注意,方法是使用箭头函数类字段定义的(绑定“this”)
handleChange = value => {
let user = ConfigStore.get('user');
if (user.isSuperuser) {
this.props.onEdit(value);
}
};
render() {
let {content} = this.props; // 对 props 使用解构赋值
return <div onChange={this.handleChange}>{content}</div>;
}
}
export default Note;
一些较旧的组件使用 createReactClass
和 mixins
,但这已被弃用。
app/components/
和 app/views
文件夹都包含 React
组件。
组件应该有一个关联的 .stories.js
文件来记录它应该如何使用。
使用 yarn storybook
在本地运行 Storybook
或在 https://storybook.getsentry.net/
上查看托管版本
使用它们,要明确,尽可能使用共享的自定义属性。
更倾向 Proptypes.arrayOf
而不是 PropTypes.array
和 PropTypes.shape
而不是 PropTypes.object
如果你使用一组重要的、定义良好的 key
(你的组件依赖)传递对象,那么使用 PropTypes.shape
显式定义它们:
PropTypes.shape({
username: PropTypes.string.isRequired,
email: PropTypes.string
})
如果您要重复使用自定义 prop-type
或传递常见的共享 shape
(如 organization
、project
或 user
), 请确保从我们有用的自定义集合中导入 proptype
!
我们使用不同的前缀来更好地区分事件处理程序
和事件回调属性
。
对事件处理程序使用 handle
前缀,例如:
<Button onClick={this.handleDelete}/>
对于传递给组件的事件回调属性,请使用 on
前缀,例如:
<Button onClick={this.props.onDelete}>
Emotion
,使用 theme
对象。css-in-js
库 e m o t i o n - 它允许您将样式绑定到元素而无需全局选择器的间接性。你甚至不需要打开另一个文件!z-indexes
, paddings
, colors
)import styled from 'react-emotion';
const SomeComponent = styled('div')`
border-radius: 1.45em;
font-weight: bold;
z-index: ${p => p.theme.zIndex.modal};
padding: ${p => p.theme.grid}px ${p => p.theme.grid * 2}px;
border: 1px solid ${p => p.theme.borderLight};
color: ${p => p.theme.purple};
box-shadow: ${p => p.theme.dropShadowHeavy};
`;
export default SomeComponent;
reflexbox
(例如Flex
和Box
)已被弃用,请避免在新代码中使用。stylelint
错误当您使用样式组件(styled component)
作为选择器时会发生这种情况,我们需要通过使用注释来辅助 linter
来告诉 stylelint
我们正在插入的是一个选择器。例如
const ButtonBar = styled("div")`
${/* sc-selector */Button) {
border-radius: 0;
}
`;
有关其他标签和更多信息,请参阅。
我们目前使用 Reflux 来管理全局状态。
Reflux
实现了 Flux 概述的单向数据流模式。 Store
注册在 app/stores
下,用于存储应用程序使用的各种数据。 Action
需要在 app/actions
下注册。我们使用 action creator
函数(在 app/actionCreators
下)来分派 action
。 Reflux store
监听 action
并相应地更新自己。
我们目前正在探索 Reflux
库的替代方案以供将来使用。
我们正在远离 Enzyme,转而使用 React Testing Library。有关 RTL 提示,请查看此页面。
注意:你的文件名必须是 .spec.jsx
否则 jest
不会运行它!
我们在 setup.js 中定义了有用的 fixtures
,使用这些!如果您以重复的方式定义模拟数据,则可能值得添加此文件。routerContext
是一种特别有用的方法,用于提供大多数视图所依赖的上下文对象。
Client.addMockResponse
是模拟 API
请求的最佳方式。这是我们的代码, 所以如果它让您感到困惑,只需将 console.log()
语句放入其逻辑中即可!
我们测试环境中的一个重要问题是,enzyme
修改了 react
生命周期的许多方面以同步评估(即使它们通常是异步的)。当您触发某些逻辑并且没有立即在您的断言逻辑中反映出来时,这可能会使您陷入一种虚假的安全感。
标记您的测试方法 async
并使用 await tick();
实用程序可以让事件循环刷新运行事件并修复此问题:
wrapper.find('ExpandButton').simulate('click');
await tick();
expect(wrapper.find('CommitRow')).toHaveLength(2);
如果您正在编写 jest
测试,您可以使用 Component
(和 Styled Component
)名称作为选择器。此外,如果您需要使用 DOM
查询选择器,请使用 data-test-id
而不是类名。我们目前没有,但我们可以在构建过程中使用 babel
去除它。
theme
属性而不是使用来自 enzyme
的 mount()
...使用这个:import {mountWithTheme} from 'sentry-test/enzyme'
以便被测组件用 <ThemeProvider>
。
我们决定只使用处于 stage 3
(或更高版本)的 ECMAScript 提案(参见 TC39 提案)。此外,因为我们正在迁移到 typescript
,我们将与他们的编译器支持的内容保持一致。唯一的例外是装饰器。
可选链 帮助我们访问 [嵌套] 对象, 而无需在每个属性/方法
访问之前检查是否存在。如果我们尝试访问 undefined
或 null
对象的属性,它将停止并返回 undefined
。
可选链操作符拼写为 ?.
。它可能出现在三个位置:
obj?.prop // 可选的静态属性访问
obj?.[expr] // 可选的动态属性访问
func?.(...args) // 可选的函数或方法调用
来自 https://github.com/tc39/proposal-optional-chaining
这是一种设置“默认”值的方法。例如:以前你会做类似的事情
let x = volume || 0.5;
这是一个问题,因为 0
是 volume
的有效值,但因为它的计算结果为 false
-y,我们不会使表达式短路,并且 x
的值为 0.5
如果我们使用空值合并
let x = volume ?? 0.5
如果 volume
为 null
或 undefined
,它只会默认为 0.5
。
基本情况。如果表达式在 ??
的左侧运算符计算为 undefined
或 null
,则返回其右侧。
const response = {
settings: {
nullValue: null,
height: 400,
animationDuration: 0,
headerText: '',
showSplashScreen: false
}
};
const undefinedValue = response.settings.undefinedValue ?? 'some other default'; // result: 'some other default'
const nullValue = response.settings.nullValue ?? 'some other default'; // result: 'some other default'
const headerText = response.settings.headerText ?? 'Hello, world!'; // result: ''
const animationDuration = response.settings.animationDuration ?? 300; // result: 0
const showSplashScreen = response.settings.showSplashScreen ?? true; // result: false
From https://github.com/tc39/proposal-nullish-coalescing
确保不要使用默认的 lodash
包导入 lodash
实用程序。有一个 eslint
规则来确保这不会发生。而是直接导入实用程序,例如 import isEqual from 'lodash/isEqual';
。
以前我们使用了 lodash-webpack-plugin 和 babel-plugin-lodash 的组合, 但是在尝试使用新的 lodash
实用程序(例如这个 PR)时很容易忽略这些插件和配置。通过 webpack tree shaking
和 eslint
强制执行,我们应该能够保持合理的包大小。
有关更多信息,请参阅此 PR。
我们更喜欢使用可选链
和空值合并
而不是来自 lodash/get
的 get
。
Typing DefaultProps
Grid-Emotion
引用其文档,“Storybook
是用于 UI
组件的 UI
开发环境。有了它,您可以可视化 UI
组件的不同状态并以交互方式开发它们。”
更多细节在这里:
是的!我们将 Storybook
用于 getsentry/sentry 项目。 Storybook
的配置可以在 https://github.com/getsentry/sentry/tree/master/.storybook 中找到。
要在本地运行 Storybook
,请在 getsentry/sentry
存储库的根目录中运行 npm run storybook
。
Sentry 的 Storybook 是使用 Vercel 构建和部署的。每个 Pull Request
都有自己的部署,每次推送到主分支都会部署到 https://storybook.sentry.dev。
由于 Typescript 3.0
默认 props
可以更简单地输入。有几种不同的方法适合不同的场景。
import React from 'react';
type DefaultProps = {
size: 'Small' | 'Medium' | 'Large'; // 这些不应标记为可选
};
// 没有 Partial<DefaultProps>
type Props = DefaultProps & {
name: string;
codename?: string;
};
class Planet extends React.Component<Props> {
// 没有 Partial<Props> 因为它会将所有内容标记为可选
static defaultProps: DefaultProps = {
size: 'Medium',
};
render() {
const {name, size, codename} = this.props;
return (
<p>
{name} is a {size.toLowerCase()} planet.
{codename && ` Its codename is ${codename}`}
</p>
);
}
}
const planet = <Planet name="Mars" />;
或在 typeof
的帮助下:
import React from 'react';
const defaultProps = {
size: 'Medium' as 'Small' | 'Medium' | 'Large',
};
type Props = {
name: string;
codename?: string;
} & typeof defaultProps;
// 没有 Partial<typeof defaultProps> 因为它会将所有内容标记为可选
class Planet extends React.Component<Props> {
static defaultProps = defaultProps;
render() {
const {name, size, codename} = this.props;
return (
<p>
{name} is a {size.toLowerCase()} planet. Its color is{' '}
{codename && ` Its codename is ${codename}`}
</p>
);
}
}
const planet = <Planet name="Mars" />;
import React from 'react';
// 函数组件上的 defaultProps 将在未来停止使用
// https://twitter.com/dan_abramov/status/1133878326358171650
// https://github.com/reactjs/rfcs/pull/107
// 我们应该使用默认参数
type Props = {
name: string;
size?: 'Small' | 'Medium' | 'Large'; // 具有 es6 默认参数的属性应标记为可选
codename?: string;
};
// 共识是输入解构的 Props 比使用 React.FC<Props> 稍微好一点
// https://github.com/typescript-cheatsheets/react-typescript-cheatsheet#function-components
const Planet = ({name, size = 'Medium', codename}: Props) => {
return (
<p>
{name} is a {size.toLowerCase()} planet.
{codename && ` Its codename is ${codename}`}
</p>
);
};
const planet = <Planet name="Mars" />;
为了使组件更易于重用和更易于理解,React
和 React 生态系统
一直趋向于函数式组件和 hooks
。 Hooks
是一种向功能组件添加状态
和副作用
的便捷方式。它们还为库提供了一种公开行为的便捷方式。
虽然我们通常支持 hooks
,但我们有一些关于 hooks
应该如何与 Sentry 前端
一起使用的建议。
如果一个库提供了 hooks
,你应该使用它们。通常,这将是使用库的唯一方法。例如,dnd-kit
通过钩子公开了它的所有原语(primitives
),我们应该按照预期的方式使用该库。
我们不喜欢使用不用 hooks
的库。相反,与具有更大、更复杂的 API
或更大的包大小的库相比, 更喜欢具有更清晰、更简单的 API
和更小的包大小的库。
useState
, useMemo
, useCallback
, useContext
和 useRef
hooks 在任何函数式组件中都是受欢迎的。在需要少量状态或访问 react
原语(如引用和上下文)的展示组件中,它们通常是一个不错的选择。例如,具有滑出(slide-out)
或可展开状态(expandable state)
的组件。
useEffect
hook 更复杂,您需要小心地跟踪您的依赖项并确保通过清理回调取消订阅。应避免 useEffect
的复杂链式应用程序,此时 'controller'
组件应保持基于类(class
)。
同样,useReducer
钩子与目前尚未确定的状态管理重叠。我们希望避免 又一个 状态管理模式,因此此时避免使用useReducer
。
当我们计划远离 Reflux
的路径时,useContext
hook 提供了一个更简单的实现选项来共享状态和行为。当您需要创建新的共享状态源时,请考虑使用 context
和 useContext
而不是 Reflux
。此外,可以利用虫洞状态管理模式来公开共享状态
和突变函数
。
可以创建自定义 hooks
来共享应用程序中的可重用逻辑。创建自定义 hook
时,函数名称必须遵循约定,以 “use”
开头(例如 useTheme
), 并且可以在自定义 hooks
内调用其他 hooks
。
React hooks
有一些规则。请注意 hooks
创建的规则和限制。我们使用 ESLint
规则来防止大多数 hook
规则被非法侵入。
此外,我们建议您尽量少使用 useEffect
。使用多个 useEffect
回调表示您有一个高度有状态
的组件, 您应该使用类(class)
组件来代替。
我们的基础视图组件(AsyncView
和 AsyncComponent
)是基于类的,并且会持续很长时间。在构建视图时请记住这一点。您将需要额外的 wrapper
组件来访问 hooks
或将 hook state
转换为您的 AsyncComponent
的 props
。
虽然 hooks
可以在新代码中符合人体工程学,但我们应该避免重写现有代码以利用 hooks
。重写需要时间,使我们面临风险,并且为最终用户提供的价值很小。
如果您需要重新设计一个组件以使用库中的 hooks
,那么还可以考虑从一个类转换为一个函数组件。
我们正在将我们的测试从 Enzyme
转换为 React Testing Library
。在本指南中,您将找到遵循最佳实践和避免常见陷阱的技巧。
我们有两个 ESLint 规则来帮助解决这个问题:
我们努力以一种与应用程序使用方式非常相似的方式编写测试。
我们不是处理渲染组件
的实例,而是以与用户相同的方式查询 DOM
。我们通过 label
文本找到表单元素(就像用户一样),我们从他们的文本中找到链接和按钮(就像用户一样)。
作为此目标的一部分,我们避免测试实现细节,因此重构(更改实现但不是功能)不会破坏测试。
我们通常赞成用例覆盖
而不是代码覆盖
。
getBy...
queryBy...
DOM
更改后出现时才使用 await findBy...
为确保测试类似于用户与我们的代码交互的方式,我们建议使用以下优先级进行查询:
getByRole
- 这应该是几乎所有东西的首选选择器。作为这个选择器的一个很好的奖励,我们确保我们的应用程序是可访问的。它很可能与 name
选项 getByRole('button', {name: /save/i})
一起使用。 name
通常是表单元素的 label
或 button
的文本内容,或 aria-label
属性的值。如果不确定,请使用 logRoles 功能 或查阅可用角色列表。
getByLabelText
/getByPlaceholderText
- 用户使用 label
文本查找表单元素,因此在测试表单时首选此选项。getByText
- 在表单之外,文本内容是用户查找元素的主要方式。此方法可用于查找非交互式元素(如 div
、span
和 paragraph
)。getByTestId
- 因为这不反映用户如何与应用交互,所以只推荐用于不能使用任何其他选择器的情况如果您仍然无法决定使用哪个查询, 请查看 testing-playground.com 以及 screen.logTestingPlaygroundURL()
及其浏览器扩展。
不要忘记,你可以在测试中的任何地方放置 screen.debug()
来查看当前的 DOM
。
在官方文档中阅读有关查询的更多信息。
避免从 render
方法中解构查询函数,而是使用 screen
(examples)。当您添加/删除
您需要的查询时,您不必使 render
调用解构保持最新。您只需要输入 screen
并让您的编辑器的自动完成功能处理其余的工作。
import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
// ❌
const { getByRole } = mountWithTheme(<Example />);
const errorMessageNode = getByRole("alert");
// ✅
mountWithTheme(<Example />);
const errorMessageNode = screen.getByRole("alert");
除了检查不存在(examples)之外,避免将 queryBy...
用于任何事情。如果没有找到元素,getBy...
和 findBy...
变量将抛出更有用的错误消息。
import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
// ❌
mountWithTheme(<Example />);
expect(screen.queryByRole("alert")).toBeInTheDocument();
// ✅
mountWithTheme(<Example />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
避免使用 waitFor
等待出现,而是使用 findBy...
(examples)。这两个基本上是等价的(findBy...
甚至在其里面使用了 waitFor
),但是 findBy...
更简单,我们得到的错误信息也会更好。
import {
mountWithTheme,
screen,
waitFor,
} from "sentry-test/reactTestingLibrary";
// ❌
mountWithTheme(<Example />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
// ✅
mountWithTheme(<Example />);
expect(await screen.findByRole("alert")).toBeInTheDocument();
避免使用 waitFor
等待消失,使用 waitForElementToBeRemoved
代替(examples)。
后者使用 MutationObserver
,这比使用 waitFor
定期轮询 DOM
更有效。
import {
mountWithTheme,
screen,
waitFor,
waitForElementToBeRemoved,
} from "sentry-test/reactTestingLibrary";
// ❌
mountWithTheme(<Example />);
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument()
);
// ✅
mountWithTheme(<Example />);
await waitForElementToBeRemoved(() => screen.getByRole("alert"));
更喜欢使用 jest-dom
断言(examples)。使用这些推荐的断言的优点是更好的错误消息、整体语义、一致性和统一性。
import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
// ❌
mountWithTheme(<Example />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByRole("alert").textContent).toEqual("abc");
expect(screen.queryByRole("button")).toBeFalsy();
expect(screen.queryByRole("button")).toBeNull();
// ✅
mountWithTheme(<Example />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("abc");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
按文本搜索时,最好使用不区分大小写的正则表达式。它将使测试更能适应变化。
import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
// ❌
mountWithTheme(<Example />);
expect(screen.getByText("Hello World")).toBeInTheDocument();
// ✅
mountWithTheme(<Example />);
expect(screen.getByText(/hello world/i)).toBeInTheDocument();
尽可能在 fireEvent
上使用 userEvent
。 userEvent
来自 @testing-library/user-event
包,它构建在 fireEvent
之上,但它提供了几种更类似于用户交互的方法。
// ❌
import {
mountWithTheme,
screen,
fireEvent,
} from "sentry-test/reactTestingLibrary";
mountWithTheme(<Example />);
fireEvent.change(screen.getByLabelText("Search by name"), {
target: { value: "sentry" },
});
// ✅
import {
mountWithTheme,
screen,
userEvent,
} from "sentry-test/reactTestingLibrary";
mountWithTheme(<Example />);
userEvent.type(screen.getByLabelText("Search by name"), "sentry");
grid-emotion 已经被弃用一年多了,新项目是 reflexbox。为了升级到最新版本的 emotion,我们需要迁移出 grid-emotion
。
要迁移,请使用 emotion
将导入的 <Flex>
和 <Box>
组件替换为带样式的组件。
用下面的替换组件,然后删除必要的 props
并移动到 styled component
。
<Flex>
const Flex = styled('div')`
display: flex;
`;
<Box>
const Box = styled('div')`
`;
如果您正在修改导出的组件,请确保通过该组件的代码库进行 grep
以确保它没有被渲染为特定于 grid-emotion
的附加属性。示例是<Panel>
组件。
Margin
属性 以 m
开头,以 p
填充。下面的例子将使用 margin
作为例子
margin: ${space(2);
这些是 flexbox
属性
align-items: center;
现在只需忽略 grid-emotion
的导入语句,例如 // eslint-disable-line no-restricted-imports