写点什么

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

  • 2023-10-11
    北京
  • 本文字数:6068 字

    阅读完需:约 20 分钟

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


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

Vue: watcher

运行期自动依赖收集 示例


<script setup>import { ref } from 'vue'const msg = ref('World!')
setTimeout(() => { msg.value = 'Vue'}, 1000)</script>
<template> <h1>Hello {{ msg }}</h1></template>
复制代码


_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 值也更新


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


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


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


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


<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')的情况下,我们的行为都只能改变当前应用的状态,不管是输入、选择还是触发异步事件的提交,网络相关的副作用在下节讨论

点击/输入

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


滑动输入、键盘输入等


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


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


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



  • 接口返回选中的值通过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))    }  },}
复制代码

参考资料


作者:京东零售 刘威

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

发布于: 10 分钟前阅读数: 6
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
基于Effect的组件设计 | 京东云技术团队_前端_京东科技开发者_InfoQ写作社区