65.9K
CodeProject 正在变化。 阅读更多。
Home

如何在 React Redux 应用程序中创建可编辑的数据网格

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2020年9月14日

CPOL

15分钟阅读

viewsIcon

12781

在本文中,我们探讨了一个名为 ImmutabilityProvider 的 FlexGrid 扩展组件,它解决了这样一个问题:如果你想使用 Redux,就必须放弃可编辑的数据网格。

本文附有此示例

开发者钟爱 Redux

Redux 是当今一种流行的应用程序架构,尤其是在 React 社区中。它鼓励开发者使用单向数据流,并在一个统一的地方——全局 Redux Store 中,通过 Redux reducer 来应用数据变更。这种架构据称能使应用程序更可靠、更易于维护。因此,许多开发团队选择它作为其应用程序架构的基础。

此范式的一个关键要求是保持数据不可变性。所有对数据的更改都必须通过在 Redux reducer 中克隆现有数据来完成。如果需要向数组中添加一个项目,应创建一个包含新项目的该数组的克隆。如果需要更改项目的属性,应创建一个包含该属性新值的该项目的克隆。绝不能直接修改现有的数组和对象!

应用程序青睐可编辑的数据网格

与此同时,开发者希望使用数据网格组件作为其应用程序 UI 的一部分。数据网格组件是任何 Web UI 组件库的关键部分,这已不是什么秘密。对此有一个简单的解释。数据网格不仅能让应用程序的用户以便捷紧凑的形式查看复杂的表格数据,并高效地执行数据转换(如排序、分组和筛选),还能让用户像在 Microsoft Excel 中编辑数据一样编辑数据。

Excel 允许用户直接在数据网格单元格中编辑项目值。您可以添加或删除项目,从 Excel 或其他数据网格复制粘贴数据,清除多个选定的单元格等等。

这使得数据网格成为许多现代 Web 应用程序中的头号常客(有时甚至是主角)。

可编辑的数据网格与 Redux 的冲突

一方面,您想使用基于 Redux 的高级应用程序架构。另一方面,您又想在应用程序 UI 中拥有可编辑的数据网格。您可能会问“有什么问题呢?”,直接用不就行了?这时,我们就遇到了最有趣的一点。

正确的答案是“不行”。(更确切地说,过去的答案是“不行”。)

问题在于,数据网格组件的设计初衷是直接修改它们所绑定的数据。这是可以理解的;对于任何“常规”应用程序来说,这正是其所需。但这与 Redux 及其对数据不可变性的要求相悖。

因此,您面临一个两难的境地:如果想用 Redux,就必须放弃可编辑的数据网格。这样一来,您应用程序提供的用户体验就会大打折扣。

听起来很糟糕,先进的应用程序架构却不允许您使用先进的 UI 组件。而且没有明显的解决方案。像 Immer 和 Immutable 这样的专门库可以帮助您解决在 Redux reducer 中可能遇到的问题,但无法解决这个特定的问题。

该怎么办?放弃酷炫的 UI 吗?有了 FlexGrid 就不用!

FlexGrid 拥抱 Redux

我们 Wijmo 团队认识到了这个问题(值得一提的是,这也得益于我们客户的帮助),并引入了一个非常易于使用的 FlexGrid 扩展组件,名为 ImmutabilityProvider。将其应用于 FlexGrid 组件后,它会以下列方式改变其行为:

  • 在保持数据网格所有数据编辑功能的同时,防止其修改底层数据。也就是说,用户可以通过 FlexGrid 以各种可能的方式编辑数据,但来自 Redux Store 的底层数据数组仍然是不可变的。
  • 当用户在 FlexGrid 中编辑数据时,ImmutabilityProvider 会触发一个特殊事件,该事件包含有关变更的信息,可用于向 Redux Store 派发数据变更操作。

现在让我们看看它是如何工作的。

ImmutabilityProvider

在组件的 `render` 方法中添加一个数据绑定的 FlexGrid 控件的最简单的 JSX 可能如下所示:

<FlexGrid itemsSource={this.props.items}>
</FlexGrid>

当用户通过数据网格编辑数据时,这个数据网格会修改绑定到其 `itemsSource` 属性的数据数组。要改变这种行为并强制 FlexGrid 停止修改底层数据,我们将 ImmutabilityProvider React 组件嵌套在 FlexGrid 组件中,如下所示:

<FlexGrid>
    <ImmutabilityProvider 
        itemsSource={this.props.items}
        dataChanged={this.onGridDataChanged} />
</FlexGrid>

请注意,`itemsSource` 属性现在指定在 ImmutabilityProvider 组件上,而不是 FlexGrid 上。我们还为 `dataChanged` 事件定义了一个处理程序,它会通知我们数据网格中由于用户编辑而发生的三种可能的数据变更类型:

  • 现有项目的属性值已更改
  • 添加了新项目
  • 项目被删除

当此事件被触发时,尽管在视觉上一切看起来像是数据已经改变,但底层的项目数组(包括数组本身及其项目的属性)仍然保持不变。

我们利用这个事件向 Redux Store 派发相应的数据变更操作,以将用户所做的更改应用到全局应用程序状态。事件处理程序可能如下所示:

onGridDataChanged(s: ImmutabilityProvider, e: DataChangeEventArgs) {
    switch (e.action) {
        case DataChangeAction.Add:
            this.props.addItemAction(e.newItem);
            break;
        case DataChangeAction.Remove:
            this.props.removeItemAction(e.newItem, e.itemIndex);
            break;
        case DataChangeAction.Change:
            this.props.changeItemAction(e.newItem, e.itemIndex);
            break;
        default:
            throw 'Unknown data action'
    }
}

根据 FlexGrid 中发生的数据变更类型(添加、移除或更改),事件处理程序将向 Redux Store 的 reducer 派发一个相应的操作。reducer 会用一个包含已派发变更的数组克隆来更新全局状态。由于这个数组直接绑定到 `ImmutabilityProvider.itemsSource` 属性,后者将检测到变化并使 FlexGrid 刷新其内容以反映 Store 中发生的变化。

尽管数据流看起来复杂,但即使在相当大的数据集上,性能也很好。用户所做的更改几乎是即时应用的。

通过这种方法,在 Redux 应用程序中使用数据网格作为数据编辑控件,变得几乎和使用单值输入控件(如原生 `input` 元素,或专门的 `InputNumber`、`InputDate` 等)一样简单。您只需将控件的值属性绑定到全局状态属性,并在控件的“值已更改”事件中派发一个带有新值的操作即可。

更多 React-Redux 细节

现在,让我们更详细地了解如何创建一个简单的应用程序,该应用程序使用数据网格来显示和编辑来自 Redux Store 的数组。为了演示,我们将使用这个示例。它被有意设计得非常简单,以便您更容易理解我们所讨论问题的解决方案的精髓。未来,我们计划添加更多示例来演示 FlexGrid 和其他 Wijmo 控件在 Redux 应用程序中使用的各个方面。如果您有任何特别想让我们重点介绍的问题,请随时与我们分享您的建议!

该示例试图遵循标准的 React-Redux 应用程序结构,但采用了扁平化的文件夹结构,以更好地适应 Wijmo 在线演示网站。此外,由于演示网站对示例设置的要求,它使用 SystemJS 运行时加载器来加载模块,而不是 Webpack 或类似的打包工具。

该应用程序只有一个视图,包含两个 FlexGrid 控件。顶部的是可编辑数据网格,由 ImmutabilityProvider 组件控制,它绑定到 Redux Store 中的数组。这就是我们用来检验本文所讨论功能的数据网格。您可以通过键盘输入来编辑单元格值,使用网格行列表末尾的“新行”来添加新项目,或者通过选择项目并按 Delete 键来删除它们。

您还可以从剪贴板粘贴数据,或清除所选单元格范围内的多个单元格值。

需要特别指出的是,数据网格中显示的数据数组中的所有项目都使用 `Object.freeze()` 函数进行了冻结,以确保在您进行编辑时数据网格不会修改数据。

除了编辑之外,您还可以根据需要转换数据——通过点击列标题进行排序,通过将列标题拖动到数据网格上方的分组面板来进行分组,以及通过点击列标题中的筛选器图标来进行筛选。

第二个数据网格是只读的。它不使用 ImmutabilityProvider,而是通过其 `itemsSource` 属性直接绑定到 Store 的数组。这个数据网格可以帮助您检查通过顶部数据网格所做的更改是如何应用到 Redux Store 的。

在顶部数据网格上方还有一个菜单,允许您更改数据数组的大小。小数组便于检查您的更改是如何应用到 Store 的。大数组则可用于评估编辑过程的性能。您可以选择与您实际应用程序中预期相似的几个项目,并评估其工作情况。

状态

初始应用程序的全局状态在 reducers.jsx 文件中定义如下:

const itemCount = 5000;
const initialState = {
    itemCount,
    items: getData(itemCount),
    idCounter: itemCount
}

它包含一个带有随机生成数据的 `items` 数组,以及几个辅助属性——`itemCount` 定义了数组中的项目数,`idCounter` 存储了一个唯一 id 值,用于赋给新添加到 `items` 数组中项目的 id 属性。`items` 数组就是示例数据网格所表示的那个。
数组中的每个项目都使用 `Object.freeze()` 函数进行冻结,以确保真正满足 Redux 强制要求的数据不可变性。

Actions

Redux action creator 函数在 actions.jsx 文件中定义:

export const addItemAction = (item) => ({
    type: 'ADD_ITEM',
    item
});

export const removeItemAction = (item, index) => ({
    type: 'REMOVE_ITEM',
    item,
    index
});

export const changeItemAction = (item, index) => ({
    type: 'CHANGE_ITEM',
    item,
    index
});

export const changeCountAction = (count) => ({
    type: 'CHANGE_COUNT',
    count
});

有三个用于在 `items` 数组上执行数据变更操作的 action(`ADD_ITEM`、`REMOVE_ITEM` 和 `CHANGE_ITEM`),以及一个额外的 `CHANGE_COUNT` action,它会使 Store 创建一个具有不同项目数量的全新 `items` 数组。

每个 action 都由一个 action creator 函数表示。这些函数在 `ImmutabilityProvider.dataChanged` 事件处理程序(在 GridView 展示组件中)中被调用,以通知 Store 数据网格中发生的数据变更。

对于项目变更 action,`index` 属性包含受影响项目在 `items` 数组中的索引。而 `item` 属性存储了对项目对象的引用。更多相关内容将在下面的 Reducer 主题中讨论。

Reducer

该应用程序定义了一个单一的 reducer,根据上述 action 执行对应用程序全局状态的所有更新。它在 reducers.jsx 文件中实现:

export const appReducer = (state = initialState, action) => {
    switch (action.type) {
        case 'ADD_ITEM':
            {
                // make a clone of the new item which will be added to the
                // items array, and assigns its 'id' property with a unique value.
                let newItem = Object.freeze(copyObject({}, action.item, 
                        { id: state.idCounter }));
                return copyObject({}, state, {
                    // items array clone with a new item added
                    items: state.items.concat([newItem]),
                    // increment 'id' counter
                    idCounter: state.idCounter + 1
                });
            }
        case 'REMOVE_ITEM':
            {
                let items = state.items,
                    index = action.index;
                return copyObject({}, state, {
                    // items array clone with the item removed
                    items: items.slice(0, index).concat(items.slice(index + 1))
                });
            }
        case 'CHANGE_ITEM':
            {
                let items = state.items,
                    index = action.index,
                    oldItem = items[index],
                    // create a cloned item with the property changes applied
                    clonedItem = Object.freeze(copyObject({}, oldItem, action.item));
                return copyObject({}, state, {
                    // items array clone with the updated item
                    items: items.slice(0, index).
                        concat([clonedItem]).
                        concat(items.slice(index + 1))
                });
            }
        case 'CHANGE_COUNT':
            {
                // create a brand new state with a new data
                let ret = copyObject({}, state, {
                    itemCount: action.count,
                    items: getData(action.count),
                    idCounter: action.count
                });
                return ret;
            }
        default:
            return state;
    }
}

正如 Redux 所要求的,我们从不修改现有的 `items` 数组,也不修改其项目的属性。如果添加或删除一个项目,我们会创建一个添加或移除了该项目的新数组克隆。如果 action 指示我们更新现有项目的属性,我们会创建一个新数组,其中更新后的项目被替换为已更改项目的克隆。

这个克隆的项目拥有已更改属性的新值。我们使用 @grapecity/wijmo.grid.immutable 模块中的 `copyObject` 函数来克隆对象。如果浏览器实现了 `Object.assign` 函数,它会有效地使用该函数,否则(例如在 IE 中)会回退到自定义实现。

为了处理 `REMOVE_ITEM` 和 `CHANGE_ITEM` action,我们需要知道 `items` 数组中受此变更影响的现有项目和/或其索引。在本示例中,我们使用最简单、最快的方式来实现:项目的索引通过 action 数据的 `index` 属性传递(`ImmutabilityProvider.dataChanged` 事件会为您带来此信息!)。

如果由于某种原因这种方法不适用于您,您也可以选择在 action 数据中传递即将更改的原始项目,然后使用 `items.indexOf()` 方法找到其索引。或者,如果按项目 id 搜索对您更有意义,也可以这样做。

对于 `CHANGE_ITEM` action,您不仅需要知道即将更改的现有项目,还需要知道项目的新属性值。`ImmutabilityProvider.dataChanged` 事件的数据也带来了此信息,它为您提供了一个包含新项目属性值的克隆对象。这个克隆对象在 action 的 `item` 属性中传递,并由 reducer 用于创建一个具有新属性值的新克隆项目,以在克隆的 `items` 数组中替换旧项目。

请注意,对于添加到克隆的 `items` 数组中的任何克隆项目,我们都会调用 `Object.freeze` 来保护该项目免受意外的无意修改。

视图组件 - 展示型与容器型

该示例的 UI 在 GridView.jsx 文件中的单个 GridView 展示型组件中实现。并且,按照 React Redux 绑定的惯例,我们将其与在 GridViewContainer.jsx 文件中实现的容器型组件——GridViewContainer 一起使用。后者只是前者的一个包装器,旨在为展示型 GridView 提供来自 Redux Store 的必要数据。

这些数据是在数据网格中表示的 `items` 数组,以及 action creator 函数(`addItemAction`、`removeItemAction` 等)。它们将作为 props 通过 `this.props` 对象提供给 GridView

以下是 `GridViewContainer` 的实现方式:

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { GridView } from './GridView';
import { addItemAction, removeItemAction, changeItemAction, changeCountAction } from './actions';


const mapStateToProps = state => ({
    items: state.items,
    itemCount: state.itemCount
})
const mapDispatchToProps = dispatch => {
    return bindActionCreators(
        { 
            addItemAction, removeItemAction, changeItemAction, changeCountAction 
        }, 
        dispatch
    );
};

export const GridViewContainer = connect(
    mapStateToProps,
    mapDispatchToProps
  )(GridView);

GridView 展示型组件使用组件 `render` 方法中的以下代码添加了一个 FlexGrid 组件及其关联的 ImmutabilityProvider

import * as wjFlexGrid from '@grapecity/wijmo.react.grid';
import * as wjGridFilter from '@grapecity/wijmo.react.grid.filter';
import { DataChangeEventArgs, DataChangeAction } from '@grapecity/wijmo.grid.immutable';
import { ImmutabilityProvider } from '@grapecity/wijmo.react.grid.immutable';
....


<wjFlexGrid.FlexGrid
        allowAddNew 
        allowDelete
        initialized={this.onGridInitialized}>
    <ImmutabilityProvider 
        itemsSource={this.props.items}
        dataChanged={this.onGridDataChanged} />
    <wjGridFilter.FlexGridFilter/>
    <wjFlexGrid.FlexGridColumn binding="id" header="ID" width={80} isReadOnly={true}></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="start" header="Date" format="d"></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="end" header="Time" format="t"></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="country" header="Country"></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="product" header="Product"></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="sales" header="Sales" format="n2"></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="downloads" header="Downloads" format="n0"></wjFlexGrid.FlexGridColumn>
    <wjFlexGrid.FlexGridColumn binding="active" header="Active" width={80}></wjFlexGrid.FlexGridColumn>
</wjFlexGrid.FlexGrid>

如您所见,ImmutabilityProvider 的 `itemsSource` 属性绑定到 `this.props.items` 属性,该属性包含来自全局应用程序状态的 `items` 数组。每当 Store reducer 为应用用户更改而生成一个新的数组克隆时,`this.props.items` 将自动更新为新的数组实例,然后 ImmutabilityProvider 将使 FlexGrid 更新其内容以反映这些变化。

每当用户在数据网格中进行并保存数据更改时,ImmutabilityProvider 的 `dataChanged` 事件就会被调用。它绑定到 `onGridDataChanged` 处理函数,该函数的实现如下:

onGridDataChanged(s: ImmutabilityProvider, e: DataChangeEventArgs) {
    switch (e.action) {
        case DataChangeAction.Add:
            this.props.addItemAction(e.newItem);
            break;
        case DataChangeAction.Remove:
            this.props.removeItemAction(e.oldItem, e.itemIndex);
            break;
        case DataChangeAction.Change:
            this.props.changeItemAction(e.newItem, e.itemIndex);
            break;
        default:
            throw 'Unknown data action'
    }
}

该处理程序只是调用了相应的 action creator 函数,这些函数也通过 GridViewContainer 容器组件经由 `this.props` 对象提供给 GridView 组件。
Action 数据从 `DataChangeEventArgs` 类型的事件参数中检索。它带来了有关所执行的更改操作的信息(`action` 属性,可以取 AddRemoveChange 值)、受影响项目在源数组中的索引(`index`),以及对受影响项目的引用(`oldItem` 或 `newItem`,取决于数据操作)。

这里一个特殊情况是 Change 操作,其中 `oldItem` 和 `newItem` 属性都被使用。`oldItem` 包含其属性值必须更改的原始(未更改)项目,而 `newItem` 包含带有新属性值的原始项目的克隆。

因此,FlexGrid 附带 ImmutabilityProvider 后,不是直接修改源数组,而是触发 `dataChanged` 事件,该事件使用事件提供的数据调用相应的 action creator 函数。此调用将 action 派发到 Redux Store,之后它会到达 Store 的 reducer。

reducer 创建一个应用了数据更改的数组克隆,这个新的数组副本在绑定到 `ImmutabilityProvider.itemsSource` 属性的 `this.props.items` 属性中变得可用。ImmutabilityProvider 检测到这个新的数组实例,并使 FlexGrid 刷新其内容。

该视图包含一个 Menu 组件,允许用户更改数据网格中显示的数组大小。更改其值会导致 Redux Store 创建一个指定长度的新 `items` 数组。该菜单通过组件 `render` 方法中的以下代码添加到视图中:

import * as wjInput from '@grapecity/wijmo.react.input';
....


<wjInput.Menu header='Items number'
    value={this.props.itemCount}
    itemClicked={this.onCountChanged}>
    <wjInput.MenuItem value={5}>5</wjInput.MenuItem>
    <wjInput.MenuItem value={50}>50</wjInput.MenuItem>
    <wjInput.MenuItem value={100}>100</wjInput.MenuItem>
    <wjInput.MenuItem value={500}>500</wjInput.MenuItem>
    <wjInput.MenuItem value={5000}>5,000</wjInput.MenuItem>
    <wjInput.MenuItem value={10000}>10,000</wjInput.MenuItem>
    <wjInput.MenuItem value={50000}>50,000</wjInput.MenuItem>
    <wjInput.MenuItem value={100000}>100,000</wjInput.MenuItem>
</wjInput.Menu>

菜单的 `value` 属性绑定到全局 Redux 状态的 `itemCount` 属性,该属性包含当前 `items` 数组的长度。当用户在菜单下拉列表中选择另一个值时,会触发 `itemClicked` 事件并调用 `onCountChanged` 事件处理函数,该函数具有以下简单实现:

onCountChanged(s: wjcInput.Menu) {
    this.props.changeCountAction(s.selectedValue);
}

该处理程序只是调用 `changeCountAction` action creator 函数,将新的数组长度作为 action 数据传递。这会强制 Store 的 reducer 创建一个指定长度的新 `items` 数组。
视图的另一个 UI 元素是一个只读数据网格,它仅显示 `items` 数组的内容。

该数据网格关联了一个显示数据复选框元素,允许用户临时断开数据网格与数据数组的连接。以下是组件 `render` 方法中添加这些组件的 JSX 代码:

<input type="checkbox" 
    checked={this.state.showStoreData}
    onChange={ (e) => { 
        this.setState({ showStoreData: e.target.checked}); 
} } /> 
<b>Show data</b>
<wjFlexGrid.FlexGrid 
    itemsSource={this.state.showStoreData ? this.props.items : null} 
    isReadOnly/> 
</div>

“显示数据”复选框是一个受控组件,其值存储在组件状态的 `showStoreData` 属性中。我们在这里使用本地组件状态来存储此值,因为这是特定属性的视图,对应用程序的其余部分没有意义。但如果您倾向于将所有内容都存储在全局 Redux 状态中,也没问题,可以轻松地将其移至那里。
请注意,`FlexGrid.itemsSource` 属性根据 `showStoreData` 属性的值,有条件地绑定到 Store 的 `items` 数组或一个 null 值。

整合一切

应用程序的入口点是 app.jsx 文件,我们在这里将所有应用程序部分整合在一起并运行根 `App` 组件:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
//Application
import { appReducer } from './reducers';
import { GridViewContainer } from './GridViewContainer';

// Create global Redux Store
const store = createStore(appReducer);

class App extends React.Component<any, any> {
    render() {
        return <Provider store={store}>
            <GridViewContainer />
          </Provider>;
    }
}

ReactDOM.render(<App />, document.getElementById('app'));

如您所见,我们创建了一个应用程序 store,并将其传递给我们的 reducer。然后我们渲染 GridViewContainer 容器组件,它将依次渲染 GridView 展示型组件,并将全局 Store 数据作为 props 传递给它。我们将应用程序组件树用 react-reduxProvider 组件包裹起来,以便从任何应用程序组件中都能轻松访问 store。

结论

FlexGrid 数据网格与相关的 ImmutabilityProvider 组件相结合,让您能够兼得两个世界的优点——基于 Redux 的应用程序状态管理和可编辑的数据网格。使用它,您可以在应用程序 UI 中使用可编辑的数据网格,而不会违背 Redux 对数据不可变性的要求。即使在相当大的数据集上,该解决方案的性能也表现良好。

在 Redux 应用程序中使用数据网格作为数据编辑控件,变得几乎和使用输入控件一样简单,您只需将控件的值绑定到全局状态值,并在控件的“值已更改”事件中派发一个带有新值的 action 即可。

您可以在此处找到本文中使用的示例的在线版本,并下载其源代码进行离线研究。

ImmutabilityProvider 的文档可以在这里找到。

查看我们的示例.

阅读完整的 Wijmo 2020 v1 版本.

如果您有任何有趣的事情想让我们知道,我们很乐意倾听!

© . All rights reserved.