前端
实体加载器
如果您正在开发一个新功能,或者通常需要访问前端的应用程序数据,那么实体加载器将是您的好帮手。它们抽象了 API 调用、加载和错误状态的处理、缓存先前加载的对象、使缓存失效(在某些情况下),并允许您轻松地执行更新或创建新项。
实体加载器的良好用途
- 我需要获取一个特定的 X(用户、数据库等)并显示它。
- 我需要获取一个 X(数据库、问题等)的列表并显示它。
当前可用的实体
- 问题、仪表板、脉冲
- 集合
- 数据库、表、字段、段、指标
- 用户、组
- 完整的当前实体列表请参见:https://github.com/metabase/metabase/tree/master/frontend/src/metabase/entities
有两种使用加载器的方法,一种是作为 React 的“渲染属性”组件,另一种是作为 React 组件类装饰器(“高阶组件”)。
对象加载
在此示例中,我们将为一个新页面加载特定数据库的信息。
import React from "react";
import Databases from "metabase/entities/databases";
@Databases.load({ id: 4 })
class MyNewPage extends React.Component {
render() {
const { database } = this.props;
return (
<div>
<h1>{database.name}</h1>
</div>
);
}
}
此示例使用类装饰器来请求并显示 ID 为 4 的数据库。如果您想使用渲染属性组件,代码将如下所示。
import React from "react";
import Databases from "metabase/entities/databases";
class MyNewPage extends React.Component {
render() {
const { database } = this.props;
return (
<div>
<Databases.Loader id={4}>
{({ database }) => <h1>{database.name}</h1>}
</Databases.Loader>
</div>
);
}
}
现在,您很可能不只是想显示一个静态项,因此对于您可能需要的一些值是动态的情况,您可以使用函数来获取 props 并返回所需的值。如果您使用的是组件方法,则可以像往常一样为动态值传递 props。
@Databases.load({
id: (state, props) => props.params.databaseId
}))
列表加载
加载项目列表就像应用 loadList 装饰器一样简单。
import React from "react";
import Users from "metabase/entities/users";
@Users.loadList()
class MyList extends React.Component {
render() {
const { users } = this.props;
return <div>{users.map((u) => u.first_name)}</div>;
}
}
与对象加载器的 id 参数类似,您也可以传递一个 query 对象(如果 API 支持)。
@Users.loadList({
query: (state, props) => ({ archived: props.showArchivedOnly })
})
对加载和错误状态的控制
默认情况下,EntityObject 和 EntityList 加载器都会为您处理加载状态,通过在内部使用 LoadingAndErrorWrapper。如果您出于某种原因想自己处理加载,可以通过设置 loadingAndErrorWrapper: false 来禁用此行为。
包装后的对象
如果您将 wrapped: true 传递给加载器,那么对象或对象将被包装成辅助类,使您能够执行 user.getName()、user.delete() 或 user.update({ name: "new name" ) 等操作。操作会自动绑定到 dispatch。
如果对象很多,这可能会带来性能开销。
在实体的 objectSelectors 或 objectActions 中定义的任何其他选择器和操作都会出现在包装后对象的相应方法中。
高级用法
您还可以直接使用 Redux 操作和选择器,例如,dispatch(Users.actions.loadList()) 和 Users.selectors.getList(state)。
风格指南
设置 Prettier
我们使用 Prettier 来格式化我们的 JavaScript 代码,CI 会强制执行此规则。我们建议将编辑器设置为“保存时格式化”。您也可以使用 yarn prettier 来格式化代码,并使用 yarn lint-prettier 来验证其格式是否正确。
我们使用 ESLint 来强制执行其他规则。它已集成到 Webpack 构建中,或者您可以手动运行 yarn lint-eslint 进行检查。
React 和 JSX 风格指南
在大多数情况下,我们遵循 Airbnb React/JSX 风格指南。ESLint 和 Prettier 应该可以处理 Airbnb 风格指南中的大部分规则。本文档将另行说明例外情况。
- 优先使用 React 函数组件而非类组件
- 避免在
containers文件夹中创建新组件,因为这种方法已被弃用。相反,将已连接和视图组件都存储在components文件夹中,以实现更统一高效的组织。如果一个已连接组件的大小显著增加,并且您需要提取一个视图组件,请选择使用View后缀。 - 对于控制组件,我们通常使用
value和onChange。具有选项的控件(例如Radio、Select)通常接受一个包含name和value属性的对象数组。 - 命名为
FooModal和FooPopover的组件通常指代模态框/弹出框的*内容*,这些内容应在Modal/ModalWithTrigger或Popover/PopoverWithTrigger中使用。 -
命名为
FooWidget的组件通常包含一个FooPopover,它被包裹在一个PopoverWithTrigger中,并带有一个触发器元素,通常是FooName。 - 如果需要在类中绑定方法,请使用箭头函数实例属性(而不是在构造函数中使用
this.method = this.method.bind(this);),但前提是该函数需要被绑定(例如,如果您将其作为 prop 传递给 React 组件)。
class MyComponent extends React.Component {
constructor(props) {
super(props);
// NO:
this.handleChange = this.handleChange.bind(this);
}
// YES:
handleChange = (e) => {
// ...
};
// no need to bind:
componentDidMount() {}
render() {
return <input onChange={this.handleChange} />;
}
}
- 对于组件的样式,我们目前混合使用了
styled-components和 “原子化”/“实用至上”的 CSS 类。 - 优先使用
grid-styled的Box和Flex组件,而不是原始的div。 - 组件通常应将其
classNameprop 传递给组件的根元素。可以使用classnames包中的cx函数将其与额外的类合并。 - 为了使组件更具可重用性,组件应仅对影响其自身内容布局/样式的组件根元素应用类或样式,但*不应*影响其自身在其父容器中的布局。例如,它可以包含内边距或
flex类,但不应包含外边距或flex-full、full、absolute、spread等。这些应通过消费者className或styleprops 传递,因为消费者知道组件应如何在其内部定位。 - 避免在单个组件中将 JSX 分解为单独的方法调用。优先内联 JSX,以便您能更好地看到
render方法返回的 JSX 与组件的state或props之间的关系。通过内联 JSX,您还可以更好地了解哪些部分应该成为独立的组件,哪些不应该。
// don't do this
render () {
return (
<div>
{this.renderThing1()}
{this.renderThing2()}
{this.state.thing3Needed && this.renderThing3()}
</div>
);
}
// do this
render () {
return (
<div>
<button onClick={this.toggleThing3Needed}>toggle</button>
<Thing2 randomProp={this.props.foo} />
{this.state.thing3Needed && <Thing3 randomProp2={this.state.bar} />}
</div>
);
}
JavaScript 约定
import应按类型排序,通常是:- 外部库(通常
react放在最前面,以及ttags、underscore、classnames等) - Metabase 的顶级 React 组件和容器(
metabase/components/*、metabase/containers/*等) - Metabase 的特定于应用程序此部分的 React 组件和容器(
metabase/*/components/*等) - Metabase 的
lib、entities、services、Redux 文件等
- 外部库(通常
- 优先使用
const而不是let(绝不使用var)。仅当您有特定原因需要重新赋值标识符时才使用let(注意:这现在由 ESLint 强制执行)。 - 对于内联函数,优先使用箭头函数,特别是当您需要引用父作用域中的
this时(几乎不需要const self = this;等),但通常即使不需要(例如array.map(x => x * 2))。 - 对于顶级函数,包括 React 函数组件,优先使用
function声明。例外情况是返回值的单行函数。
// YES:
function MyComponent(props) {
return <div>...</div>;
}
// NO:
const MyComponent = (props) => {
return <div>...</div>;
};
// YES:
const double = (n) => n * 2;
// ALSO OK:
function double(n) {
return n * 2;
}
- 优先使用本地
Array方法,而不是underscore的方法。我们 polyfill 所有 ES6 功能。对于未原生实现的功能,请使用 Underscore。 - 优先使用
async/await,而不是直接使用promise.then(...)等。 - 您可以使用赋值解构或参数解构,但要避免深度嵌套解构,因为它们可能难以阅读,并且
prettier有时会以额外的空格格式化它们。- 避免从“实体”类对象中解构属性,例如,不要这样做
const { display_name } = column; - 不要直接解构
this,例如const { foo } = this.props; const { bar } = this.state;而不是const { props: { foo }, state: { bar } } = this;
- 避免从“实体”类对象中解构属性,例如,不要这样做
- 避免使用嵌套的三元运算符,因为它们通常会导致代码难以阅读。如果您在代码中有取决于字符串值的逻辑分支,请优先使用对象作为映射到多个值(当求值是简单的时候)或
switch语句(当求值更复杂时,例如根据要返回的 React 组件进行分支)。
// don't do this
const foo = str == 'a' ? 123 : str === 'b' ? 456 : str === 'c' : 789 : 0;
// do this
const foo = {
a: 123,
b: 456,
c: 789,
}[str] || 0;
// or do this
switch (str) {
case 'a':
return <ComponentA />;
case 'b':
return <ComponentB />;
case 'c':
return <ComponentC />;
case 'd':
default:
return <ComponentD />;
}
如果您的嵌套三元运算符是求值为布尔值的谓词,请优先使用一个隔离到单独的纯函数的 if/if-else/else 语句。
const foo = getFoo(a, b);
function getFoo(a, b, c) {
if (a.includes("foo")) {
return 123;
} else if (a === b) {
return 456;
} else {
return 0;
}
}
- 在代码库中添加注释时要保守。注释不应被用作提醒或待办事项——请通过在 GitHub 上创建新 issue 来记录这些。理想情况下,代码应以一种能够清晰解释自身的方式编写。当它不能时,您应该首先尝试重写代码。如果出于任何原因您无法清晰地编写内容,请添加注释来解释“原因”。
// don't do this--the comment is redundant
// get the native permissions for this db
const nativePermissions = getNativePermissions(perms, groupId, {
databaseId: database.id,
});
// don't add TODOs -- they quickly become forgotten cruft
isSearchable(): boolean {
// TODO: this should return the thing instead
return this.isString();
}
// this is acceptable -- the implementer explains a not-obvious edge case of a third party library
// foo-lib seems to return undefined/NaN occasionally, which breaks things
if (isNaN(x) || isNaN(y)) {
return;
}
- 避免在 if 语句中使用复杂的逻辑表达式。
// don't do this
if (typeof children === "string" && children.split(/\n/g).length > 1) {
// ...
}
// do this
const isMultilineText =
typeof children === "string" && children.split(/\n/g).length > 1;
if (isMultilineText) {
// ...
}
- 对常量使用全部大写。
// do this
const MIN_HEIGHT = 200;
// also acceptable
const OBJECT_CONFIG_CONSTANT = {
camelCaseProps: "are OK",
abc: 123,
};
- 优先使用命名导出而非默认导出。
// this makes it harder to search for Widget
import Foo from "./Widget";
// do this to enforce using the proper name
import { Widget } from "./Widget";
- 避免使用魔术字符串和数字。
// don't do this
const options = _.times(10, () => ...);
// do this in a constants file
export const MAX_NUM_OPTIONS = 10;
const options = _.times(MAX_NUM_OPTIONS, () => ...);
编写声明式代码
您应该为其他工程师编写代码,因为其他工程师将花费比您编写(和重写)更多的时间来阅读代码。代码越具可读性,就越能告诉计算机“做什么”而不是“怎么做”。避免使用命令式模式,如 for 循环。
// don't do this
let foo = [];
for (let i = 0; i < list.length; i++) {
if (list[i].bar === false) {
continue;
}
foo.push(list[i]);
}
// do this
const foo = list.filter((entry) => entry.bar !== false);
在处理业务逻辑时,您不希望关心语言的细节。例如,与其编写 const query = new Question(card).query();(这涉及到实例化一个新的 Question 实例并在该实例上调用 query 方法),不如引入一个函数,如 getQueryFromCard(card),以便实现者可以避免思考从 card 获取 query 值所涉及的内容。
组件样式树环
CSS 模块
.primary {
color: -var(--mb-color-brand);
}
import S from "./Foo.css";
const Foo = () => <div className={S.primary} />;
Emotion(不推荐)
import styled from "@emotion/styled";
const Foo = styled.div`
color: ${(props) => props.color};
`;
const Bar = ({ color }) => <Foo color={color} />;
弹出框
弹出框是弹出窗口或模态框。
在 Metabase 核心中,它们是视觉响应式的:它们出现在触发其出现的元素之上或之下。它们的高度会自动计算以使其适合屏幕。
在用户旅程中找到弹出框的位置
创建自定义问题时
- 从主页,点击
新建,然后点击问题 - 👀 紧挨着
选择你的起始数据自动打开的选项选择器是一个<Popover />。 - 如果尚未选择,请选择
示例数据库 - 选择任意表,例如
People
在这里,点击以下内容将打开 <Popover /> 组件
选择列(Data部分FieldsPicker控件右侧的箭头)Data部分下方带有加号的灰色网格图标添加筛选器以缩小答案范围选择要查看的指标选择一个要分组的列可视化按钮上方的带有向上和向下箭头的排序图标
单元测试
设置模式
我们使用以下模式来单元测试组件
import React from "react";
import userEvent from "@testing-library/user-event";
import { Collection } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
import { renderWithProviders, screen } from "__support__/ui";
import CollectionHeader from "./CollectionHeader";
interface SetupOpts {
collection: Collection;
}
const setup = ({ collection }: SetupOpts) => {
const onUpdateCollection = jest.fn();
renderWithProviders(
<CollectionHeader
collection={collection}
onUpdateCollection={onUpdateCollection}
/>,
);
return { onUpdateCollection };
};
describe("CollectionHeader", () => {
it("should be able to update the name of the collection", () => {
const collection = createMockCollection({
name: "Old name",
});
const { onUpdateCollection } = setup({
collection,
});
await userEvent.clear(screen.getByDisplayValue("Old name"));
await userEvent.type(screen.getByPlaceholderText("Add title"), "New title");
await userEvent.tab();
expect(onUpdateCollection).toHaveBeenCalledWith({
...collection,
name: "New name",
});
});
});
要点
setup函数renderWithProviders添加了应用程序使用的提供程序,包括redux
请求模拟
我们使用 fetch-mock 来模拟请求。
import fetchMock from "fetch-mock";
import { setupCollectionsEndpoints } from "__support__/server-mocks";
interface SetupOpts {
collections: Collection[];
}
const setup = ({ collections }: SetupOpts) => {
setupCollectionsEndpoints({ collections });
// renderWithProviders and other setup
};
describe("Component", () => {
it("renders correctly", async () => {
setup();
expect(await screen.findByText("Collection")).toBeInTheDocument();
});
});
要点
setup函数- 调用
__support__/server-mocks中的辅助函数来设置您的数据终结点。
阅读其他版本的 Metabase 的文档。