• 基于Effect的组件设计 | 京东云技术团队


    Effect的概念起源

    输入输出的角度理解Effect https://link.excalidraw.com/p/readonly/KXAy7d2DlnkM8X1yps6L

    编程中的Effect起源于函数式编程中纯函数的概念

    纯函数是指在相同的输入下,总是产生相同的输出,并且没有任何副作用(side effect)的函数。

    副作用是指函数执行过程中对函数外部环境进行的可观察的改变,比如修改全局变量、打印输出、写入文件等。

    前端的典型副作用场景是 浏览器环境中在window上注册变量

    副作用引入了不确定性,使得程序的行为难以预测和调试。为了处理那些需要进行副作用的操作,函数式编程引入了Effect的抽象概念。

    它可以表示诸如读取文件、写入数据库、发送网络请求DOM渲染等对外部环境产生可观察改变的操作。通过将这些操作包装在Effect中,函数式编程可以更好地控制和管理副作用,使得代码更具可预测性和可维护性。

    实际工作中我们也是从React的useEffect开始直接使用Effect的说法

    React: useEffect

    useEffect is a React Hook that lets you synchronize a component with an external system.

    import { useState, useEffect } from 'react';
    // 模拟异步事件
    function getMsg() {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve('React')
        }, 1000)
      })
    }
    
    export default function Hello() {
      const [msg, setMsg] = useState('World')
      useEffect(() => {
        getMsg().then((msg) => {
          setMsg(msg)
        })
        const timer = setInterval(() => {
          console.log('test interval')
        })
        return () => {
          // 清除异步事件
          clearTimeout(timer)
        }
      }, [])
      return (
        

    Hello { msg }

    ); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    Effect中处理异步事件,并在此处消除异步事件的副作用clearTimeout(timer),避免闭包一直无法被销毁

    Vue: watcher

    运行期自动依赖收集 示例

    
    
    
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    _createElementVNode("h1", null, _toDisplayString(msg.value), 1 /* TEXT */)
    
    
    
    • 1
    • 2
    • 3

    runtime的render期间通过msg.value对msg产生了引用,此时产生了一个watch effect:msg的watchlist中多了一个render的watcher,在msg变化的时候 render会通过watcher重新执行

    Svelte: $

    编译器依赖收集 示例

    suffix的值依赖name,在name变化之后,suffix值也更新

    
    
    

    Hello {suffix}

    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    // 编译后部分代码
    function instance($$self, $$props, $$invalidate) {
      let suffix
      let name = 'world'
    
      setTimeout(() => {
        $$invalidate(1, (name = 'svelte'))
      }, 1000)
      // 更新关系
      $$self.$$.update = () => {
        if ($$self.$$.dirty & /*name*/ 2) {
          $: $$invalidate(0, (suffix = name + '!'))
        }
      }
    
      return [suffix, name]
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    Effect分类

    React先介绍了两种典型的Effect

    • 渲染逻辑中可以获取 props 和 state,并对它们进行转换,然后返回您希望在屏幕上看到的 JSX。渲染代码必须是纯的,就像数学公式一样,它只应该计算结果,而不做其他任何事情。
    • 事件处理程序是嵌套在组件内部的函数,它们执行操作而不仅仅做计算。事件处理程序可以更新输入字段、提交HTTP POST请求以购买产品或将用户导航到另一个页面。它包含由用户特定操作(例如按钮点击或输入)引起的 “副作用”(它们改变程序的状态)。

    Consider a ChatRoom component that must connect to the chat server whenever it’s visible on the screen. Connecting to a server is not a pure calculation (it’s a side effect) so it can’t happen during rendering. However, there is no single particular event like a click that causes ChatRoom to be displayed.

    考虑一个ChatRoom组件,每当它在屏幕上可见时都必须连接到聊天服务器。连接到服务器不是一个纯粹的计算(它是一个副作用),因此不能在渲染期间发生(渲染必须是纯函数)。然而,并没有单个特定的事件(如点击)会触发ChatRoom的展示

    Effects let you specify side effects that are caused by rendering itself, rather than by a particular event. Sending a message in the chat is an event because it is directly caused by the user clicking a specific button. However, setting up a server connection is an Effect because it should happen no matter which interaction caused the component to appear. Effects run at the end of a commit after the screen updates. This is a good time to synchronize the React components with some external system (like network or a third-party library).

    Effect 允许指定由渲染本身引起的副作用,而不是由特定事件引起的副作用。在聊天中发送消息是一个事件,因为它直接由用户点击特定按钮引起。然而不管是任何交互触发的组件展示,_设置服务器连接_都是一个Effect。Effect会在页面更新后的commit结束时运行。这是与某个外部系统(如网络或第三方库)同步React组件的好时机

    以下Effect尽量达到不重不漏,不重的意义是他们之间是相互独立的,每个模块可以独立实现,这样可以在系统设计的初期可以将业务Model建设和Effect处理分离,甚至于将Effects提取成独立的utils

    渲染

    生命周期

    组件被初始化、更新、卸载的时候我们需要做一些业务逻辑处理,例如:组件初始化时调用接口更新数据

    React

    react基于自己的fiber结构,通过闭包完成状态的管理,不会建立值和渲染过程的绑定关系,通过在commit之后执行Effect达到值的状态更新等副作用操作,因此声明周期需要自己模拟实现

    import { useState, useEffect } from 'react';
    
    export default function Hello() {
      const [msg, setMsg] = useState('World')
      // dependency是空 因此只会在第一次执行 声明周期上可以理解为onMounted
      useEffect(() => {
        // 异步事件
        const timer = setTimeout(() => {
          // setMsg会触发重渲染 https://react.dev/learn/render-and-commit
          setMsg('React')
        }, 1000)
        return () => {
          // 卸载时/重新执行Effect前 清除异步事件
          clearTimeout(timer)
        }
      // 如果dependency有值 则每次更新如果dependency不一样就会执行Effect
      }, [])
      return (
        

    Hello { msg }

    ); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    
    
    
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    
    
    

    Hello {suffix}

    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    Action 用户行为

    对应React中提到的两个典型Effect中的 事件处理程序

    在不考虑跳出应用(location.href='xxx')的情况下,我们的行为都只能改变当前应用的状态,不管是输入、选择还是触发异步事件的提交,网络相关的副作用在下节讨论

    点击/输入
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    滑动输入、键盘输入等

    
     setValue(val)} placeholder="enter your name" />
    
    
    
    
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    所谓的MVVM即是视图和模型的绑定关系通过框架(v-mode,bind:valuel)完成,所以需要自己处理绑定关系的React不是MVVM

    滚动

    同上

    Network 网络请求

    基础:XMLHttpRequest,Fetch

    NPM包:Axios,useSwr

    Storage 存储

    任何存储行为都是副作用:POST请求、变量赋值、local存储、cookie设置、URL参数设置

    Remote

    缓存/数据库,同上 网络请求

    Local

    内存

    • 局部变量 闭包

    React的函数式组件中的useState的值的变更

    • 全局变量 window

    浏览器环境初始化完成之后,我们的context中就会有window全局变量,修改window的属性会使同一个页面环境中的所有内容都被影响(微前端的window隔离方案除外)

    LocalStorage

    兼容localStorage存储和 原生APP存储;返回Promise 其实也可以兼容从接口获取、存储数据

    export function getItem(key) {
      const now = Date.now();
      if (window.XWebView) {
        window.XWebView.callNative(
          'JDBStoragePlugin',
          'getItem',
          JSON.stringify({
            key,
          }),
          `orange_${now}`,
          '-1',
        );
      } else {
        setTimeout(() => {
          window[`orange_${now}`](
            JSON.stringify({
              status: '0',
              data: {
                result: 'success',
                data: localStorage.getItem(key),
              },
            }),
          );
        }, 0);
      }
      return new Promise((resolve, reject) => {
        window[`orange_${now}`] = (result) => {
          try {
            const obj = JSON.parse(result);
            const { status, data } = obj;
            if (status === '0' && data && data.result === 'success') {
              resolve(data.data);
            } else {
              reject(result);
            }
          } catch (e) {
            reject(e);
          }
          window[`orange_${now}`] = undefined;
        };
      });
    }
    
    export function setItem(key, value = BABEL_CHANNEL) {
      const now = Date.now();
      if (window.XWebView) {
        window.XWebView.callNative(
          'JDBStoragePlugin',
          'setItem',
          JSON.stringify({
            key,
            value,
          }),
          `orange_${now}`,
          '-1',
        );
      } else {
        setTimeout(() => {
          window[`orange_${now}`](
            JSON.stringify({
              status: '0',
              data: {
                result: 'success',
                data: localStorage.setItem(key, value),
              },
            }),
          );
        }, 0);
      }
      return new Promise((resolve, reject) => {
        window[`orange_${now}`] = (result) => {
          console.log('MKT ~ file: storage.js:46 ~ returnnewPromise ~ result:', result);
          try {
            const obj = JSON.parse(result);
            const { status, data } = obj;
            if (status === '0' && data && data.result === 'success') {
              resolve(data.data);
            } else {
              reject(result);
            }
          } catch (e) {
            reject(e);
          }
          window[`orange_${now}`] = undefined;
        };
      });
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89

    Cookie

    https://www.npmjs.com/package/js-cookie

    URL

    参见地址栏参数

    举个栗子🌰

    组件诉求

    1. 支持分页

    2. 支持搜索

    3. 已选择的门店需要回显,但是已选择的门店只能分页获取,无法全部获取

    4. 需要知道用户移除了哪些选项,增加了哪些选项

    5. 支持服务端全选

    组件Effect分析

    • 业务组件可以视load-data为纯函数,因为loda-data的调用不会影响外部业务组件,清晰的Effects归属可以降低业务的复杂度,最大程度上降低组件的耦合
    • 用户在组件内的行为(除了确定之外)产生的Effect只对组件自身产生影响,提升了组件的内聚

    组件模型设计

    • 组件list兼容搜索和下拉场景
    const { result: list, hasNext } =  await this.loadData(param).catch(() => ({ hasNext: false, result: [] }))
    const lastRemove = this.remove // 本次新增之前移除的内容
    if (param.pageNo === 1 && !param.search) {
      this.list = list
    } else {
      // 建立新值的索引 接口返回的信息是无状态属性的(选中与否)
      const map = list.reduce((pre, cur) => {
        pre[cur.id] = Object.assign(cur, { from: param.search })
        return pre
      }, {})
      // 此处应该遍历list 而不是 this.list
      this.list = this.list.map(item => {
        const diff = map[item.id]
        // 找到之前已经有的数据 就从map中移动到之前list的位置做替换
        if (diff) delete map[item.id]
        return diff || item
        // 剩余的值补充到最后面
      }).concat(Object.values(map))
    }
    const value = diffBy(this.last.add.concat(this.remote, this.local, this.checked), lastRemove)
    this.value = value
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    • 接口返回选中的值通过checked-by-remote纯函数的依赖反转实现惰性计算
    • 业务组件默认选中的值通过checked-by-local纯函数的依赖反转实现惰性计算
    • 增加或者移除的值通过相应的diff计算出来
    • Reactivity极大提升了Model的表达能力
    {
      computed: {
        /**
         * 接口返回已选中的数据且不能在已移除的数据中, 否则上次移除的数据会被自动选中
         */
        remote() {
          return diffBy(this.list.filter(this.checkedByRemote || emptyFilter).map(it => it.id), this.last.remove)
        },
        /**
         * 本地默认选中 且不是从remote选中的 且不是上次选中的
         */
        local() {
          return diffBy(this.list.filter(this.checkedByLocal || emptyFilter).map(it => it.id), this.remote, this.last.add)
        },
        // 用户选择的
        checked() {
          return diffBy(this.value, this.remote, this.last.add, this.local)
        },
        // 1. 本地有接口没有的 是新增,this.value中已包含了last.add 2. 需要新增的且不在上次本地移除的范围内:上次移除的可能不在this.remote范围内
        add() {
          return diffBy(this.value, this.remote, this.last.remove)
        },
        // 1. 接口有本地没有的 是移除 2. 需要移除的 且 不在上次本地新增的范围内
        remove() {
          return this.last.remove.concat(diffBy(this.remote, this.value, this.last.remove))
        }
      },
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    参考资料

    作者:京东零售 刘威
    来源:京东云开发者社区 转载请注明来源

  • 相关阅读:
    为何越来越多人选择进入软件测试行业?深度剖析软件测试的优势...
    List集合
    ES6学习系列
    服务器数据恢复—误还原虚拟机快照后如何恢复之前的数据?
    免费小程序商城搭建之b2b2c o2o 多商家入驻商城 直播带货商城 电子商务b2b2c o2o 多商家入驻商城 直播带货商城 电子商务
    Java基础20221105
    Android Studio gradle 默认sourceSets配置
    快速查找swagger接口的插件
    【Appium UI自动化】pytest运行常见错误解决办法
    spark运行报错
  • 原文地址:https://blog.csdn.net/JDDTechTalk/article/details/133764017