前端手册
本指南涵盖了我们如何在 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
文件。更倾向于导入单个组件。
React
定义 React 组件
新组件在需要访问 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
组件。
- 使用通常不会在代码库的其他部分重用的 UI 视图。
- 使用设计为高度可重用的 UI 组件。
组件应该有一个关联的 .stories.js
文件来记录它应该如何使用。
使用 yarn storybook
在本地运行 Storybook
或在 https://storybook.getsentry.net/
上查看托管版本
PropTypes
使用它们,要明确,尽可能使用共享的自定义属性。
更倾向 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}>
CSS 和 Emotion
- 使用
Emotion
,使用theme
对象。 - 最好的样式是您不编写的样式 - 尽可能使用现有组件。
- 新代码应该使用
css-in-js
库 e m o t i o n - 它允许您将样式绑定到元素而无需全局选择器的间接性。你甚至不需要打开另一个文件! - 从 props.theme 获取常量(
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
错误
"No duplicate selectors"
当您使用样式组件(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>
。
Babel 语法插件
我们决定只使用处于 stage 3
(或更高版本)的 ECMAScript 提案(参见 TC39 提案)。此外,因为我们正在迁移到 typescript
,我们将与他们的编译器支持的内容保持一致。唯一的例外是装饰器。
新语法
可选链
可选链 帮助我们访问 [嵌套] 对象, 而无需在每个属性/方法
访问之前检查是否存在。如果我们尝试访问 undefined
或 null
对象的属性,它将停止并返回 undefined
。
语法
可选链操作符拼写为 ?.
。它可能出现在三个位置:
obj?.prop // 可选的静态属性访问 obj?.[expr] // 可选的动态属性访问 func?.(...args) // 可选的函数或方法调用
空值合并
这是一种设置“默认”值的方法。例如:以前你会做类似的事情
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
Lodash
确保不要使用默认的 lodash
包导入 lodash
实用程序。有一个 eslint
规则来确保这不会发生。而是直接导入实用程序,例如 import isEqual from 'lodash/isEqual';
。
以前我们使用了 lodash-webpack-plugin 和 babel-plugin-lodash 的组合, 但是在尝试使用新的 lodash
实用程序(例如这个 PR)时很容易忽略这些插件和配置。通过 webpack tree shaking
和 eslint
强制执行,我们应该能够保持合理的包大小。
- https://www.npmjs.com/package/lodash-webpack-plugin
- https://github.com/lodash/babel-plugin-lodash
- https://github.com/getsentry/sentry/pull/13834
有关更多信息,请参阅此 PR。
我们更喜欢使用可选链
和空值合并
而不是来自 lodash/get
的 get
。
Typescript
Typing DefaultProps
迁移指南
Grid-Emotion
Storybook Styleguide
引用其文档,“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。
Typing DefaultProps
由于 Typescript 3.0
默认 props
可以更简单地输入。有几种不同的方法适合不同的场景。
类(Class)组件
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" />;
函数式(Function)组件
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" />;
参考
- Typescript 3.0 Release notes
- Stack Overflow question on typing default props
使用 Hooks
为了使组件更易于重用和更易于理解,React
和 React 生态系统
一直趋向于函数式组件和 hooks
。 Hooks
是一种向功能组件添加状态
和副作用
的便捷方式。它们还为库提供了一种公开行为的便捷方式。
虽然我们通常支持 hooks
,但我们有一些关于 hooks
应该如何与 Sentry 前端
一起使用的建议。
使用库中的 hooks
如果一个库提供了 hooks
,你应该使用它们。通常,这将是使用库的唯一方法。例如,dnd-kit
通过钩子公开了它的所有原语(primitives
),我们应该按照预期的方式使用该库。
我们不喜欢使用不用 hooks
的库。相反,与具有更大、更复杂的 API
或更大的包大小的库相比, 更喜欢具有更清晰、更简单的 API
和更小的包大小的库。
使用 react 的内置 hooks
useState
, useMemo
, useCallback
, useContext
和 useRef
hooks 在任何函数式组件中都是受欢迎的。在需要少量状态或访问 react
原语(如引用和上下文)的展示组件中,它们通常是一个不错的选择。例如,具有滑出(slide-out)
或可展开状态(expandable state)
的组件。
useEffect
hook 更复杂,您需要小心地跟踪您的依赖项并确保通过清理回调取消订阅。应避免 useEffect
的复杂链式应用程序,此时 'controller'
组件应保持基于类(class
)。
同样,useReducer
钩子与目前尚未确定的状态管理重叠。我们希望避免 又一个 状态管理模式,因此此时避免使用useReducer
。
使用 context
当我们计划远离 Reflux
的路径时,useContext
hook 提供了一个更简单的实现选项来共享状态和行为。当您需要创建新的共享状态源时,请考虑使用 context
和 useContext
而不是 Reflux
。此外,可以利用虫洞状态管理模式来公开共享状态
和突变函数
。
使用自定义 hooks
可以创建自定义 hooks
来共享应用程序中的可重用逻辑。创建自定义 hook
时,函数名称必须遵循约定,以 “use”
开头(例如 useTheme
), 并且可以在自定义 hooks
内调用其他 hooks
。
注意 hooks 的规则和注意事项
React hooks
有一些规则。请注意 hooks
创建的规则和限制。我们使用 ESLint
规则来防止大多数 hook
规则被非法侵入。
此外,我们建议您尽量少使用 useEffect
。使用多个 useEffect
回调表示您有一个高度有状态
的组件, 您应该使用类(class)
组件来代替。
我们的基础视图组件仍然是基于类的
我们的基础视图组件(AsyncView
和 AsyncComponent
)是基于类的,并且会持续很长时间。在构建视图时请记住这一点。您将需要额外的 wrapper
组件来访问 hooks
或将 hook state
转换为您的 AsyncComponent
的 props
。
不要为 hooks 重写
虽然 hooks
可以在新代码中符合人体工程学,但我们应该避免重写现有代码以利用 hooks
。重写需要时间,使我们面临风险,并且为最终用户提供的价值很小。
如果您需要重新设计一个组件以使用库中的 hooks
,那么还可以考虑从一个类转换为一个函数组件。
使用 React Testing Library
我们正在将我们的测试从 Enzyme
转换为 React Testing Library
。在本指南中,您将找到遵循最佳实践和避免常见陷阱的技巧。
我们有两个 ESLint 规则来帮助解决这个问题:
- eslint-plugin-jest-dom
- eslint-plugin-testing-library
我们努力以一种与应用程序使用方式非常相似的方式编写测试。
我们不是处理渲染组件
的实例,而是以与用户相同的方式查询 DOM
。我们通过 label
文本找到表单元素(就像用户一样),我们从他们的文本中找到链接和按钮(就像用户一样)。
作为此目标的一部分,我们避免测试实现细节,因此重构(更改实现但不是功能)不会破坏测试。
我们通常赞成用例覆盖
而不是代码覆盖
。
查询
- 尽可能使用
getBy...
- 仅在检查不存在时使用
queryBy...
- 仅当期望元素在可能不会立即发生的
DOM
更改后出现时才使用await findBy...
为确保测试类似于用户与我们的代码交互的方式,我们建议使用以下优先级进行查询:
getByRole
- 这应该是几乎所有东西的首选选择器。
作为这个选择器的一个很好的奖励,我们确保我们的应用程序是可访问的。它很可能与 name
选项 getByRole('button', {name: /save/i})
一起使用。 name
通常是表单元素的 label
或 button
的文本内容,或 aria-label
属性的值。如果不确定,请使用 logRoles 功能 或查阅可用角色列表。
- https://testing-library.com/docs/dom-testing-library/api-accessibility/#logroles
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques#roles
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
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 和 padding
Margin
属性 以 m
开头,以 p
填充。下面的例子将使用 margin
作为例子
旧 (grid-emotion) | 新 (css/emotion/styled) |
m={2} |
margin: ${space(2); |
mx={2} |
margin-left: ${space(2); margin-right: ${space(2)}; |
my={2} |
margin-top: ${space(2); margin-bottom: ${space(2)}; |
ml={2} |
margin-left: ${space(2); |
mr={2} |
margin-right: ${space(2); |
mt={2} |
margin-top: ${space(2); |
mb={2} |
margin-bottom: ${space(2); |
flexbox
这些是 flexbox
属性
旧 (grid-emotion) | 新 (css/emotion/styled) |
align="center" |
align-items: center; |
justify="center" |
justify-content: center; |
direction="column" |
flex-direction: column; |
wrap="wrap" |
flex-wrap: wrap; |
现在只需忽略 grid-emotion
的导入语句,例如 // eslint-disable-line no-restricted-imports