基于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.

1import { useState, useEffect } from 'react'; 2// 模拟异步事件 3function getMsg() { 4 return new Promise((resolve) => { 5 setTimeout(() => { 6 resolve('React') 7 }, 1000) 8 }) 9} 10 11export default function Hello() { 12 const [msg, setMsg] = useState('World') 13 useEffect(() => { 14 getMsg().then((msg) => { 15 setMsg(msg) 16 }) 17 const timer = setInterval(() => { 18 console.log('test interval') 19 }) 20 return () => { 21 // 清除异步事件 22 clearTimeout(timer) 23 } 24 }, []) 25 return ( 26 <h1>Hello { msg }</h1> 27 ); 28} 29 30

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

Vue: watcher

运行期自动依赖收集 示例

1<script setup> 2import { ref } from 'vue' 3const msg = ref('World!') 4 5setTimeout(() => { 6 msg.value = 'Vue' 7}, 1000) 8</script> 9 10<template> 11 <h1>Hello {{ msg }}</h1> 12</template> 13 14
1_createElementVNode("h1", null, _toDisplayString(msg.value), 1 /* TEXT */) 2 3

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

Svelte: $

编译器依赖收集 示例

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

1<script> 2 let name = 'world'; 3 $: suffix = name + '!' 4 setTimeout(() => { 5 name = 'svelte' 6 }, 1000) 7</script> 8 9<h1>Hello {suffix}</h1> 10 11 12
1// 编译后部分代码 2function instance($$self, $$props, $$invalidate) { 3 let suffix 4 let name = 'world' 5 6 setTimeout(() => { 7 $$invalidate(1, (name = 'svelte')) 8 }, 1000) 9 // 更新关系 10 $$self.$$.update = () => { 11 if ($$self.$$.dirty & /*name*/ 2) { 12 $: $$invalidate(0, (suffix = name + '!')) 13 } 14 } 15 16 return [suffix, name] 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达到值的状态更新等副作用操作,因此声明周期需要自己模拟实现

1import { useState, useEffect } from 'react'; 2 3export default function Hello() { 4 const [msg, setMsg] = useState('World') 5 // dependency是空 因此只会在第一次执行 声明周期上可以理解为onMounted 6 useEffect(() => { 7 // 异步事件 8 const timer = setTimeout(() => { 9 // setMsg会触发重渲染 https://react.dev/learn/render-and-commit 10 setMsg('React') 11 }, 1000) 12 return () => { 13 // 卸载时/重新执行Effect前 清除异步事件 14 clearTimeout(timer) 15 } 16 // 如果dependency有值 则每次更新如果dependency不一样就会执行Effect 17 }, []) 18 return ( 19 <h1>Hello { msg }</h1> 20 ); 21} 22 23
1<script setup> 2import { onMounted, onUnmounted, onUpdated, ref } from 'vue' 3 4const msg = ref('Hello World!') 5// 挂载 6onMounted(async () => { 7 function getValue() { 8 return Promise.resolve('hello, vue') 9 } 10 const value = await getValue() 11 msg.value = value 12}) 13onUpdated(() => {}) // 更新 14onUnmounted(() => {}) // 卸载 15</script> 16 17<template> 18 <h1>{{ msg }}</h1> 19 <input v-model="msg"> 20</template> 21 22
1<script> 2 import { onMount, onDestroy, beforeUpdate } from 'svelte' 3 let name = 'world' 4 $: suffix = name + '!' 5 6 onMount(() => { 7 setTimeout(() => { 8 name = 'svelte' 9 }, 1000) 10 }) 11 beforeUpdate(() => {}) // 更新 12 onDestroy(() => {}) // 卸载/销毁 13</script> 14 15<h1>Hello {suffix}</h1> 16 17

Action 用户行为

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

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

点击/输入

1<!-- 原生 要求onClick是全局变量 --> 2<div onclick="onClick"/> 3<!-- React --> 4<div onClick={onClick}/> 5<!-- Vue --> 6<div @click="onClick"/> 7<!-- Svelte --> 8<div on:click="onClick"/> 9 10

滑动输入、键盘输入等

1<!-- React view和model的关系需要自己处理 --> 2<input value={value} onChange={val => setValue(val)} placeholder="enter your name" /> 3<!-- Vue 通过指令自动建立view和model的绑定关系 --> 4<input v-model="name" placeholder="enter your name" /> 5<!-- Svelte --> 6<input bind:value={name} placeholder="enter your name" /> 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 其实也可以兼容从接口获取、存储数据

1export function getItem(key) { 2 const now = Date.now(); 3 if (window.XWebView) { 4 window.XWebView.callNative( 5 'JDBStoragePlugin', 6 'getItem', 7 JSON.stringify({ 8 key, 9 }), 10 `orange_${now}`, 11 '-1', 12 ); 13 } else { 14 setTimeout(() => { 15 window[`orange_${now}`]( 16 JSON.stringify({ 17 status: '0', 18 data: { 19 result: 'success', 20 data: localStorage.getItem(key), 21 }, 22 }), 23 ); 24 }, 0); 25 } 26 return new Promise((resolve, reject) => { 27 window[`orange_${now}`] = (result) => { 28 try { 29 const obj = JSON.parse(result); 30 const { status, data } = obj; 31 if (status === '0' && data && data.result === 'success') { 32 resolve(data.data); 33 } else { 34 reject(result); 35 } 36 } catch (e) { 37 reject(e); 38 } 39 window[`orange_${now}`] = undefined; 40 }; 41 }); 42} 43 44export function setItem(key, value = BABEL_CHANNEL) { 45 const now = Date.now(); 46 if (window.XWebView) { 47 window.XWebView.callNative( 48 'JDBStoragePlugin', 49 'setItem', 50 JSON.stringify({ 51 key, 52 value, 53 }), 54 `orange_${now}`, 55 '-1', 56 ); 57 } else { 58 setTimeout(() => { 59 window[`orange_${now}`]( 60 JSON.stringify({ 61 status: '0', 62 data: { 63 result: 'success', 64 data: localStorage.setItem(key, value), 65 }, 66 }), 67 ); 68 }, 0); 69 } 70 return new Promise((resolve, reject) => { 71 window[`orange_${now}`] = (result) => { 72 console.log('MKT ~ file: storage.js:46 ~ returnnewPromise ~ result:', result); 73 try { 74 const obj = JSON.parse(result); 75 const { status, data } = obj; 76 if (status === '0' && data && data.result === 'success') { 77 resolve(data.data); 78 } else { 79 reject(result); 80 } 81 } catch (e) { 82 reject(e); 83 } 84 window[`orange_${now}`] = undefined; 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兼容搜索和下拉场景
1const { result: list, hasNext } = await this.loadData(param).catch(() => ({ hasNext: false, result: [] })) 2const lastRemove = this.remove // 本次新增之前移除的内容 3if (param.pageNo === 1 && !param.search) { 4 this.list = list 5} else { 6 // 建立新值的索引 接口返回的信息是无状态属性的(选中与否) 7 const map = list.reduce((pre, cur) => { 8 pre[cur.id] = Object.assign(cur, { from: param.search }) 9 return pre 10 }, {}) 11 // 此处应该遍历list 而不是 this.list 12 this.list = this.list.map(item => { 13 const diff = map[item.id] 14 // 找到之前已经有的数据 就从map中移动到之前list的位置做替换 15 if (diff) delete map[item.id] 16 return diff || item 17 // 剩余的值补充到最后面 18 }).concat(Object.values(map)) 19} 20const value = diffBy(this.last.add.concat(this.remote, this.local, this.checked), lastRemove) 21this.value = value 22 23

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

参考资料

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

点赞
收藏

评论区

加载中...

相关推荐

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

皕杰报表之UUID

​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为

什么是函数式编程?

原文链接:什么是函数式编程?函数式编程(FunctionalProgramming,FP)就是利用纯函数实现细粒度的函数,然后再通过函数的组合把细粒度的函数组合成功能更强大的函数。函数式编程中的"函数"不是程序中的函数(方法),而是数学中的函数(映射关系)

React Hooks 快速上手

ReactHook快速上手一、Hook简介1.1Hook历史在ReactHook出现之前的版本中,组件主要分为两种:函数式组件和类组件。其中,函数式组件通常只考虑负责UI的渲染,没有自身的状态也没有业务逻辑代码,是一个纯函数。而类组件则不同,类组件有自己的内部

Python中的函数式编程教程,学会用一行代码搞定

01前言在本文中,您将了解什么是函数范型,以及如何在Python中使用函数式编程。在Python中,函数式编程中的map和filter可以做与列表相同的事情。这打破了Python的禅宗规则之一,因此函数式编程的这些部分不被认为是“Python式的”。但是由于函数式编程高阶编程的必经之路,所以我们需要了解甚至熟练掌握。02

HIVE 时间操作函数

日期函数UNIX时间戳转日期函数: from\_unixtime语法:   from\_unixtime(bigint unixtime\, string format\)返回值: string说明: 转化UNIX时间戳(从19700101 00:00:00 UTC到指定时间的秒数)到当前时区的时间格式举例:hive   selec