Redux中如何处理关系数据? [英] How to deal with relational data in Redux?

查看:23
本文介绍了Redux中如何处理关系数据?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

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

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

我面临的问题是,当我尝试从 Store 中获取具有其关系的实体时.

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

演示应用

业务逻辑

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

const 作者 = [{编号:1,name: '乔丹·埃内夫',书籍:[1]}];const 书籍 = [{编号:1,name: 'Book 1',类别:'编程',作者 ID:1}];

Redux 商店

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

以下是 Books 和 Authors Store 的初始状态:

const initialState = {//通过 id 保留实体://{ 1: { 名称: '' } }byIds:{},//保留实体 ID所有 ID:[]};

组件

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

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

const mapStateToProps = state =>({书籍:getBooksSelector(状态),作者:getAuthorsSelector(state),healthAuthors: getHealthAuthorsSelector(state),healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)});const mapDispatchToProps = {添加书籍,添加作者}const App = connect(mapStateToProps, mapDispatchToProps)(View);

组件仅用于演示.它将虚拟数据推送到 Store 并将所有 Presentation 组件呈现为 , .

选择器

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

/*** 获取书店实体*/const getBooks = ({books}) =>图书;/*** 获取所有书籍*/const getBooksSelector = createSelector(getBooks,(books => books.allIds.map(id => books.byIds[id])));/*** 获取作者商店实体*/const getAuthors = ({authors}) =>作者;/*** 获取所有作者*/const getAuthorsSelector = createSelector(getAuthors,(authors =>authors.allIds.map(id =>authors.byIds[id])));

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

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

这里是讨厌的选择器:

/*** 获取作者 ID 数组,* 其中有健康"类别的书籍*/const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],(作者,书籍)=>(author.allIds.filter(id => {const author =authors.byIds[id];constfilteredBooks = author.books.filter(id => (book.byIds[id].category === '健康'));返回filteredBooks.length;})));/*** 获取作者数组,* 其中有健康"类别的书籍*/const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],(filteredIds,authors) =>(filteredIds.map(id =>authors.byIds[id])));/*** 获取一系列作者及其书籍,* 其中有健康"类别的书籍*/const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],(filteredIds, 作者, 书籍) =>(filteredIds.map(id => ({...authors.byIds[id],书籍:authors.byIds[id].books.map(id => books.byIds[id])}))));

<小时>

总结

  1. 如您所见,在选择器中计算/查询关系数据变得过于复杂.

    1. 加载子关系(作者->书籍).
    2. 按子实体过滤 (getHealthAuthorsWithBooksSelector()).

  2. 如果一个实体有很多子关系,就会有太多的选择器参数.查看 getHealthAuthorsWithBooksSelector() 并想象作者是否有更多关系.

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

这看起来像是一个常见的用例,但令人惊讶的是没有任何好的做法.

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

const { Component } = Reactconst { combineReducers, createStore } = Reduxconst { 连接,提供者 } = ReactReduxconst { createSelector } = 重新选择/*** Books 和 Authors 商店的初始状态*/常量初始状态 = {byIds:{},所有 ID:[]}/*** Book Action 创建者和Reducer*/const addBooks = 有效载荷 =>({类型:'ADD_BOOKS',有效载荷})const booksReducer = (state = initialState, action) =>{开关(动作.类型){案例ADD_BOOKS":让 byIds = {}让 allIds = []action.payload.map(entity => {byIds[entity.id] = 实体allIds.push(entity.id)})返回 { byIds, allIds }默认:返回状态}}/*** 作者动作创建者和减速器*/const addAuthors = 有效载荷 =>({类型:'ADD_AUTHORS',有效载荷})const authorReducer = (state = initialState, action) =>{开关(动作.类型){案例ADD_AUTHORS":让 byIds = {}让 allIds = []action.payload.map(entity => {byIds[entity.id] = 实体allIds.push(entity.id)})返回 { byIds, allIds }默认:返回状态}}/*** 展示组件*/const Book = ({ book }) =><div>{`名称:${book.name}`}</div>const 作者 = ({作者 }) =><div>{`名称:${author.name}`}</div>/*** 容器组件*/类视图扩展组件{组件WillMount(){this.addBooks()this.addAuthors()}/*** 将虚拟书籍添加到商店*/添加书籍(){const 书籍 = [{编号:1,name: '编程书',类别:'编程',作者 ID:1}, {编号:2,name: '健康书',类别:'健康',作者 ID:2}]this.props.addBooks(书籍)}/*** 将虚拟作者添加到商店*/添加作者(){常量作者 = [{编号:1,name: '乔丹·埃内夫',书籍:[1]}, {编号:2,name: '娜杰日达·塞拉菲莫娃',书籍:[2]}]this.props.addAuthors(作者)}渲染书籍(){const { 书籍} = this.propsreturn book.map(book => 

{`名称:${book.name}`}</div>)}渲染作者(){const { 作者 } = this.props返回authors.map(author => <Author author={author} key={author.id}/>)}渲染健康作者(){const { healthAuthors } = this.propsreturn healthAuthors.map(author => <Author author={author} key={author.id}/>)}renderHealthAuthorsWithBooks () {const { healthAuthorsWithBooks } = this.propsreturn healthAuthorsWithBooks.map(author =>

<作者 author={author}/>图书:{author.books.map(book => <Book book={book} key={book.id}/>)}</div>)}使成为 () {返回

<h1>书籍:</h1>{this.renderBooks()}<小时/><h1>作者:</h1>{this.renderAuthors()}<小时/><h2>健康作者:</h2>{this.renderHealthAuthors()}<小时/><h2>已加载书籍的健康作者:</h2>{this.renderHealthAuthorsWithBooks()}

}};const mapStateToProps = state =>({书籍:getBooksSelector(状态),作者:getAuthorsSelector(state),healthAuthors: getHealthAuthorsSelector(state),healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)})const mapDispatchToProps = {添加书籍,添加作者}const App = connect(mapStateToProps, mapDispatchToProps)(View)/*** 书籍选择器*//*** 获取书店实体*/const getBooks = ({books }) =>图书/*** 获取所有书籍*/const getBooksSelector = createSelector(getBooks,书籍 =>book.allIds.map(id => books.byIds[id]))/*** 作者选择器*//*** 获取作者商店实体*/const getAuthors = ({ 作者 }) =>作者/*** 获取所有作者*/const getAuthorsSelector = createSelector(getAuthors,作者 =>author.allIds.map(id =>authors.byIds[id]))/*** 获取作者 ID 数组,* 其中有健康"类别的书籍*/const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],(作者,书籍)=>(author.allIds.filter(id => {const author =authors.byIds[id]constfilteredBooks = author.books.filter(id => (book.byIds[id].category === '健康'))返回已过滤的Books.length})))/*** 获取作者数组,* 其中有健康"类别的书籍*/const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],(filteredIds,authors) =>(filteredIds.map(id =>authors.byIds[id])))/*** 获取一系列作者及其书籍,* 其中有健康"类别的书籍*/const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],(filteredIds, 作者, 书籍) =>(filteredIds.map(id => ({...authors.byIds[id],书籍:authors.byIds[id].books.map(id => books.byIds[id])}))))//组合减速器const reducers = combineReducers({书籍:booksReducer,作者:authorsReducer})//店铺const store = createStore(reducers)const render = () =>{ReactDOM.render(<Provider store={store}><应用程序/></Provider>, document.getElementById('root'))}渲染()

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

JSFiddle.

解决方案

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

1) 状态中的归一化数据

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

<代码>...书籍:[1]......作者 ID:1...

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

redux 风格架构的一个一般经验法则是永远不要存储(在状态中)你可以计算的内容.包括这个关系,它很容易通过 authorId 计算出来.

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

我们提到在状态中标准化数据并不好.但是在选择器中对其进行非规范化是可以的,对吗?嗯,是的.但问题是,有必要吗?我做了你现在正在做的同样的事情,让选择器基本上像一个后端 ORM.我只是希望能够调用 author.books 并获取所有书籍!"你可能在想.如果能够在 React 组件中循环遍历 author.books 并渲染每本书,会很容易,对吧?

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

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

const 作者 = [{编号:1,name: '乔丹·埃内夫',书籍:[1]}];

const 作者 = [{编号:1,name: '乔丹·埃内夫',书籍:[{编号:1,name: 'Book 1',类别:'编程',作者 ID:1}]}];

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

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

所以现在当我们查看您的 mapStateToProps 时:

const mapStateToProps = state =>({书籍:getBooksSelector(状态),作者:getAuthorsSelector(state),healthAuthors: getHealthAuthorsSelector(state),healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)});

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

思考解决方案

首先,在我们开始制作新的选择器并让它变得快速和花哨之前,让我们先制定一个简单的解决方案.

const mapStateToProps = state =>({书籍:getBooksSelector(状态),作者:getAuthors(state),});

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

注意到我把它从 getAuthorsSelector 改成了 getAuthors 吗?这是因为我们需要计算的所有数据都在 books 数组中,我们可以通过我们拥有的 id 拉取作者!

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

const { 书籍,作者 } = this.props;const healthBooksByAuthor = books.reduce((indexedBooks, book) => {if (book.category === '健康') {if (!(book.authorId in indexedBooks)) {indexedBooks[book.authorId] = [];}indexedBooks[book.authorId].push(book);}返回索引书籍;}, {});

我们如何使用它?

const healthAuthorIds = Object.keys(healthBooksByAuthor);...healthAuthorIds.map(authorId => {const author = author.byIds[authorId];返回 (
  • { author.name }<ul>{ healthBooksByAuthor[authorId].map(book => <li>{ book.name }</li> }</li>);})...
  • 诸如此类

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

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

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

    const indexList = fieldsBy =>列表 =>{//所以我们不必在循环内创建属性键const indexedBase = fieldsBy.reduce((obj, field) => {对象[字段] = {};返回对象;}, {});返回列表.reduce((indexedData, item) =>{fieldsBy.forEach((field) => {常量值 = 项目[字段];if (!(indexedData[field] 中的值)) {indexedData[字段][值] = [];}indexedData[field][value].push(item);});返回索引数据;},索引库,);};

    现在这看起来有点像怪物.但是我们必须使代码的某些部分变得复杂,这样我们才能使更多的部分变得干净.如何清洁?

    const getBooksIndexed = createSelector([getBooksSelector], indexList(['category', 'authorId']));const getBooksIndexedInCategory = 类别 =>createSelector([getBooksIndexed],bookIndexedBy =>{return indexList(['authorId'])(booksIndexedBy.category[category])});//你实际上可以进一步抽象它!...那天晚些时候...const mapStateToProps = state =>({bookIndexedBy: getBooksIndexedInCategory('Health')(state),作者:getAuthors(state),});...const { booksIndexedBy,authors } = this.props;const healthAuthorIds = Object.keys(booksIndexedBy.authorId);healthAuthorIds.map(authorId => {const author = author.byIds[authorId];返回 (
  • { author.name }<ul>{ healthBooksByAuthor[authorId].map(book => <li>{ book.name }</li> }</li>);})...
  • 这当然不是那么容易理解,因为它主要依赖于组合这些函数和选择器来构建数据表示,而不是对其进行重新规范化.

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

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

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

    的函数

    索引并不适合所有人,我会让其他人推荐其他方法.

    The app I'm creating has a lot of entities and relationships (database is relational). To get an idea, there're 25+ entities, with any type of relations between them (one-to-many, many-to-many).

    The app is React + Redux based. For getting data from the Store, we're using Reselect library.

    The problem I'm facing is when I try to get an entity with its relations from the Store.

    In order to explain the problem better, I've created a simple demo app, that has similar architecture. I'll highlight the most important code base. In the end I'll include a snippet (fiddle) in order to play with it.

    Demo app

    Business logic

    We have Books and Authors. One Book has one Author. One Author has many Books. As simple as possible.

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

    Redux Store

    Store is organized in flat structure, compliant with Redux best practices - Normalizing State Shape.

    Here is the initial state for both Books and Authors Stores:

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

    Components

    The components are organized as Containers and Presentations.

    <App /> component act as Container (gets all needed data):

    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 /> component is just for the demo. It pushes dummy data to the Store and renders all Presentation components as <Author />, <Book />.

    Selectors

    For the simple selectors, it looks straightforward:

    /**
     * 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]) ));
    

    It gets messy, when you have a selector, that computes / queries relational data. The demo app includes the following examples:

    1. Getting all Authors, which have at least one Book in specific category.
    2. Getting the same Authors, but together with their Books.

    Here are the nasty selectors:

    /**
     * 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])
        }))
    ));
    


    Summing up

    1. As you can see, computing / querying relational data in selectors gets too complicated.

      1. Loading child relations (Author->Books).
      2. Filtering by child entities (getHealthAuthorsWithBooksSelector()).

    2. There will be too many selector parameters, if an entity has a lot of child relations. Checkout getHealthAuthorsWithBooksSelector() and imagine if the Author has a lot of more relations.

    So how do you deal with relations in Redux?

    It looks like a common use case, but surprisingly there aren't any good practices round.

    *I checked redux-orm library and it looks promising, but its API is still unstable and I'm not sure is it production ready.

    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>

    JSFiddle.

    解决方案

    This reminds me of how I started one of my projects where the data was highly relational. You think too much still about the backend way of doing things, but you gotta start thinking of more of the JS way of doing things (a scary thought for some, to be sure).

    1) Normalized Data in State

    You've done a good job of normalizing your data, but really, it's only somewhat normalized. Why do I say that?

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

    You have the same conceptual data stored in two places. This can easily become out of sync. For example, let's say you receive new books from the server. If they all have authorId of 1, you also have to modify the book itself and add those ids to it! That's a lot of extra work that doesn't need to be done. And if it isn't done, the data will be out of sync.

    One general rule of thumb with a redux style architecture is never store (in the state) what you can compute. That includes this relation, it is easily computed by authorId.

    2) Denormalized Data in Selectors

    We mentioned having normalized data in the state was not good. But denormalizing it in selectors is ok right? Well, it is. But the question is, is it needed? I did the same thing you are doing now, getting the selector to basically act like a backend ORM. "I just want to be able to call author.books and get all the books!" you may be thinking. It would be so easy to just be able to loop through author.books in your React component, and render each book, right?

    But, do you really want to normalize every piece of data in your state? React doesn't need that. In fact, it will also increase your memory usage. Why is that?

    Because now you will have two copies of the same author, for instance:

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

    and

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

    So getHealthAuthorsWithBooksSelector now creates a new object for each author, which will not be === to the one in the state.

    This is not bad. But I would say it's not ideal. On top of the redundant (<- keyword) memory usage, it's better to have one single authoritative reference to each entity in your store. Right now, there are two entities for each author that are the same conceptually, but your program views them as totally different objects.

    So now when we look at your mapStateToProps:

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

    You are basically providing the component with 3-4 different copies of all the same data.

    Thinking About Solutions

    First, before we get to making new selectors and make it all fast and fancy, let's just make up a naive solution.

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

    Ahh, the only data this component really needs! The books, and the authors. Using the data therein, it can compute anything it needs.

    Notice that I changed it from getAuthorsSelector to just getAuthors? This is because all the data we need for computing is in the books array, and we can just pull the authors by id one we have them!

    Remember, we're not worrying about using selectors yet, let's just think about the problem in simple terms. So, inside the component, let's build an "index" of books by their author.

    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;
    }, {});
    

    And how do we use it?

    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>);
    })
    ...
    

    Etc etc.

    But but but you mentioned memory earlier, that's why we didn't denormalize stuff with getHealthAuthorsWithBooksSelector, right? Correct! But in this case we aren't taking up memory with redundant information. In fact, every single entity, the books and the authors, are just reference to the original objects in the store! This means that the only new memory being taken up is by the container arrays/objects themselves, not by the actual items in them.

    I've found this kind of solution ideal for many use cases. Of course, I don't keep it in the component like above, I extract it into a reusable function which creates selectors based on certain criteria. Although, I'll admit I haven't had a problem with the same complexity as yours, in that you have to filter a specific entity, through another entity. Yikes! But still doable.

    Let's extract our indexer function into a reusable function:

    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,
      );
    };
    

    Now this looks like kind of a monstrosity. But we must make certain parts of our code complex, so we can make many more parts clean. Clean how?

    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>);
    })
    ...
    

    This is not as easy to understand of course, because it relies primarily on composing these functions and selectors to build representations of data, instead of renormalizing it.

    The point is: We're not looking to recreate copies of the state with normalized data. We're trying to *create indexed representations (read: references) of that state which are easily digested by components.

    The indexing I've presented here is very reusable, but not without certain problems (I'll let everyone else figure those out). I don't expect you to use it, but I do expect you to learn this from it: rather than trying to coerce your selectors to give you backend-like, ORM-like nested versions of your data, use the inherent ability to link your data using the tools you already have: ids and object references.

    These principles can even be applied to your current selectors. Rather than create a bunch of highly specialized selectors for every conceivable combination of data... 1) Create functions that create selectors for you based on certain parameters 2) Create functions that can be used as the resultFunc of many different selectors

    Indexing isn't for everyone, I'll let others suggest other methods.

    这篇关于Redux中如何处理关系数据?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

    查看全文
    登录 关闭
    扫码关注1秒登录
    发送“验证码”获取 | 15天全站免登陆