javascript - Redux中如何处理关系数据?

标签 javascript reactjs redux react-redux reselect

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

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

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

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

演示应用

商业逻辑

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

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

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

Redux商店

商店采用扁平结构组织,符合Redux最佳实践Normalizing State Shape

这是书店和作者商店的初始状态:
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]) ));

当您有一个选择器来计算/查询关系数据时,它会变得困惑。
该演示应用程序包括以下示例:
  • 获取所有作者,其中至少有一本书属于特定类别。
  • 获得相同的作者,但还有他们的书籍。

  • 这是讨厌的选择器:
    /**
     * 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])
        }))
    ));
    

    加起来
  • 如您所见,选择器中的计算/查询关系数据变得太复杂了。
  • 加载子关系(作者->书籍)。
  • 按子实体过滤(getHealthAuthorsWithBooksSelector())。
  • 如果实体具有很多子关系,则选择器参数将过多。 checkout 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>


    JSFiddle

    最佳答案

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

    1)标准化状态数据

    您已经很好地标准化了数据,但实际上,它只是有些标准化了。我怎么这么说

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

    您将相同的概念数据存储在两个位置。这很容易变得不同步。例如,假设您从服务器收到新书。如果它们的authorId均为1,则还必须修改书本身并将这些ID添加到书中!这是很多不需要做的额外工作。如果不这样做,数据将不同步。

    带有redux样式架构的一般经验法则是永远不会存储(在状态中)您可以计算的内容。包括此关系,可以通过authorId轻松计算。

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

    我们提到过在该状态下标准化数据是不好的。但是在选择器中对其进行非规范化可以吗?好吧,是的。但是问题是,是否需要?我做了与您现在正在做的相同的事情,使选择器基本上像后端ORM。 “我只希望能够调用author.books并获得所有书籍!”你可能在想。能够在React组件中遍历author.books并呈现每本书会非常容易,对吧?

    但是,您真的要对状态中的每个数据进行规范化吗? 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更改为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对内容进行非规范化,对吗?
    正确的!但是在这种情况下,我们不会占用多余信息的内存。实际上,每个单独的实体booksauthor都只是引用商店中的原始对象!这意味着唯一占用的新内存是容器数组/对象本身,而不是容器中的实际项目。

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

    让我们将索引器函数提取为可重用函数:
    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的函数

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

    关于javascript - Redux中如何处理关系数据?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/45373369/

    相关文章:

    reactjs - Redux Form - 初始值不随状态更新

    javascript - 尝试将 mysql 数据库数据传输到 javascript 数组

    javascript - Mocha 测试中的共享功能

    javascript - 选中时如何为星码赋予悬停效果?

    javascript - Redux 存储更新但不在组件中呈现

    javascript - 如何将自定义脚本添加到 React 应用程序组件中?

    reactjs - react-transition-group/react-router 在路由更改时跳转到窗口顶部

    javascript - 如何访问链后面部分的 promise 结果

    javascript - combineLatest<{ [id : string]: Book }, string[]> 这是什么?

    javascript - 初学者 Redux/React - 如果不在状态,则添加单个项目