34

我正在创建的应用程序有很多实体和关系(数据库是关系的)。了解一下,有 25 多个实体,它们之间有任何类型的关系(一对多、多对多)。

该应用程序基于 React + Redux。为了从 Store 中获取数据,我们使用了Reselect库。

我面临的问题是当我尝试从商店获取实体及其关系时。

为了更好地解释这个问题,我创建了一个简单的演示应用程序,它具有类似的架构。我将重点介绍最重要的代码库。最后,我将包含一个片段(小提琴)以便使用它。

演示应用

商业逻辑

我们有书籍和作者。一本书有一个作者。一位作者有很多书。尽可能简单。

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [1]
}];

const books = [{
  id: 1,
  name: 'Book 1',
  category: 'Programming',
  authorId: 1
}];

Redux 商店

Store 以扁平结构组织,符合 Redux 最佳实践 - Normalizing State Shape

这是 Books 和 Authors Stores 的初始状态:

const initialState = {
  // Keep entities, by id:
  // { 1: { name: '' } }
  byIds: {},
  // Keep entities ids
  allIds:[]
};

成分

这些组件被组织为容器和演示文稿。

<App />组件充当容器(获取所有需要的数据):

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
});

const mapDispatchToProps = {
  addBooks, addAuthors
}

const App = connect(mapStateToProps, mapDispatchToProps)(View);

<View />组件仅用于演示。它将虚拟数据推送到 Store 并将所有 Presentation 组件呈现为<Author />, <Book />.

选择器

对于简单的选择器,它看起来很简单:

/**
 * Get Books Store entity
 */
const getBooks = ({books}) => books;

/**
 * Get all Books
 */
const getBooksSelector = createSelector(getBooks,
    (books => books.allIds.map(id => books.byIds[id]) ));


/**
 * Get Authors Store entity
 */
const getAuthors = ({authors}) => authors;

/**
 * Get all Authors
 */
const getAuthorsSelector = createSelector(getAuthors,
    (authors => authors.allIds.map(id => authors.byIds[id]) ));

当你有一个计算/查询关系数据的选择器时,它会变得混乱。演示应用程序包括以下示例:

  1. 获取所有作者,其中至少有一本特定类别的书。
  2. 获得相同的作者,但与他们的书籍一起。

以下是讨厌的选择器:

/**
 * Get array of Authors ids,
 * which have books in 'Health' category
 */  
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
    (authors, books) => (
    authors.allIds.filter(id => {
      const author = authors.byIds[id];
      const filteredBooks = author.books.filter(id => (
        books.byIds[id].category === 'Health'
      ));

      return filteredBooks.length;
    })
)); 

/**
 * Get array of Authors,
 * which have books in 'Health' category
 */   
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
    (filteredIds, authors) => (
    filteredIds.map(id => authors.byIds[id])
)); 

/**
 * Get array of Authors, together with their Books,
 * which have books in 'Health' category
 */    
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
    (filteredIds, authors, books) => (
    filteredIds.map(id => ({
        ...authors.byIds[id],
      books: authors.byIds[id].books.map(id => books.byIds[id])
    }))
));

加起来

  1. 如您所见,在选择器中计算/查询关系数据变得过于复杂。
    1. 加载子关系(作者->书籍)。
    2. 按子实体 ( getHealthAuthorsWithBooksSelector()) 过滤。
  2. 如果一个实体有很多子关系,就会有太多的选择器参数。结帐getHealthAuthorsWithBooksSelector()并想象作者是否有更多的关系。

那么你如何处理 Redux 中的关系呢?

它看起来像一个常见的用例,但令人惊讶的是没有任何好的实践。

*我检查了redux-orm库,它看起来很有希望,但它的 API 仍然不稳定,我不确定它是否已准备好生产。

const { Component } = React
const { combineReducers, createStore } = Redux
const { connect, Provider } = ReactRedux
const { createSelector } = Reselect

/**
 * Initial state for Books and Authors stores
 */
const initialState = {
  byIds: {},
  allIds:[]
}

/**
 * Book Action creator and Reducer
 */

const addBooks = payload => ({
  type: 'ADD_BOOKS',
  payload
})

const booksReducer = (state = initialState, action) => {
  switch (action.type) {
  case 'ADD_BOOKS':
    let byIds = {}
    let allIds = []

    action.payload.map(entity => {
      byIds[entity.id] = entity
      allIds.push(entity.id)
    })

    return { byIds, allIds }
  default:
    return state
  }
}

/**
 * Author Action creator and Reducer
 */

const addAuthors = payload => ({
  type: 'ADD_AUTHORS',
  payload
})

const authorsReducer = (state = initialState, action) => {
  switch (action.type) {
  case 'ADD_AUTHORS':
    let byIds = {}
    let allIds = []

    action.payload.map(entity => {
      byIds[entity.id] = entity
      allIds.push(entity.id)
    })

    return { byIds, allIds }
  default:
    return state
  }
}

/**
 * Presentational components
 */
const Book = ({ book }) => <div>{`Name: ${book.name}`}</div>
const Author = ({ author }) => <div>{`Name: ${author.name}`}</div>

/**
 * Container components
 */

class View extends Component {
  componentWillMount () {
    this.addBooks()
    this.addAuthors()
  }

  /**
   * Add dummy Books to the Store
   */
  addBooks () {
    const books = [{
      id: 1,
      name: 'Programming book',
      category: 'Programming',
      authorId: 1
    }, {
      id: 2,
      name: 'Healthy book',
      category: 'Health',
      authorId: 2
    }]

    this.props.addBooks(books)
  }

  /**
   * Add dummy Authors to the Store
   */
  addAuthors () {
    const authors = [{
      id: 1,
      name: 'Jordan Enev',
      books: [1]
    }, {
      id: 2,
      name: 'Nadezhda Serafimova',
      books: [2]
    }]

    this.props.addAuthors(authors)
  }

  renderBooks () {
    const { books } = this.props

    return books.map(book => <div key={book.id}>
      {`Name: ${book.name}`}
    </div>)
  }

  renderAuthors () {
    const { authors } = this.props

    return authors.map(author => <Author author={author} key={author.id} />)
  }

  renderHealthAuthors () {
    const { healthAuthors } = this.props

    return healthAuthors.map(author => <Author author={author} key={author.id} />)
  }

  renderHealthAuthorsWithBooks () {
    const { healthAuthorsWithBooks } = this.props

    return healthAuthorsWithBooks.map(author => <div key={author.id}>
      <Author author={author} />
      Books:
      {author.books.map(book => <Book book={book} key={book.id} />)}
    </div>)
  }

  render () {
    return <div>
      <h1>Books:</h1> {this.renderBooks()}
      <hr />
      <h1>Authors:</h1> {this.renderAuthors()}
      <hr />
      <h2>Health Authors:</h2> {this.renderHealthAuthors()}
      <hr />
      <h2>Health Authors with loaded Books:</h2> {this.renderHealthAuthorsWithBooks()}
    </div>
  }
};

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
})

const mapDispatchToProps = {
  addBooks, addAuthors
}

const App = connect(mapStateToProps, mapDispatchToProps)(View)

/**
 * Books selectors
 */

/**
 * Get Books Store entity
 */
const getBooks = ({ books }) => books

/**
 * Get all Books
 */
const getBooksSelector = createSelector(getBooks,
  books => books.allIds.map(id => books.byIds[id]))

/**
 * Authors selectors
 */

/**
 * Get Authors Store entity
 */
const getAuthors = ({ authors }) => authors

/**
 * Get all Authors
 */
const getAuthorsSelector = createSelector(getAuthors,
  authors => authors.allIds.map(id => authors.byIds[id]))

/**
 * Get array of Authors ids,
 * which have books in 'Health' category
 */
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
  (authors, books) => (
    authors.allIds.filter(id => {
      const author = authors.byIds[id]
      const filteredBooks = author.books.filter(id => (
        books.byIds[id].category === 'Health'
      ))

      return filteredBooks.length
    })
  ))

/**
 * Get array of Authors,
 * which have books in 'Health' category
 */
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
  (filteredIds, authors) => (
    filteredIds.map(id => authors.byIds[id])
  ))

/**
 * Get array of Authors, together with their Books,
 * which have books in 'Health' category
 */
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
  (filteredIds, authors, books) => (
    filteredIds.map(id => ({
      ...authors.byIds[id],
      books: authors.byIds[id].books.map(id => books.byIds[id])
    }))
  ))

// Combined Reducer
const reducers = combineReducers({
  books: booksReducer,
  authors: authorsReducer
})

// Store
const store = createStore(reducers)

const render = () => {
  ReactDOM.render(<Provider store={store}>
    <App />
  </Provider>, document.getElementById('root'))
}

render()
<div id="root"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.24/browser.js"></script>
<script src="https://npmcdn.com/reselect@3.0.1/dist/reselect.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.3.1/redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.6/react-redux.min.js"></script>

JSF中。

4

4 回答 4

36

这让我想起了我是如何开始我的一个数据高度相关的项目的。你仍然对后端的做事方式想得太多,但你必须开始考虑更多的 JS 做事方式(当然,这对某些人来说是一个可怕的想法)。

1) 状态中的归一化数据

您在规范化数据方面做得很好,但实际上,它只是稍微规范化了。我为什么这么说?

...
books: [1]
...
...
authorId: 1
...

您将相同的概念数据存储在两个地方。这很容易变得不同步。例如,假设您从服务器接收新书。如果它们都authorId为 1,您还必须修改书本身并将这些 id 添加到其中!这是很多不需要完成的额外工作。如果没有完成,数据将不同步。

一个具有 redux 风格架构的一般经验法则是永远不要存储(在状态中)您可以计算的内容。这包括这种关系,它很容易由 计算authorId

2) 选择器中的非规范化数据

我们提到在该州拥有标准化数据并不好。但是在选择器中对其进行非规范化是可以的,对吧?嗯,是的。但问题是,需要吗?我做了你现在正在做的同样的事情,让选择器基本上像一个后端 ORM。“我只是想能够打电话author.books把所有的书都拿到!” 你可能在想。能够author.books在你的 React 组件中循环并渲染每本书是很容易的,对吧?

但是,您真的想规范化您所在州的每条数据吗?React 不需要那个。事实上,它也会增加你的内存使用量。这是为什么?

因为现在您将拥有相同的两个副本author,例如:

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [1]
}];

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [{
      id: 1,
      name: 'Book 1',
      category: 'Programming',
      authorId: 1
  }]
}];

所以getHealthAuthorsWithBooksSelector现在为每个作者创建一个新对象,这不会是===状态中的那个。

这还不错。但我会说这并不理想。除了冗余(<- 关键字)内存使用之外,最好对商店中的每个实体都有一个单一的权威引用。现在,每个作者都有两个概念上相同的实体,但您的程序将它们视为完全不同的对象。

所以现在当我们看你的mapStateToProps

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
});

您基本上为组件提供了所有相同数据的 3-4 个不同副本。

思考解决方案

首先,在我们开始制作新的选择器并让它变得又快又好之前,让我们先做一个简单的解决方案。

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthors(state),
});

啊,这个组件真正需要的唯一数据!,booksauthors。使用其中的数据,它可以计算它需要的任何东西。

请注意,我将其从更改getAuthorsSelector为 just getAuthors?这是因为我们计算所需的所有数据都在books数组中,我们可以将作者拉到id我们拥有的一个!

请记住,我们还没有担心使用选择器,让我们简单地考虑一下这个问题。因此,组件内部,让我们为作者的书籍建立一个“索引”。

const { books, authors } = this.props;

const healthBooksByAuthor = books.reduce((indexedBooks, book) => {
   if (book.category === 'Health') {
      if (!(book.authorId in indexedBooks)) {
         indexedBooks[book.authorId] = [];
      }
      indexedBooks[book.authorId].push(book);
   }
   return indexedBooks;
}, {});

我们如何使用它?

const healthyAuthorIds = Object.keys(healthBooksByAuthor);

...
healthyAuthorIds.map(authorId => {
    const author = authors.byIds[authorId];

    return (<li>{ author.name }
       <ul>
         { healthBooksByAuthor[authorId].map(book => <li>{ book.name }</li> }
       </ul>
    </li>);
})
...

等等等等。

但是,但是您之前提到了内存,这就是为什么我们没有用 非规范化东西getHealthAuthorsWithBooksSelector,对吧? 正确的!但在这种情况下,我们不会用冗余信息占用内存事实上,每一个实体,thebooksauthors,只是对 store 中原始对象的引用!这意味着唯一占用的新内存是容器数组/对象本身,而不是它们中的实际项目。

我发现这种解决方案非常适合许多用例。当然,我不会像上面那样将它保存在组件中,而是将其提取到一个可重用的函数中,该函数根据某些标准创建选择器。 虽然,我承认我没有遇到与您的复杂性相同的问题,因为您必须通过另一个实体过滤特定实体。哎呀!但还是可行的。

让我们将索引器函数提取为可重用函数:

const indexList = fieldsBy => list => {
 // so we don't have to create property keys inside the loop
  const indexedBase = fieldsBy.reduce((obj, field) => {
    obj[field] = {};
    return obj;
  }, {});

  return list.reduce(
    (indexedData, item) => {
      fieldsBy.forEach((field) => {
        const value = item[field];

        if (!(value in indexedData[field])) {
          indexedData[field][value] = [];
        }

        indexedData[field][value].push(item);
      });

      return indexedData;
    },
    indexedBase,
  );
};

现在这看起来像一个怪物。但是我们必须使代码的某些部分变得复杂,这样我们才能使更多的部分变得干净。怎么清洁?

const getBooksIndexed = createSelector([getBooksSelector], indexList(['category', 'authorId']));
const getBooksIndexedInCategory = category => createSelector([getBooksIndexed],
    booksIndexedBy => {
        return indexList(['authorId'])(booksIndexedBy.category[category])
    });
    // you can actually abstract this even more!

...
later that day
...

const mapStateToProps = state => ({
  booksIndexedBy: getBooksIndexedInCategory('Health')(state),
  authors: getAuthors(state),
});

...
const { booksIndexedBy, authors } = this.props;
const healthyAuthorIds = Object.keys(booksIndexedBy.authorId);

healthyAuthorIds.map(authorId => {
    const author = authors.byIds[authorId];

    return (<li>{ author.name }
       <ul>
         { healthBooksByAuthor[authorId].map(book => <li>{ book.name }</li> }
       </ul>
    </li>);
})
...

当然,这并不容易理解,因为它主要依赖于组合这些函数和选择器来构建数据的表示,而不是对其进行重新规范化。

关键是:我们不希望使用标准化数据重新创建状态副本。我们正在尝试*创建该状态的索引表示(阅读:引用),这些表示很容易被组件消化。

我在这里介绍的索引是非常可重用的,但并非没有某些问题(我会让其他人弄清楚)。我不希望您使用它,但我确实希望您从中学到这一点:与其试图强制您的选择器为您提供类似后端、类似 ORM 的数据嵌套版本,不如使用固有的链接能力使用您已有的工具来处理您的数据:ID 和对象引用。

这些原则甚至可以应用于您当前的选择器。而不是为每个可能的数据组合创建一堆高度专业化的选择器...... 1)创建基于某些参数为您创建选择器的函数 2)创建可用作resultFunc许多不同选择器的函数

索引并不适合所有人,我会让其他人建议其他方法。

于 2017-07-28T17:57:28.287 回答
12

问题作者来了!

一年后,现在我要在这里总结一下我的经验和想法。

我正在研究处理关系数据的两种可能方法:

1. 索引

aaronofleonard已经在这里给了我们一个很棒且非常详细的答案,他的主要概念如下:

我们不希望使用标准化数据重新创建状态副本。我们正在尝试*创建该状态的索引表示(阅读:引用),这些表示很容易被组件消化。

他提到,它非常适合这些例子。但重要的是要强调他的示例仅为一对多关系创建索引(一本书有很多作者)。所以我开始思考这种方法如何满足我所有可能的要求:

  1. 处理多对多案件。示例:一本书有多个作者,通过 BookStore。
  2. 处理深层过滤。示例:从健康类别中获取所有书籍,其中至少作者来自特定国家/地区。现在想象一下,如果我们有更多嵌套级别的实体。

当然这是可行的,但正如你所见,事情很快就会变得严重。

如果您对使用索引管理这种复杂性感到满意,那么请确保您有足够的设计时间来创建选择器和编写索引实用程序。

我继续寻找解决方案,因为创建这样的索引实用程序看起来完全超出了项目的范围。它更像是创建一个第三方库。

所以我决定尝试一下Redux-ORM库。

2. Redux-ORM

一个小型、简单且不可变的 ORM,用于管理 Redux 存储中的关系数据。

不冗长,这里是我管理所有需求的方式,只使用库:

// Handing many-to-many case.
const getBooks = createSelector({ Book } => {
  return Books.all().toModelArray()
   .map( book => ({ 
     book: book.ref,
     authors: book.authors.toRefArray()
   })
})

// Handling Deep filtration.
// Keep in mind here you can pass parameters, instead of hardcoding the filtration criteria.
const getFilteredBooks = createSelector({ Book } => {
  return Books.all().toModelArray()
   .filter( book => {
     const authors = book.authors.toModelArray()
     const hasAuthorInCountry = authors.filter(a => a.country.name === 'Bulgaria').length

     return book.category.type === 'Health' && hasAuthorInCountry
   })
   .map( book => ({ 
     book: book.ref,
     authors: book.authors.toRefArray()
   })
})

如您所见 - 库为我们处理所有关系,我们可以轻松访问所有子实体并执行复杂的计算。

同样使用.ref我们返回实体存储的引用,而不是创建一个新的对象副本(你担心内存)。

所以有了这种类型的选择器,我的流程如下:

  1. 容器组件通过 API 获取数据。
  2. 选择器只获取所需的数据片段。
  3. 渲染 Presentation 组件。

然而,没有什么是完美的,因为它听起来。Redux-ORM 以非常易于使用的方式处理查询、过滤等关系操作。凉爽的!

但是当我们谈论选择器的可重用性、组合、扩展等等时——这是一项棘手而尴尬的任务。这不是 Redux-ORM 的问题,而是reselect库本身及其工作方式的问题。在这里,我们讨论了这个话题。

结论(个人)

对于更简单的关系项目,我会尝试使用索引方法。

否则,我会坚持使用 Redux-ORM,因为我在应用程序中使用它,为此我提出了这个问题。我有 70 多个实体,而且还在计数!

于 2018-06-29T13:15:28.600 回答
4

getHealthAuthorsSelector当您开始使用其他命名选择器(例如,...) “重载”您的选择器(例如)时getHealthAuthorsWithBooksSelector,您最终可能会得到类似getHealthAuthorsWithBooksWithRelatedBooksSelectoretc的东西。

这是不可持续的。我建议您坚持使用高级(即getHealthAuthorsSelector)并使用一种机制,以便他们的书籍以及这些书籍的相关书籍等始终可用。

您可以使用 TypeScript 并将其author.books转换为 getter,或者仅使用便利函数在需要时从商店获取书籍。通过一个操作,您可以将 get from store 与 fetch from db 结合起来,以直接显示(可能)过时的数据,并在从数据库中检索数据后让 Redux/React 负责视觉更新。

我没有听说过这种重新选择,但它似乎是一种将各种过滤器放在一个地方以避免在组件中重复代码的好方法。
尽管它们很简单,但它们也很容易测试。业务/领域逻辑测试通常是一个(非常?)好主意,尤其是当您自己不是领域专家时。

还要记住,将多个实体连接成新的东西有时很有用,例如展平实体,以便它们可以轻松地绑定到网格控件。

于 2017-07-28T15:17:19.640 回答
1

有一个解决关系选择的库:ngrx-entity-relationship

它的类似演示在代码沙箱上

对于书籍和作者的情况,它会是这样的:

下一个代码我们需要定义一次。

// first we need proper state selectors, because of custom field names
const bookState = stateKeys(getBooks, 'byIds', 'allIds');
const authorState = stateKeys(getAuthors, 'byIds', 'allIds');

// now let's define root and relationship selector factories
const book = rootEntitySelector(bookState);
const bookAuthor = relatedEntitySelector(
  authorState,
  'authorId',
  'author'
);

// the same for authors
const author = rootEntitySelector(authorState);
const authorBooks = relatedEntitySelector(
  bookState,
  'books', // I would rename it to `booksId`
  'booksEntities', // and would use here `books`
);

现在我们可以构建选择器并在需要时重用它们。

// now we can build a selector
const getBooksWithAuthors = rootEntities(
  book(
    bookAuthor(
      authorBooks(), // if we want to go crazy
    ),
  ),
);

// and connect it
const mapStateToProps = state => ({
  books: getBooksWithAuthors(state, [1, 2, 3]), // or a selector for ids
  // ...
});

结果将是

this.props.books = [
  {
    id: 1,
    name: 'Book 1',
    category: 'Programming',
    authorId: 1
    author: {
      id: 1,
      name: 'Jordan Enev',
      books: [1],
      booksEntities: [
        {
          id: 1,
          name: 'Book 1',
          category: 'Programming',
          authorId: 1,
        },
      ],
    },
  },
];
于 2021-01-06T21:11:41.403 回答