前端手册
本指南涵盖了我们如何在 Sentry 编写前端代码, 并特别关注 Sentry 和 Getsentry 代码库。它假设您使用的是 eslint-config-sentry 概述的 eslint 规则;因此,这里不会讨论由这些 linting 规则强制执行的代码风格。
- https://github.com/getsentry/eslint-config-sentry
目录结构
前端代码库当前位于 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 {content};
- }
- }
-
- 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!
- https://github.com/getsentry/sentry/blob/master/static/app/sentryTypes.tsx
事件处理程序
我们使用不同的前缀来更好地区分事件处理程序和事件回调属性。
对事件处理程序使用 handle 前缀,例如:
-
对于传递给组件的事件回调属性,请使用 on 前缀,例如:
CSS 和 Emotion
- 使用 Emotion,使用 theme 对象。
- 最好的样式是您不编写的样式 - 尽可能使用现有组件。
- 新代码应该使用 css-in-js 库 e m o t i o n - 它允许您将样式绑定到元素而无需全局选择器的间接性。你甚至不需要打开另一个文件!
- 从 props.theme 获取常量(z-indexes, paddings, colors)
- https://emotion.sh/
- https://github.com/getsentry/sentry/blob/master/static/app/utils/theme.tsx
- 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")`
- ${Button) {
- border-radius: 0;
- }
- `;
有关其他标签和更多信息,请参阅。
- https://styled-components.com/docs/tooling#interpolation-tagging
状态管理
我们目前使用 Reflux 来管理全局状态。
Reflux 实现了 Flux 概述的单向数据流模式。 Store 注册在 app/stores 下,用于存储应用程序使用的各种数据。 Action 需要在 app/actions 下注册。我们使用 action creator 函数(在 app/actionCreators 下)来分派 action。 Reflux store 监听 action 并相应地更新自己。
我们目前正在探索 Reflux 库的替代方案以供将来使用。
- https://github.com/reflux/refluxjs
- https://facebook.github.io/flux/docs/overview.html
测试
我们正在远离 Enzyme,转而使用 React Testing Library。有关 RTL 提示,请查看此页面。
注意:你的文件名必须是 .spec.jsx 否则 jest 不会运行它!
我们在 setup.js 中定义了有用的 fixtures,使用这些!如果您以重复的方式定义模拟数据,则可能值得添加此文件。routerContext 是一种特别有用的方法,用于提供大多数视图所依赖的上下文对象。
- https://github.com/getsentry/sentry/blob/master/tests/js/setup.ts
Client.addMockResponse 是模拟 API 请求的最佳方式。这是我们的代码, 所以如果它让您感到困惑,只需将 console.log() 语句放入其逻辑中即可!
- https://github.com/getsentry/sentry/blob/master/static/app/__mocks__/api.tsx
我们测试环境中的一个重要问题是,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' 以便被测组件用
- https://emotion.sh/docs/theming
Babel 语法插件
我们决定只使用处于 stage 3(或更高版本)的 ECMAScript 提案(参见 TC39 提案)。此外,因为我们正在迁移到 typescript,我们将与他们的编译器支持的内容保持一致。唯一的例外是装饰器。
- https://github.com/tc39/proposals
新语法
可选链
可选链 帮助我们访问 [嵌套] 对象, 而无需在每个属性/方法访问之前检查是否存在。如果我们尝试访问 undefined 或 null 对象的属性,它将停止并返回 undefined。
https://github.com/tc39/proposal-optional-chaining
语法
可选链操作符拼写为 ?.。它可能出现在三个位置:
- 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
如果我们使用空值合并
- https://github.com/tc39/proposal-nullish-coalescing
- 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。
- https://github.com/getsentry/sentry/pull/15521
我们更喜欢使用可选链和空值合并而不是来自 lodash/get 的 get。
Typescript
- Typing DefaultProps
迁移指南
- Grid-Emotion
Storybook Styleguide
引用其文档,“Storybook 是用于 UI 组件的 UI 开发环境。有了它,您可以可视化 UI 组件的不同状态并以交互方式开发它们。”
更多细节在这里:
- https://storybook.js.org/
我们使用它吗?
是的!我们将 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。
- https://storybook.sentry.dev
Typing DefaultProps
由于 Typescript 3.0 默认 props 可以更简单地输入。有几种不同的方法适合不同的场景。
类(Class)组件
- import React from 'react';
-
- type DefaultProps = {
- size: 'Small' | 'Medium' | 'Large'; // 这些不应标记为可选
- };
-
- // 没有 Partial
- type Props = DefaultProps & {
- name: string;
- codename?: string;
- };
-
- class Planet extends React.Component
{ - // 没有 Partial
因为它会将所有内容标记为可选 - static defaultProps: DefaultProps = {
- size: 'Medium',
- };
-
- render() {
- const {name, size, codename} = this.props;
-
- return (
-
- {name} is a {size.toLowerCase()} planet.
- {codename && ` Its codename is ${codename}`}
-
- );
- }
- }
-
- const 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
因为它会将所有内容标记为可选 -
- class Planet extends React.Component
{ - static defaultProps = defaultProps;
-
- render() {
- const {name, size, codename} = this.props;
-
- return (
-
- {name} is a {size.toLowerCase()} planet. Its color is{' '}
- {codename && ` Its codename is ${codename}`}
-
- );
- }
- }
-
- const 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
稍微好一点 - // https://github.com/typescript-cheatsheets/react-typescript-cheatsheet#function-components
- const Planet = ({name, size = 'Medium', codename}: Props) => {
- return (
-
- {name} is a {size.toLowerCase()} planet.
- {codename && ` Its codename is ${codename}`}
-
- );
- };
-
- const planet =
name ="Mars" />;
参考
- Typescript 3.0 Release notes
- https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#support-for-defaultprops-in-jsx
- Stack Overflow question on typing default props
- https://stackoverflow.com/questions/37282159/default-property-value-in-react-component-using-typescript/37282264#37282264
使用 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。此外,可以利用虫洞状态管理模式来公开共享状态和突变函数。
- https://swizec.com/blog/wormhole-state-management
使用自定义 hooks
可以创建自定义 hooks 来共享应用程序中的可重用逻辑。创建自定义 hook 时,函数名称必须遵循约定,以 “use” 开头(例如 useTheme), 并且可以在自定义 hooks 内调用其他 hooks。
注意 hooks 的规则和注意事项
React hooks 有一些规则。请注意 hooks 创建的规则和限制。我们使用 ESLint 规则来防止大多数 hook 规则被非法侵入。
- https://reactjs.org/docs/hooks-rules.html
此外,我们建议您尽量少使用 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
- https://github.com/testing-library/eslint-plugin-jest-dom
- eslint-plugin-testing-library
- https://github.com/testing-library/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() 及其浏览器扩展。
- https://testing-playground.com/
不要忘记,你可以在测试中的任何地方放置 screen.debug() 来查看当前的 DOM。
在官方文档中阅读有关查询的更多信息。
- https://testing-library.com/docs/queries/about/
技巧
避免从 render 方法中解构查询函数,而是使用 screen(examples)。当您添加/删除您需要的查询时,您不必使 render 调用解构保持最新。您只需要输入 screen 并让您的编辑器的自动完成功能处理其余的工作。
- https://github.com/getsentry/sentry/pull/29312
- import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
-
- // ❌
- const { getByRole } = mountWithTheme(
); - const errorMessageNode = getByRole("alert");
-
- // ✅
- mountWithTheme(
); - const errorMessageNode = screen.getByRole("alert");
除了检查不存在(examples)之外,避免将 queryBy... 用于任何事情。如果没有找到元素,getBy... 和 findBy... 变量将抛出更有用的错误消息。
- https://github.com/getsentry/sentry/pull/29517
- import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
-
- // ❌
- mountWithTheme(
); - expect(screen.queryByRole("alert")).toBeInTheDocument();
-
- // ✅
- mountWithTheme(
); - expect(screen.getByRole("alert")).toBeInTheDocument();
- expect(screen.queryByRole("button")).not.toBeInTheDocument();
避免使用 waitFor 等待出现,而是使用 findBy...(examples)。这两个基本上是等价的(findBy... 甚至在其里面使用了 waitFor),但是 findBy... 更简单,我们得到的错误信息也会更好。
- https://github.com/getsentry/sentry/pull/29544
- import {
- mountWithTheme,
- screen,
- waitFor,
- } from "sentry-test/reactTestingLibrary";
-
- // ❌
- mountWithTheme(
); - await waitFor(() => {
- expect(screen.getByRole("alert")).toBeInTheDocument();
- });
-
- // ✅
- mountWithTheme(
); - expect(await screen.findByRole("alert")).toBeInTheDocument();
避免使用 waitFor 等待消失,使用 waitForElementToBeRemoved 代替(examples)。
- https://github.com/getsentry/sentry/pull/29547
后者使用 MutationObserver,这比使用 waitFor 定期轮询 DOM 更有效。
- import {
- mountWithTheme,
- screen,
- waitFor,
- waitForElementToBeRemoved,
- } from "sentry-test/reactTestingLibrary";
-
- // ❌
- mountWithTheme(
); - await waitFor(() =>
- expect(screen.queryByRole("alert")).not.toBeInTheDocument()
- );
-
- // ✅
- mountWithTheme(
); - await waitForElementToBeRemoved(() => screen.getByRole("alert"));
更喜欢使用 jest-dom 断言(examples)。使用这些推荐的断言的优点是更好的错误消息、整体语义、一致性和统一性。
- https://github.com/getsentry/sentry/pull/29508
- import { mountWithTheme, screen } from "sentry-test/reactTestingLibrary";
-
- // ❌
- mountWithTheme(
); - expect(screen.getByRole("alert")).toBeTruthy();
- expect(screen.getByRole("alert").textContent).toEqual("abc");
- expect(screen.queryByRole("button")).toBeFalsy();
- expect(screen.queryByRole("button")).toBeNull();
-
- // ✅
- mountWithTheme(
); - 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(
); - expect(screen.getByText("Hello World")).toBeInTheDocument();
-
- // ✅
- mountWithTheme(
); - expect(screen.getByText(/hello world/i)).toBeInTheDocument();
尽可能在 fireEvent 上使用 userEvent。 userEvent 来自 @testing-library/user-event 包,它构建在 fireEvent 之上,但它提供了几种更类似于用户交互的方法。
- // ❌
- import {
- mountWithTheme,
- screen,
- fireEvent,
- } from "sentry-test/reactTestingLibrary";
- mountWithTheme(
); - fireEvent.change(screen.getByLabelText("Search by name"), {
- target: { value: "sentry" },
- });
-
- // ✅
- import {
- mountWithTheme,
- screen,
- userEvent,
- } from "sentry-test/reactTestingLibrary";
- mountWithTheme(
); - userEvent.type(screen.getByLabelText("Search by name"), "sentry");
迁移 - grid-emotion
grid-emotion 已经被弃用一年多了,新项目是 reflexbox。为了升级到最新版本的 emotion,我们需要迁移出 grid-emotion。
要迁移,请使用 emotion 将导入的
组件
用下面的替换组件,然后删除必要的 props 并移动到 styled component。
- const Flex = styled('div')`
- display: flex;
- `;
- const Box = styled('div')`
- `;
属性
如果您正在修改导出的组件,请确保通过该组件的代码库进行 grep 以确保它没有被渲染为特定于 grid-emotion 的附加属性。示例是
margin 和 padding
旧 (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