• React 中的不可变数据 — Immer


    Immer 是什么?

    Immer 是一个不可变数据的 Javascript 库,让你更方便的处理不可变数据。

    什么是不可变数据?

    不可变数据概念来源于函数式编程。函数式编程中,对已初始化的“变量”是不可以更改的,每次更改都要创建一个新的“变量”。

    Javascript 在语言层没有实现不可变数据,需要借助第三方库来实现。Immer 就是其中一种实现(类似的还有 immutable.js)。

    为什么使用不可变数据?

    在 React 性能优化一节中用了很长篇幅来介绍 shouldComponentUpdate,不可变数据也是由此引出。使用不可变数据可以解决性能优化引入的问题,所以重点介绍这一部分背景。

    React 中的性能优化

    避免调停(Avoid Reconciliation)

    当一个组件的 props 或 state 变更,React 会将最新返回的元素与之前渲染的元素进行对比,以此决定是否有必要更新真实的 DOM。当它们不相同时,React 会更新该 DOM。虽然 React 已经保证未变更的元素不会进行更新,但即使 React 只更新改变了的 DOM 节点,重新渲染仍然花费了一些时间。在大部分情况下它并不是问题,不过如果它已经慢到让人注意了,你可以通过覆盖生命周期方法 shouldComponentUpdate 来进行提速。该方法会在重新渲染前被触发。其默认实现总是返回 true,让 React 执行更新:

    shouldComponentUpdate(nextProps, nextState) {
      return true;
    }
    

    如果你知道在什么情况下你的组件不需要更新,你可以在 shouldComponentUpdate 中返回 false 来跳过整个渲染过程。其包括该组件的 render 调用以及之后的操作。

    shouldComponentUpdate 的作用

    这是一个组件的子树。每个节点中,SCU 代表 shouldComponentUpdate 返回的值,而 vDOMEq 代表返回的 React 元素是否相同。最后,圆圈的颜色代表了该组件是否需要被调停(Reconciliation)。
    should-component-update
    节点 C2 的 shouldComponentUpdate 返回了 false,React 因而不会调用 C2 的 render,也因此 C4 和 C5 的 shouldComponentUpdate 不会被调用到。

    对于 C1 和 C3,shouldComponentUpdate 返回了 true,所以 React 需要继续向下查询子节点。这里 C6 的 shouldComponentUpdate 返回了 true,同时由于 render 返回的元素与之前不同使得 React 更新了该 DOM。

    最后一个有趣的例子是 C8。React 需要调用这个组件的 render,但是由于其返回的 React 元素和之前相同,所以不需要更新 DOM。

    显而易见,你看到 React 只改变了 C6 的 DOM。对于 C8,通过对比了渲染的 React 元素跳过了真实 DOM 的渲染。而对于 C2 的子节点和 C7,由于 shouldComponentUpdate 使得 render 并没有被调用。因此它们也不需要对比元素了。

    示例

    上一小节有一个有趣的例子 C8,它完全没有发生改变,React 却还是对它进行了调停(Reconciliation)。我们完全可以通过条件判断来避免此类问题,避免调停(Reconciliation),优化性能。

    如果你的组件只有当 props.color 或者 state.count 的值改变才需要更新时,你可以使用 shouldComponentUpdate 来进行检查:

    class CounterButton extends React.Component {
      constructor(props) {
        super(props);
        this.state = {count: 1};
      }
    
      shouldComponentUpdate(nextProps, nextState) {
        if (this.props.color !== nextProps.color) {
          return true;
        }
        if (this.state.count !== nextState.count) {
          return true;
        }
        return false;
      }
    
      render() {
        return (
          <button
            color={this.props.color}
            onClick={() => this.setState(state => ({count: state.count + 1}))}>
            Count: {this.state.count}
          </button>
        );
      }
    }
    

    在这段代码中,shouldComponentUpdate 仅检查了 props.color 或 state.count 是否改变。如果这些值没有改变,那么这个组件不会更新。如果你的组件更复杂一些,你可以使用类似“浅比较”的模式来检查 props 和 state 中所有的字段,以此来决定是否组件需要更新。React 已经提供了一位好帮手来帮你实现这种常见的模式 - 你只要继承 React.PureComponent 就行了(函数组件使用 React.memo)。所以这段代码可以改成以下这种更简洁的形式:

    class CounterButton extends React.PureComponent {
      constructor(props) {
        super(props);
        this.state = {count: 1};
      }
    
      render() {
        return (
          <button
            color={this.props.color}
            onClick={() => this.setState(state => ({count: state.count + 1}))}>
            Count: {this.state.count}
          </button>
        );
      }
    }
    

    但 React.PureComponent 只进行浅比较,所以当 props 或者 state 某种程度是可变的话,浅比较会有遗漏,那你就不能使用它了。比如使用了数组或对象:(以下代码是错误的)

    class ListOfWords extends React.PureComponent {
      render() {
        return <div>{this.props.words.join(',')}</div>;
      }
    }
    
    class WordAdder extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          words: ['marklar']
        };
        this.handleClick = this.handleClick.bind(this);
      }
    
      handleClick() {
        // 这部分代码很糟,而且还有 bug
        const words = this.state.words;
        words.push('marklar');
        this.setState({words: words});
      }
    
      render() {
        return (
          <div>
            <button onClick={this.handleClick} />
            <ListOfWords words={this.state.words} />
          </div>
        );
      }
    }
    

    words 数组使用 push 方法添加了一个元素,但 state 持有的 words 的引用并没有发生变化。push 直接改变了数据本身,并没有产生新的数据,浅比较无法感知到这种变化。React 会产生错误的行为,不会重新执行 render。为了性能优化,引入了另一个问题。

    不可变数据的力量

    避免该问题最简单的方式是避免更改你正用于 props 或 state 的值。例如,上面 handleClick 方法可以用 concat 重写:

    handleClick() {
      this.setState(state => ({
        words: state.words.concat(['marklar'])
      }));
    }
    

    或者使用 ES6 数组扩展运算符:

    handleClick() {
      this.setState(state => ({
        words: [...state.words, 'marklar'],
      }));
    };
    

    但是当处理深层嵌套对象时,以 immutable(不可变)的方式更新它们令人费解。比如可能写出这样的代码:

    handleClick() {
      this.setState(state => ({
        objA: {
          ...state.objA,
          objB: {
            ...state.objA.objB,
            objC: {
              ...state.objA.objB.objC,
              stringA: 'string',
            }
          },
        },
      }));
    };
    

    我们需要一个更友好的库帮助我们直观的使用 immutable(不可变)数据。

    为什么不使用深拷贝/比较?

    深拷贝会让所有组件都接收到新的数据,让 shouldComponentUpdate 失效。深比较每次都比较所有值,当数据层次很深且只有一个值变化时,这些比较是对性能的浪费。

    视图层的代码,我们希望它更快响应,所以使用 immutable 库进行不可变数据的操作,也算是一种空间换时间的取舍。

    为什么是 Immer?

    immutable.js

    • 自己维护了一套数据结构,Javascript 的数据类型和 immutable.js 的类型需要相互转换,对数据有侵入性。
    • 库的体积比较大(63KB),不太适合包体积紧张的移动端。
    • API 极其丰富,学习成本较高。
    • 兼容性非常好,支持 IE 较老的版本。

    immer

    • 使用 Proxy 实现,兼容性差。
    • 体积很小(12KB),移动端友好。
    • API 简洁,使用 Javascript 自己的数据类型,几乎没有理解成本。

    优缺点对比之下,immer 的兼容性缺点在我们的环境下完全可以忽略。使用一个不带来其他概念负担的库还是要轻松很多的。

    Immer 概览

    Immer 基于 copy-on-write 机制。

    Immer 的基本思想是,所有更改都应用于临时的 draftState,它是 currentState 的代理。一旦完成所有变更,Immer 将基于草稿状态的变更生成 nextState。这意味着可以通过简单地修改数据而与数据进行交互,同时保留不可变数据的所有优点。
    immer

    本节围绕 produce 这个核心 API 做介绍。Immer 还提供了一些辅助性 API,详见官方文档

    核心 API:produce

    语法1:

    produce(currentState, recipe: (draftState) => void | draftState, ?PatchListener): nextState

    语法2:

    produce(recipe: (draftState) => void | draftState, ?PatchListener)(currentState): nextState

    使用 produce

    import produce from "immer"
    
    const baseState = [
        {
            todo: "Learn typescript",
            done: true
        },
        {
            todo: "Try immer",
            done: false
        }
    ]
    
    const nextState = produce(baseState, draftState => {
        draftState.push({todo: "Tweet about it"})
        draftState[1].done = true
    })
    

    上面的示例中,对 draftState 的修改都会反映到 nextState 上,并且不会修改 baseState。而 immer 使用的结构是共享的,nextState 在结构上与 currentState 共享未修改的部分。

    // the new item is only added to the next state,
    // base state is unmodified
    expect(baseState.length).toBe(2)
    expect(nextState.length).toBe(3)
    
    // same for the changed 'done' prop
    expect(baseState[1].done).toBe(false)
    expect(nextState[1].done).toBe(true)
    
    // unchanged data is structurally shared
    expect(nextState[0]).toBe(baseState[0])
    // changed data not (dûh)
    expect(nextState[1]).not.toBe(baseState[1])

    柯理化 produce

    给 produce 第一个参数传递函数时将会进行柯理化。它会返回一个函数,该函数接收的参数会被传递给 produce 柯理化时接收的函数。
    示例:

    // mapper will be of signature (state, index) => state
    const mapper = produce((draft, index) => {
        draft.index = index
    })
    
    // example usage
    console.dir([{}, {}, {}].map(mapper))
    // [{index: 0}, {index: 1}, {index: 2}])

    可以很好的利用这种机制简化 reducer

    import produce from "immer"
    
    const byId = produce((draft, action) => {
        switch (action.type) {
            case RECEIVE_PRODUCTS:
                action.products.forEach(product => {
                    draft[product.id] = product
                })
                return
        }
    })
    

    recipe 的返回值

    通常,recipe 不需要显示的返回任何东西,draftState 会自动作为返回值反映到 nextState。你也可以返回任意数据作为 nextState,前提是 draftState 没有被修改。

    const userReducer = produce((draft, action) => {
        switch (action.type) {
            case "renameUser":
                // OK: we modify the current state
                draft.users[action.payload.id].name = action.payload.name
                return draft // same as just 'return'
            case "loadUsers":
                // OK: we return an entirely new state
                return action.payload
            case "adduser-1":
                // NOT OK: This doesn't do change the draft nor return a new state!
                // It doesn't modify the draft (it just redeclares it)
                // In fact, this just doesn't do anything at all
                draft = {users: [...draft.users, action.payload]}
                return
            case "adduser-2":
                // NOT OK: modifying draft *and* returning a new state
                draft.userCount += 1
                return {users: [...draft.users, action.payload]}
            case "adduser-3":
                // OK: returning a new state. But, unnecessary complex and expensive
                return {
                    userCount: draft.userCount + 1,
                    users: [...draft.users, action.payload]
                }
            case "adduser-4":
                // OK: the immer way
                draft.userCount += 1
                draft.users.push(action.payload)
                return
        }
    })
    

    很显然,这样的方式无法返回 undefined

    produce({}, draft => {
        // don't do anything
    })
    
    produce({}, draft => {
        // Try to return undefined from the producer
        return undefined
    })
    

    因为在 Javascript 中,不返回任何值和返回 undefined 是一样的,函数的返回值都是 undefined 。如果你希望 immer 知道你确实想要返回 undefined 怎么办?
    使用 immer 内置的变量 nothing

    import produce, {nothing} from "immer"
    
    const state = {
        hello: "world"
    }
    
    produce(state, draft => {})
    produce(state, draft => undefined)
    // Both return the original state: { hello: "world"}
    
    produce(state, draft => nothing)
    // Produces a new state, 'undefined'

    Auto freezing(自动冻结)

    Immer 会自动冻结使用 produce 修改过的状态树,这样可以防止在变更函数外部修改状态树。这个特性会带来性能影响,所以需要在生产环境中关闭。可以使用 setAutoFreeze(true / false) 打开或者关闭。在开发环境中建议打开,可以避免不可预测的状态树更改。

    在 setState 中使用 immer

    使用 immer 进行深层状态更新很简单:

    /**
     * Classic React.setState with a deep merge
     */
    onBirthDayClick1 = () => {
        this.setState(prevState => ({
            user: {
                ...prevState.user,
                age: prevState.user.age + 1
            }
        }))
    }
    
    /**
     * ...But, since setState accepts functions,
     * we can just create a curried producer and further simplify!
     */
    onBirthDayClick2 = () => {
        this.setState(
            produce(draft => {
                draft.user.age += 1
            })
        )
    }
    

    基于 produce 提供了柯理化的特性,直接将 produce 柯理化的返回值传递给 this.setState 即可。在 recipe 内部做你想要做的状态变更。符合直觉,不引入新概念。

    以 hook 方式使用 immer

    Immer 同时提供了一个 React hook 库 use-immer 用于以 hook 方式使用 immer。

    useImmer

    useImmer 和 useState 非常像。它接收一个初始状态,返回一个数组。数组第一个值为当前状态,第二个值为状态更新函数。状态更新函数和 produce 中的 recipe 一样运作。

    import React from "react";
    import { useImmer } from "use-immer";
    
    
    function App() {
      const [person, updatePerson] = useImmer({
        name: "Michel",
        age: 33
      });
    
      function updateName(name) {
        updatePerson(draft => {
          draft.name = name;
        });
      }
    
      function becomeOlder() {
        updatePerson(draft => {
          draft.age++;
        });
      }
    
      return (
        <div className="App">
          <h1>
            Hello {person.name} ({person.age})
          </h1>
          <input
            onChange={e => {
              updateName(e.target.value);
            }}
            value={person.name}
          />
          <br />
          <button onClick={becomeOlder}>Older</button>
        </div>
      );
    }
    

    很显然,对这个例子来讲,无法体现 immer 的作用:)。只是个展示用法的例子。

    useImmerReducer

    对 useReducer 的封装:

    import React from "react";
    import { useImmerReducer } from "use-immer";
    
    const initialState = { count: 0 };
    
    function reducer(draft, action) {
      switch (action.type) {
        case "reset":
          return initialState;
        case "increment":
          return void draft.count++;
        case "decrement":
          return void draft.count--;
      }
    }
    
    function Counter() {
      const [state, dispatch] = useImmerReducer(reducer, initialState);
      return (
        <>
          Count: {state.count}
          <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
          <button onClick={() => dispatch({ type: "increment" })}>+</button>
          <button onClick={() => dispatch({ type: "decrement" })}>-</button>
        </>
      );
    }
    漫思
  • 相关阅读:
    web selenium 小笔记
    C#全局变量的使用
    一些传统阻止了新事物的发展
    javascript 类
    无聊的文档
    asp.net调用c++写的dll或ocx 方法
    在ASP.NET中的DatePicker控件
    保护眼睛的背景色
    web引用程序调用 windows的服务
    asp.net常用数据类型
  • 原文地址:https://www.cnblogs.com/sexintercourse/p/15690013.html
Copyright © 2020-2023  润新知