Sentry 開發者貢獻指南 - 前端(ReactJS生態)

為少發表於2021-12-16

內容整理自官方開發文件

系列

目錄

  • 前端手冊
    • 目錄結構
    • 資料夾和檔案結構
      • 檔案命名
      • 使用 index.(j|t)?(sx)
    • React
      • 定義 React 元件
      • 元件與檢視
      • PropTypes
      • 事件處理程式
    • CSS 和 Emotion
      • stylelint 錯誤
    • 狀態管理
    • 測試
      • 選擇器
      • 測試中未定義的 theme 屬性
    • Babel 語法外掛
    • 新語法
      • 可選鏈
      • 語法
      • 空值合併
    • Lodash
    • Typescript
    • 遷移指南
  • Storybook Styleguide
    • 我們使用它嗎?
    • 它部署在某個地方嗎?
  • Typing DefaultProps
    • 類(Class)元件
    • 函式(Function)元件
    • 參考
  • 使用 Hooks
    • 使用庫中的 hooks
    • 使用 react 的內建 hooks
    • 使用 context
    • 使用自定義 hooks
    • 注意 hooks 的規則和注意事項
    • 我們的基礎檢視元件仍然是基於類的
    • 不要為 hooks 重寫
  • 使用 React Testing Library
    • 查詢
    • 技巧
  • 遷移 - grid-emotion
    • 元件
    • 屬性
      • marginpadding
      • flexbox

前端手冊

本指南涵蓋了我們如何在 Sentry 編寫前端程式碼,
並特別關注
Sentry

Getsentry
程式碼庫。
它假設您使用的是
eslint-config-sentry
概述的 eslint 規則;
因此,這裡不會討論由這些 linting 規則強制執行的程式碼風格。

目錄結構

前端程式碼庫當前位於 sentry 中的 src/sentry/static/sentry/appgetentry 中的 static/getsentry 下。 (我們打算在未來與 static/sentry 保持一致。)

資料夾和檔案結構

檔案命名

  • 根據模組的功能或類的使用方式或使用它們的應用程式部分,有意義地命名檔案。
  • 除非必要,否則不要使用字首或字尾(即 dataScrubbingEditModaldataScrubbingAddModal),而是使用像 dataScrubbing/editModal 這樣的名稱。

使用 index.(j|t)?(sx)

在資料夾中有一個 index 檔案提供了一種隱式匯入主檔案而不指定它的方法

index 檔案的使用應遵循以下規則:

  • 如果建立資料夾來對一起使用的元件進行分組,並且有一個入口點元件,它使用分組內的元件(examplesavataridBadge)。入口點元件應該是 index 檔案。

  • 不要使用 index.(j|t)?(sx) 檔案,如果資料夾包含在應用程式的其他部分使用的元件,與入口點檔案無關。(即,actionCreatorspanels

  • 不要僅僅為了重新匯出而使用 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;

一些較舊的元件使用 createReactClassmixins,但這已被棄用。

元件與檢視

app/components/app/views 資料夾都包含 React 元件。

  • 使用通常不會在程式碼庫的其他部分重用的 UI 檢視。
  • 使用設計為高度可重用的 UI 元件。

元件應該有一個關聯的 .stories.js 檔案來記錄它應該如何使用。

使用 yarn storybook 在本地執行 Storybook 或在 https://storybook.getsentry.net/ 上檢視託管版本

PropTypes

使用它們,要明確,儘可能使用共享的自定義屬性。

更傾向 Proptypes.arrayOf 而不是 PropTypes.arrayPropTypes.shape 而不是 PropTypes.object

如果你使用一組重要的、定義良好的 key(你的元件依賴)傳遞物件,那麼使用 PropTypes.shape 顯式定義它們:

PropTypes.shape({
  username: PropTypes.string.isRequired,
  email: PropTypes.string
})

如果您要重複使用自定義 prop-type 或傳遞常見的共享 shape(如 organizationprojectuser),
請確保從我們有用的自定義集合中匯入 proptype

事件處理程式

我們使用不同的字首來更好地區分事件處理程式事件回撥屬性

對事件處理程式使用 handle 字首,例如:

<Button onClick={this.handleDelete}/>

對於傳遞給元件的事件回撥屬性,請使用 on 字首,例如:

<Button onClick={this.props.onDelete}>

CSS 和 Emotion

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(例如FlexBox)已被棄用,請避免在新程式碼中使用。

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 屬性

而不是使用來自 enzymemount() ...使用這個:import {mountWithTheme} from 'sentry-test/enzyme' 以便被測元件用 <ThemeProvider>

Babel 語法外掛

我們決定只使用處於 stage 3(或更高版本)的 ECMAScript 提案(參見 TC39 提案)。
此外,因為我們正在遷移到 typescript,我們將與他們的編譯器支援的內容保持一致。唯一的例外是裝飾器。

新語法

可選鏈

可選鏈 幫助我們訪問 [巢狀] 物件,
而無需在每個屬性/方法訪問之前檢查是否存在。
如果我們嘗試訪問 undefinednull 物件的屬性,它將停止並返回 undefined

語法

可選鏈操作符拼寫為 ?.。它可能出現在三個位置:

obj?.prop       // 可選的靜態屬性訪問
obj?.[expr]     // 可選的動態屬性訪問
func?.(...args) // 可選的函式或方法呼叫

來自 https://github.com/tc39/proposal-optional-chaining

空值合併

這是一種設定“預設”值的方法。例如:以前你會做類似的事情

let x = volume || 0.5;

這是一個問題,因為 0volume 的有效值,但因為它的計算結果為 false -y,我們不會使表示式短路,並且 x 的值為 0.5

如果我們使用空值合併

let x = volume ?? 0.5

如果 volumenullundefined,它只會預設為 0.5

語法

基本情況。如果表示式在 ?? 的左側運算子計算為 undefinednull,則返回其右側。

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 包匯入 lodash 實用程式。
有一個 eslint 規則來確保這不會發生。
而是直接匯入實用程式,例如 import isEqual from 'lodash/isEqual';

以前我們使用了 lodash-webpack-plugin
babel-plugin-lodash 的組合,
但是在嘗試使用新的 lodash 實用程式(例如這個 PR)時很容易忽略這些外掛和配置。
通過 webpack tree shakingeslint 強制執行,我們應該能夠保持合理的包大小。

有關更多資訊,請參閱此 PR

我們更喜歡使用可選鏈空值合併而不是來自 lodash/getget

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" />;

參考

使用 Hooks

為了使元件更易於重用和更易於理解,ReactReact 生態系統一直趨向於函式式元件和 hooks
Hooks 是一種向功能元件新增狀態副作用的便捷方式。它們還為庫提供了一種公開行為的便捷方式。

雖然我們通常支援 hooks,但我們有一些關於 hooks 應該如何與 Sentry 前端一起使用的建議。

使用庫中的 hooks

如果一個庫提供了 hooks,你應該使用它們。
通常,這將是使用庫的唯一方法。
例如,dnd-kit 通過鉤子公開了它的所有原語(primitives),我們應該按照預期的方式使用該庫。

我們不喜歡使用不用 hooks 的庫。
相反,與具有更大、更復雜的 API 或更大的包大小的庫相比,
更喜歡具有更清晰、更簡單的 API 和更小的包大小的庫。

使用 react 的內建 hooks

useState, useMemo, useCallback, useContextuseRef hooks 在任何函式式元件中都是受歡迎的。
在需要少量狀態或訪問 react 原語(如引用和上下文)的展示元件中,它們通常是一個不錯的選擇。
例如,具有滑出(slide-out)可展開狀態(expandable state)的元件。

useEffect hook 更復雜,您需要小心地跟蹤您的依賴項並確保通過清理回撥取消訂閱。
應避免 useEffect 的複雜鏈式應用程式,此時 'controller' 元件應保持基於類(class)。

同樣,useReducer 鉤子與目前尚未確定的狀態管理重疊。
我們希望避免 又一個 狀態管理模式,因此此時避免使用useReducer

使用 context

當我們計劃遠離 Reflux 的路徑時,useContext hook 提供了一個更簡單的實現選項來共享狀態和行為。
當您需要建立新的共享狀態源時,請考慮使用 contextuseContext 而不是 Reflux
此外,可以利用蟲洞狀態管理模式來公開共享狀態突變函式

使用自定義 hooks

可以建立自定義 hooks 來共享應用程式中的可重用邏輯。
建立自定義 hook 時,函式名稱必須遵循約定,以 “use” 開頭(例如 useTheme),
並且可以在自定義 hooks 內呼叫其他 hooks

注意 hooks 的規則和注意事項

React hooks一些規則
請注意 hooks 建立的規則和限制。
我們使用 ESLint 規則來防止大多數 hook 規則被非法侵入。

此外,我們建議您儘量少使用 useEffect
使用多個 useEffect 回撥錶示您有一個高度有狀態的元件,
您應該使用類(class)元件來代替。

我們的基礎檢視元件仍然是基於類的

我們的基礎檢視元件(AsyncViewAsyncComponent)是基於類的,並且會持續很長時間。
在構建檢視時請記住這一點。
您將需要額外的 wrapper 元件來訪問 hooks 或將 hook state 轉換為您的 AsyncComponentprops

不要為 hooks 重寫

雖然 hooks 可以在新程式碼中符合人體工程學,但我們應該避免重寫現有程式碼以利用 hooks
重寫需要時間,使我們面臨風險,並且為終端使用者提供的價值很小。

如果您需要重新設計一個元件以使用庫中的 hooks,那麼還可以考慮從一個類轉換為一個函式元件。

使用 React Testing Library

我們正在將我們的測試從 Enzyme 轉換為 React Testing Library
在本指南中,您將找到遵循最佳實踐和避免常見陷阱的技巧。

我們有兩個 ESLint 規則來幫助解決這個問題:

我們努力以一種與應用程式使用方式非常相似的方式編寫測試。

我們不是處理渲染元件的例項,而是以與使用者相同的方式查詢 DOM
我們通過 label 文字找到表單元素(就像使用者一樣),我們從他們的文字中找到連結和按鈕(就像使用者一樣)。

作為此目標的一部分,我們避免測試實現細節,因此重構(更改實現但不是功能)不會破壞測試。

我們通常贊成用例覆蓋而不是程式碼覆蓋

查詢

  • 儘可能使用 getBy...
  • 僅在檢查不存在時使用 queryBy...
  • 僅當期望元素在可能不會立即發生的 DOM 更改後出現時才使用 await findBy...

為確保測試類似於使用者與我們的程式碼互動的方式,我們建議使用以下優先順序進行查詢:

  1. getByRole - 這應該是幾乎所有東西的首選選擇器。
    作為這個選擇器的一個很好的獎勵,我們確保我們的應用程式是可訪問的。
    它很可能與 name 選項 getByRole('button', {name: /save/i}) 一起使用。
    name 通常是表單元素的 labelbutton 的文字內容,或 aria-label 屬性的值。
    如果不確定,請使用 logRoles 功能
    或查閱可用角色列表
  1. getByLabelText/getByPlaceholderText - 使用者使用 label 文字查詢表單元素,因此在測試表單時首選此選項。
  2. getByText - 在表單之外,文字內容是使用者查詢元素的主要方式。此方法可用於查詢非互動式元素(如 divspanparagraph)。
  3. getByTestId - 因為這不反映使用者如何與應用互動,所以只推薦用於不能使用任何其他選擇器的情況

如果您仍然無法決定使用哪個查詢,
請檢視 testing-playground.com
以及 screen.logTestingPlaygroundURL() 及其瀏覽器擴充套件。

不要忘記,你可以在測試中的任何地方放置 screen.debug() 來檢視當前的 DOM

官方文件中閱讀有關查詢的更多資訊。

技巧

避免從 render 方法中解構查詢函式,而是使用 screenexamples)。
當您新增/刪除您需要的查詢時,您不必使 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')`
`;

props

如果您正在修改匯出的元件,請確保通過該元件的程式碼庫進行 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

相關文章