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

Effect的概念起源

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

编程中的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.

javascript 复制代码
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 (
    <h1>Hello { msg }</h1>
  );
}

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

Vue: watcher

运行期自动依赖收集 示例

xml 复制代码
<script setup>
import { ref } from 'vue'
const msg = ref('World!')

setTimeout(() => {
  msg.value = 'Vue'
}, 1000)
</script>

<template>
  <h1>Hello {{ msg }}</h1>
</template>
csharp 复制代码
_createElementVNode("h1", null, _toDisplayString(msg.value), 1 /* TEXT */)

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

Svelte: $

编译器依赖收集 示例

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

xml 复制代码
<script>
    let name = 'world';
    $: suffix = name + '!'
    setTimeout(() => {
        name = 'svelte'
    }, 1000)
</script>

<h1>Hello {suffix}</h1>
php 复制代码
// 编译后部分代码
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]
}

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达到值的状态更新等副作用操作,因此声明周期需要自己模拟实现

javascript 复制代码
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 (
    <h1>Hello { msg }</h1>
  );
}
xml 复制代码
<script setup>
import { onMounted, onUnmounted, onUpdated, ref } from 'vue'

const msg = ref('Hello World!')
// 挂载
onMounted(async () => {
  function getValue() {
    return Promise.resolve('hello, vue')
  }
  const value = await getValue()
  msg.value = value
})
onUpdated(() => {}) // 更新
onUnmounted(() => {}) // 卸载
</script>

<template>
  <h1>{{ msg }}</h1>
  <input v-model="msg">
</template>
xml 复制代码
<script>
  import { onMount, onDestroy, beforeUpdate } from 'svelte'
  let name = 'world'
  $: suffix = name + '!'

  onMount(() => {
    setTimeout(() => {
      name = 'svelte'
    }, 1000)
  })
  beforeUpdate(() => {}) // 更新
  onDestroy(() => {}) // 卸载/销毁
</script>

<h1>Hello {suffix}</h1>

Action 用户行为

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

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

点击/输入

xml 复制代码
<!-- 原生 要求onClick是全局变量 -->
<div onclick="onClick"/>
<!-- React -->
<div onClick={onClick}/>
<!-- Vue -->
<div @click="onClick"/>
<!-- Svelte -->
<div on:click="onClick"/>

滑动输入、键盘输入等

xml 复制代码
<!-- React view和model的关系需要自己处理 -->
<input value={value} onChange={val => setValue(val)} placeholder="enter your name" />
<!-- Vue 通过指令自动建立view和model的绑定关系 -->
<input v-model="name" placeholder="enter your name" />
<!-- Svelte -->
<input bind:value={name} placeholder="enter your name" />

所谓的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 其实也可以兼容从接口获取、存储数据

javascript 复制代码
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;
    };
  });
}

www.npmjs.com/package/js-...

URL

参见地址栏参数

举个栗子🌰

组件诉求

1.支持分页

2.支持搜索

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

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

5.支持服务端全选

组件Effect分析

•业务组件可以视load-data为纯函数,因为loda-data的调用不会影响外部业务组件,清晰的Effects归属可以降低业务的复杂度,最大程度上降低组件的耦合

•用户在组件内的行为(除了确定之外)产生的Effect只对组件自身产生影响,提升了组件的内聚

组件模型设计

•组件list兼容搜索和下拉场景

kotlin 复制代码
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

•接口返回选中的值通过checked-by-remote纯函数的依赖反转实现惰性计算

•业务组件默认选中的值通过checked-by-local纯函数的依赖反转实现惰性计算

•增加或者移除的值通过相应的diff计算出来

•Reactivity极大提升了Model的表达能力

kotlin 复制代码
{
  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))
    }
  },
}

参考资料

•面向 Model 编程的前端架构设计 mp.weixin.qq.com/s/g4hnfirDm...

•Synchronizing with Effects react.dev/learn/synch...

作者:京东零售 刘威

来源:京东云开发者社区 转载请注明来源

相关推荐
L耀早睡23 分钟前
mapreduce打包运行
大数据·前端·spark·mapreduce
HouGISer37 分钟前
副业小程序YUERGS,从开发到变现
前端·小程序
outstanding木槿42 分钟前
react中安装依赖时的问题 【集合】
前端·javascript·react.js·node.js
霸王蟹1 小时前
React中useState中更新是同步的还是异步的?
前端·javascript·笔记·学习·react.js·前端框架
霸王蟹1 小时前
React Hooks 必须在组件最顶层调用的原因解析
前端·javascript·笔记·学习·react.js
专注VB编程开发20年2 小时前
asp.net IHttpHandler 对分块传输编码的支持,IIs web服务器后端技术
服务器·前端·asp.net
爱分享的程序员2 小时前
全栈项目搭建指南:Nuxt.js + Node.js + MongoDB
前端
隐含3 小时前
webpack打包,把png,jpg等文件按照在src目录结构下的存储方式打包出来。解决同一命名的图片资源在打包之后,重复命名的图片就剩下一个图片了。
前端·webpack·node.js
lightYouUp3 小时前
windows系统中下载好node无法使用npm
前端·npm·node.js
Dontla3 小时前
npm cross-env工具包介绍(跨平台环境变量设置工具)
前端·npm·node.js