alibaba / formily

📱🚀 🧩 Cross Device & High Performance Normal Form/Dynamic(JSON Schema) Form/Form Builder -- Support React/React Native/Vue 2/Vue 3
https://formilyjs.org/
MIT License
11.32k stars 1.48k forks source link

[Bug Report] formily react被动联动计算与预期不符合 #3837

Open febugcoder opened 1 year ago

febugcoder commented 1 year ago

Reproduction link

Edit on CodeSandbox

Steps to reproduce

需求

有A、B、C、D四个字段,其中C = A / B,D = C * B

react版本有问题:https://codesandbox.io/s/admiring-glade-puqoq5?file=/src/App.js:539-551

reactive版本没问题:https://codesandbox.io/s/empty-star-pk8sic?file=/src/index.js

操作步骤

  1. 输入A = 2
  2. 输入B = 1
  3. 此时C会计算出来2,但是D没有算出值

问题

为什么D会计算不出来结果?当C被计算出来之后,为什么D的onFieldReact没有再跑一次?

What is expected?

按操作步骤,能计算出D值

What is actually happening?

按操作步骤,无法计算出D值

Package

@formily/react@2.2.24


janryWang commented 1 year ago

看了下, reactive 是能复现的 https://codesandbox.io/s/old-violet-hxcn61?file=/src/index.js

Landon-CN commented 1 year ago
setTimeout(() => {
  console.log('change a')
  obs.A = 1
  setTimeout(() => {
    console.log('chagne b')
    obs.B = 2
  }, 1000)
}, 1000)

autorun(() => {
  const A = obs.A
  const B = obs.B
  if (A !== undefined && B !== undefined) {
    obs.C = A / B
    console.log('calc C', obs.C)
  }
}, 'C')

autorun(() => {
  const C = obs.C
  const B = obs.B
  if (C !== undefined && B !== undefined) {
    obs.D = C * B
    console.log('calc D', obs.D)
  }
}, 'D')

以你的demo来看,问题出在 obs.B=2这一步 obs.B=2,这时候 PendingReactions 的值是[autorun D,autorun C] 执行栈如下 -> 执行 autorun D(第一次) ---> autorun D(第一次) finally batchEnd ---> batchEnd继续执行autorun C -------> autorun C 触发 obs.C = A / B,此时PendingReactions = [autorun D(第二次)] -------> autorun C finally batchEnd触发 autorun D(第二次) -------------> 此时 autorun D._boundary = 1 , 走入分支 if (reaction._boundary > 0) return 就是这里导致了计算结果异常 -------> autorun C finally batchEnd 执行结束 ---> autorun D(第一次) finally batchEnd 执行结束 reaction._boundary=0

感觉解决方案就是在batchStart执行的途中,假如tracker产生了新的reaction 是否可以使用 microTask的形式异步插入PendingReactions.保证前面的任务执行完毕再执行下一批。 不知道可行不,还没验证 @janryWang

janryWang commented 1 year ago

我还在想,可能boundary判断需要更精细化一些,现在的问题就是响应来源有多个的时候被过滤掉了,如果做一个响应来源控制,应该是可以解决这个问题的

hchlq commented 1 year ago

boundary 判断需要更精细化一些,通过响应源控制是可以控制。这个方法是可行的,我来处理这个 bug

MeetzhDing commented 8 months ago

@janryWang @hchlq 想了解一些,这个boundary主要的意义是什么?我看是在21年引入的,原问题的复现链接已经失效了,没有看懂。

看起来主要目的是为了即让 Reaction 能够循环触发,又不想让它会重复执行最终爆栈。 但 reaction 这个响应式 api 也没有添加这个 boundary 的逻辑?这导致 reaction api 也可能会触发爆栈

在 一个响应式系统中,任务似乎不应当允许自身直接或者间接触发自身吧?

我测试了一下最新的 mobx autorun 逻辑,看起来并没有这个bug

https://runkit.com/embed/zfkqf2qdmx5y

var mobx = require("mobx")

const autorun = mobx.autorun;
const observable = mobx.observable;

const obs = observable({})

setTimeout(() => {
  console.log('change a')
  obs.A = 1
  setTimeout(() => {
    console.log('chagne b')
    obs.B = 2
  }, 1000)
}, 1000)

autorun(() => {
  const A = obs.A
  const B = obs.B
  if (A !== undefined && B !== undefined) {
    obs.C = A / B
    console.log('calc C', obs.C)
  }
})

autorun(() => {
  const C = obs.C
  const B = obs.B
  if (C !== undefined && B !== undefined) {
    obs.D = C * B
    console.log('calc D', obs.D)
  }
})

mobx 同样代码的效果:

image
MeetzhDing commented 8 months ago

vue/reactive 也没有复现这个问题

https://runkit.com/embed/zqlwosvqxnqy

var reactivity = require("@vue/reactivity")

const autorun = reactivity.effect;
const observable = reactivity.reactive;

const obs = observable({})

setTimeout(() => {
  console.log('change a')
  obs.A = 1
  setTimeout(() => {
    console.log('chagne b')
    obs.B = 2
  }, 1000)
}, 1000)

autorun(() => {
  const A = obs.A
  const B = obs.B
  if (A !== undefined && B !== undefined) {
    obs.C = A / B
    console.log('calc C', obs.C)
  }
})

autorun(() => {
  const C = obs.C
  const B = obs.B
  if (C !== undefined && B !== undefined) {
    obs.D = C * B
    console.log('calc D', obs.D)
  }
})
image
MeetzhDing commented 8 months ago

我发现了当 obs 有初始值 {A:1} 时,这个地方单测就能够通过。 autorun 在每次联动完成以后,如果来源的值是相等的,是否应该是幂等的?

test('autorun with multiple source', async () => {
  // 如果 obs 默认是 {}, 单测会失败
  // const obs = observable<any>({})

  // 如果 obs 默认是 { A: 1 },则单测会通过
  const obs = observable<any>({ A: 1 })

  autorun(() => {
    const A = obs.A
    const B = obs.B
    if (A !== undefined && B !== undefined) {
      obs.C = A / B
      console.log('calc C', obs.C)
    }
  })

  autorun(() => {
    const C = obs.C
    const B = obs.B
    if (C !== undefined && B !== undefined) {
      obs.D = C * B
      console.log('calc D', obs.D)
    }
  })

  setTimeout(() => {
    obs.A = 1
    setTimeout(() => {
      obs.B = 2
    }, 1000)
  }, 500)

  await sleep(3000)
  expect(obs.C).toBe(0.5)
  expect(obs.D).toBe(1)
})

我理解初始值 {A: 1} 在初始化时,是和随后进行 obs.A = 1 的调用,效果应该是等价的? 初始值为 {A: 1} 时,1秒延迟之后,设置 obs.A = 1,发现值相等,什么都不执行。 再过1秒延迟以后,开始执行 obs.B = 2 的联动逻辑。

这里为什么会效果不一致? 这个问题能够解释一下,或者给出可能的使用规避手段吗? @janryWang @Landon-CN @hchlq @gwsbhqt