State 的不可变化带来的麻烦
在用 Redux 处理深度复杂的数据时会有一些麻烦. 由于 js 的特性, 我们知道当对一个对象进行复制时实际上是复制它的引用, 除非你对这个对象进行深度复制. Redux 要求你每次你返回的都是一个全新的 State, 而不是去修改它. 这就要求我们要对原来的 State 进行深度复制. 这往往带来复杂的操作 (查找, 合并). 一种简单的情况是通过扩展符号或者 Object.assign 来处理:
- return {
- ...state,
- data: {
- ...state.data,
- id: 5
- }
- }
这种方式在处理简单的数据来说是比较方便的, 但是如果遇到更深一级的数据结构时会显得很无力, 而且代码会变得冗长. 不仅仅如此, 当我们要处理一个包含着对象的数组时, 我们要怎么办才好呢? 我想, 除了深度复制数组然后修改新的数组之外大概没有其他的方法了吧? 而且很重要的一点是, 如果你对原来整个数组进行了复制, 那么绑定了数据的 UI 会自动渲染, 即使它们的数据没有发生变化, 简单来说, 就是你修改了某一条表格数据, 但是界面上整个表格被重新渲染了:
- const TablesSource = {
- query: 'tables',
- tableId: 10,
- data: [{
- key: 11,
- name: '胡彦斌',
- age: 32,// 我要修改这里, 要复制整个数组后修改新的吗?
- address: '西湖区湖底公园 1 号'
- }, {
- key: 12,
- name: '胡彦祖',
- age: 42,
- address: '西湖区湖底公园 1 号'
- }]
- };
在 Redux 官方文档中提到了一种解决方案, 即范式化数据: 概括起来就一句话: 减少层级, 唯一 id 索引, 用后端建表的方法构建我们的数据结构. 其中最重要原则无非是扁平化和关联性. 最终我们需要将数据形式转化成以下格式:
- {
- "entities": {
- "bykey": {
- "11": {
- "key": 11,
- "name": "胡彦斌",
- "age": 32,
- "address": "西湖区湖底公园 1 号"
- },
- "12": {
- "key": 12,
- "name": "胡彦祖",
- "age": 42,
- "address": "西湖区湖底公园 1 号"
- }
- },
- "table": {
- "10": {
- "query": "tables",
- "tableId": 10,
- "data": [
- 11,
- 12
- ]
- }
- }
- },
- "result": 10
- }
按照卤煮的理解, 范式化数据无非就是给对象瘦瘦身, 再深的层级, 我们也尽量将它们扁平化, 这样会减少我们对 State 的查找带来的性能消耗. 然后是建立索引表, 标识每组数据之间的联系. 那么怎么样才能得到我们想要的数据呢?
normalizr 方法使用指南
官方最荐 normalizr 模块, 它的用法还是需要时间的去研究的. 下面我们就以上面的数据为示例, 说明它的用法:
- $ npm i normalizr -S // 下载模块
- .........
- import {normalize, schema} from 'normalizr';// 日常导入, 没问题
- // 原始数据
- const TablesSource = {
- query: 'tables',
- tableId: 10,
- data: [{
- key: 11,
- name: '胡彦斌',
- age: 32,
- address: '西湖区湖底公园 1 号'
- }, {
- key: 12,
- name: '胡彦祖',
- age: 42,
- address: '西湖区湖底公园 1 号'
- }]
- };
- // 创建实体, 名称为 bykey, 我们看到它的第二个参数是 undefined, 说明它是最后一层级的对象
- const bykey = new schema.Entity('bykey', undefined, {
- idAttribute: 'key'
- });
- // 创建实体, 名字为 table, 索引是 tableid.
- const table = new schema.Entity('table', {
- data: [bykey] // 这里需要说明这些实体的关系, 意思是 bykey 原来 table 下面的是一个数组, 他对应的是 data 数据, bykey 将会取这里的数据建立一个以 key 为索引的对象.
- }, {
- idAttribute: 'tableId'// 以 tableId 为为索引
- });
- const normalizedData = normalize(TablesSource, table);// 生成新数据结构
说明: new schema.Entity 的第一个参数表示你创建的最外层的实体名称, 第二个参数是它和其他新创建的实体的关系, 默认是最小的层级, 即它只是最后一层, 不包含其他层级了. 第三个参数里面有个 idAttribute, 指的是以哪个字段为索引, 默认是 "id", 它也可以是个参数, 返回你自己构造的唯一值, 记住, 是唯一值. 按照这样的套路, 你可以随意构建你想要的扁平化数据结构, 无论源数据的层级有多深. 我们最终都会得到希望的数据结构.
- {
- "entities": {
"bykey": {,, 实体名称
- "11": {// 我们之前设置的唯一所用 key
- "key": 11,
- "name": "胡彦斌",
- "age": 32,
- "address": "西湖区湖底公园 1 号"
- },
- "12": {
- "key": 12,
- "name": "胡彦祖",
- "age": 42,
- "address": "西湖区湖底公园 1 号"
- }
- },
- "table": {// 实体名
"10": {,, 唯一所用 tableid
- "query": "tables",
- "tableId": 10,
- "data": [ //data 变成了储存 key 值索引的集合了! 因为在之前我们说明了两个实体之间的关系 data: [bykey]
- 11,
- 12
- ]
- }
- }
- },
- "result": 10// 这里同样储存着 table 实体里面的索引集合 normalizr(TableSource, table)
- }
github 上有详细的官方文档可供查找, 卤煮在此只是简单的说明一下用法, 诸位可以仔细观察文档上的用法, 不需要多少时间就可以熟练掌握. 到此为止, 万里长城, 终于走完了第一步.
如何将范式化数据后再次转化
什么? 好不容易转化成自己想要的数据结构, 还需要再次转化吗? 很遗憾告诉你, 是的. 因为范式化后的数据只便于我们在维护 Redux, 而界面业务渲染的数据结构往往跟我们处理后的数据是不一样的, 举个栗子: bootstrap 或者 ant.design 的表格渲染数据结构是这个样的:
- const dataSource = [{
- key: '1',
- name: '胡彦斌',
- age: 32,
- address: '西湖区湖底公园 1 号'
- }, {
- key: '2',
- name: '胡彦祖',
- age: 42,
- address: '西湖区湖底公园 1 号'
- }];
因而在界面引用 State 上的数据时, 我们需要一个中介, 把范式化的数据再次转化成业务数据结构. 我相信这个步骤十分简单, 只需要写一个简单的转换器就行了:
- const transform = (source) => {
- const data = source.entities.bykey;
- return Object.keys(data).map(v => data[v]);
- };
- const mapStateToProps = (state,ownProps) => ({table: transform(state)})
- export default connect(mapStateToProps)(view)
如果你在 mapStateToProps 里面断点调试, 你会发现每一次 dispatch 都会强行执行 mapStateProps 方法保证对象的最新状态 (除非你引用的是基础类型数据), 因此, 不管界面的操作是如何, 被 connect 数据都会被强行执行一次, 虽然界面没有变化, 但是显然, js 的性能会有折扣, 尤其是对深度对象的复杂处理. 因此, 官方推荐我们创建可记忆的函数高效计算 Redux Store 里面的衍生数据.
Reselect 方法使用指南
- // 缓存 data 里面的索引
- const reNormalDataSource = (state, props) => state.app.entities.table['10'].data;
- // 缓存 bykey 里面对得基础数据
- const reNormal = (state, props) => state.app.entities.bykey;
- // 缓存计算结果
- const createNormalTableData = createSelector([reNormalDataSource, reNormal], (keys, source) => keys.map(item => source[item]));
- // 每次当 mapStateToProps 重新执行时, 会储存上次计算的结果, 它只会重新计算变化的数据, 其他非相关变化不做计算
- const mapStateToProps = (state, own) => ({source: createNormalTableData(state)});
我在这里做了个耍了点花样, 你可以看到, 我是按照 table.data 这个数组来查找界面业务数据的. 这种操作可以使得我们只需要关心 table.data 这个简单的一维数组, 在删除或者添加一条数据的时候显得尤为有用.
我们最后为了计算 state, 引用了 dot-prop-immutable 模块, 他是 immutable 的扩展, 对于数据计算非常高效. 我接着使用了另外一个 dot-prop-immutable-chain 模块, 它增加了 dot-prop-immutable 的链式用法. 关于 dot-prop-immutable 的用法卤煮不再详细说明, 在后面给出的例子中一眼就能看明白, 而且官方文档上也有详细说明. 下面我们通过一个表格增删查改来实际展示我们这次的解决方案.
- import {normalize, schema} from 'normalizr';
- import dotProp from 'dot-prop-immutable-chain';
- const reducer = (state = normalizedData, action) => {
- switch(action.type) {
- // 修改一条数据
- case 'EDITOR':
- return dotProp(state).set(`entities.bykey.${action.key}.age`, action.age).value();
- // 添加一条数据
- case 'ADD':
- const newId = UID++;
- return dotProp(state).set(`entities.bykey.${newId}`, Object.assign({}, model, {key: newId}))// 添加一条新数据
- .merge(`entities.table.10.data`, [newId]).value();// 新数据的 data 中的引用
- // 删除一条数据
- case 'DELETE':
- const index = state.entities.table['10'].data.indexOf(Number(action.key));
- // 可以看到, 由于我们界面数据是根据 data 里面的项来决定的, 因此我们只需要处理 data 这个简单的一维数组, 而这显然要容易维护得多
- return dotProp(state).delete(`entities.table.10.data.${index}`).value();
- }
- return state;
- };
瞧, 我们展示了整个 reducer, 相信它已经变得容易维护得多了, 并且由于使用了范式化数据结构以及 immutable 的扩展模块, 不仅仅提升了计算性能, 减少界面的的渲染, 而且还符合 Redux 的 State 不可修改的原则.
结束语
React+Redux 组合在实际应用过程中需要优化的地方还很多, 这里只是简单展示其中的一个小点. 虽然在计算 dom 界面变化时 React 已经做得足够好, 但并不意味着我们可以不用为界面渲染问题背锅, React 肩负了多数界面更新计算的任务, 而让前端开发人员更多地去处理数据, 因此, 我们可以在这里层多花点时间把项目做好.
来源: https://www.cnblogs.com/constantince/p/9063345.html