如何处理Redux中的关系数据?

Jor*_*nev 23 javascript reactjs redux react-redux reselect

我正在创建的应用程序有很多实体和关系(数据库是关系).为了得到一个想法,有25个以上的实体,它们之间有任何类型的关系(一对多,多对多).

该应用程序是基于React + Redux的.为了从商店获取数据,我们使用了Reselect库.

我面临的问题是当我试图从商店获得一个与其关系的实体时.

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

演示应用

商业逻辑

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

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

const books = [{
  id: 1,
  name: 'Book 1',
  category: 'Programming',
  authorId: 1
}];
Run Code Online (Sandbox Code Playgroud)

Redux商店

商店采用扁平结构,符合Redux最佳实践 - 规范状态.

这是书籍和作者商店的初始状态:

const initialState = {
  // Keep entities, by id:
  // { 1: { name: '' } }
  byIds: {},
  // Keep entities ids
  allIds:[]
};
Run Code Online (Sandbox Code Playgroud)

组件

组件组织为容器和演示文稿.

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

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);
Run Code Online (Sandbox Code Playgroud)

<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]) ));
Run Code Online (Sandbox Code Playgroud)

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

  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])
    }))
));
Run Code Online (Sandbox Code Playgroud)

加起来

  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()
Run Code Online (Sandbox Code Playgroud)
<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>
Run Code Online (Sandbox Code Playgroud)

JSFiddle.

aar*_*ard 25

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

1)状态中的标准化数据

您已经很好地规范了数据,但实际上,它只是在某种程度上正常化了.我为什么这么说?

...
books: [1]
...
...
authorId: 1
...
Run Code Online (Sandbox Code Playgroud)

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

具有redux样式架构的一般经验法则永远不会存储(在状态中)您可以计算的内容.这包括这种关系,很容易计算出来authorId.

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

我们提到在该州的规范化数据并不好.但是在选择器中对它进行非规范化是对的吗?嗯,确实如此.但问题是,是否需要它?我做了你现在做的同样的事情,让选择器基本上像后端ORM一样."我只是想打电话author.books给所有的书!" 你可能在想.只需能够author.books在你的React组件中循环并呈现每本书,对吗?

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

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

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [1]
}];
Run Code Online (Sandbox Code Playgroud)

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [{
      id: 1,
      name: 'Book 1',
      category: 'Programming',
      authorId: 1
  }]
}];
Run Code Online (Sandbox Code Playgroud)

所以getHealthAuthorsWithBooksSelector现在为每个作者创建一个新对象,而不是===该状态的对象.

这不错.但我会说这不理想.除了冗余(< - keyword)内存使用情况之外,最好对商店中的每个实体进行一次权威引用.现在,每个作者有两个实体在概念上是相同的,但是您的程序将它们视为完全不同的对象.

那么现在我们看看你的mapStateToProps:

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

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

思考解决方案

首先,在我们开始制作新的选择器并让它快速而有趣之前,让我们来构建一个天真的解决方案.

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthors(state),
});
Run Code Online (Sandbox Code Playgroud)

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

请注意,我改变它getAuthorsSelector只是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;
}, {});
Run Code Online (Sandbox Code Playgroud)

我们如何使用它?

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>);
})
...
Run Code Online (Sandbox Code Playgroud)

等等

但是但是你之前提到过记忆,那就是为什么我们没有对这些东西进行反规范化getHealthAuthorsWithBooksSelector,对吧? 正确!但在这种情况下,我们不会占用带有冗余信息的内存.事实上,每一个单一的实体,将booksauthorS,只是参考店里原来的对象!这意味着唯一占用的新内存是容器数组/对象本身,而不是它们中的实际项.

我发现这种解决方案适用于许多用例.当然,我没有将它保存在上面的组件中,我将其提取为可重用的函数,该函数根据特定条件创建选择器. 虽然,我承认我没有遇到与您相同复杂性的问题,因为您必须通过另一个实体过滤特定实体.哎呀!但仍然可行.

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

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,
  );
};
Run Code Online (Sandbox Code Playgroud)

现在这看起来像是一种怪物.但是我们必须使代码的某些部分变得复杂,因此我们可以使更多的部分更加清晰.干净怎么样?

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>);
})
...
Run Code Online (Sandbox Code Playgroud)

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

关键是:我们不打算使用规范化数据重新创建状态副本.我们试图*创建该状态的索引表示(读取:引用),这些表示很容易被组件消化.

我在这里提出的索引是非常可重用的,但并非没有某些问题(我会让其他人都知道这些).我不指望你使用它,但我确实希望你从中学到这一点:而不是试图强迫你的选择器给你类似后端的,类似ORM的嵌套版本的数据,使用固有的链接能力使用您已有的工具获取数据:ID和对象引用.

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

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


Jor*_*nev 8

问题的作者在这里!

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

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

1.索引

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

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

他提到,这完全符合示例。但重要的是要强调,他的示例仅为一对多关系创建索引(一本书有很多作者)。因此,我开始考虑这种方法将如何满足我所有可能的要求:

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

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

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

我继续寻找解决方案,因为创建这样的Indexing实用程序对于该项目而言似乎完全不在范围之内。这更像是创建第三方库。

因此,我决定尝试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()
   })
})
Run Code Online (Sandbox Code Playgroud)

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

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

因此,有了这种选择器,我的流程如下:

  1. 容器组件通过API获取数据。
  2. 选择器仅获取所需的数据片。
  3. 渲染演示文稿组件。

但是,听起来并不完美。Redux-ORM以非常易于使用的方式处理关系操作,例如查询,过滤等。凉!

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

结论(个人)

对于更简单的关系项目,我将尝试使用Indexing方法。

否则,我会坚持使用Redux-ORM,就像我在App中使用的那样,我曾问过这个问题。那里我有70多个实体,并且还在计数!


Lao*_*jin 4

当您开始使用getHealthAuthorsSelector其他命名选择器(例如getHealthAuthorsWithBooksSelector,...)“重载”选择器(例如)时,您可能会得到类似getHealthAuthorsWithBooksWithRelatedBooksSelector等的结果。

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

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

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

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