React16源码: React中详解在渲染阶段Suspend的源码实现

Suspend 挂起详解

1 )概述

  • 在react的更新过程当中,它的任务是可以被挂起的,也就是 Suspend
  • 关于 Suspend
    • 字面意思就是挂起
    • 在某次更新的任务更新完成之后,暂时不提交
      • 在 react更新中,分为两个阶段 ,首先是render阶段
        • 主要就是包含 performUnitOfWork 以及 completeUnitOfWork
        • 对拿到的 reactElement 进行一个向下一层一层渲染
        • 这个过程呢叫做 beginWork, 在这个过程中
        • 从一个root节点开始渲染渲染到某一层的最终的一个子节点之后
        • 然后再由这个子节点往上返回,并且渲染它的兄弟节点
        • 最终回到root这个过程,然后把一个 reactElement 形成的树最终渲染成一棵完整的fiber树
        • 在这个过程当中,会根据传入的props以及每个节点的不同的类型来给它创建不同的实例
        • 以及进行一些 diff 的内容, 这个过程为什么要叫 render 阶段呢?
        • 因为它完全不会影响我们目前的整个 dom 树,它不会更新任何 dom 节点的特性
        • 在更新的过程当中,会形成一个effect的链
        • 这些effect的链就代表着在真正commit的阶段是要去把哪些dom节点进行一个更新
      • 在 render 阶段完成了之后,就进入commit阶段
        • 把render阶段能够发现的所有需要更新的节点提交到dom的更新上面,来完成一个UI的更新
        • 在 suspend 当中,完成了 render 阶段的所有任务, 但是暂时把这个任务停在 render 阶段不提交
        • 也就是把最终要修改 dom 的任务给它停掉,就不去做这个事情
        • 那么这个更新过程就叫做被 Suspend 的,也就是被挂起了
    • 这个更新可能在下次更新中再次被执行

2 )更新过程回顾

一开始

    root
     |
     |  current
     ↓
   RootFiber    -----alternate----->   workInProgress
     |                                       |
     |                                       |
     ↓                                       ↓
  childFiber                            childWIP (childFiber 的 workInProgress)
  • 在执行更新的过程当中,一开始有一个root节点,这个root节点它是一个fiber对象
  • 它有一个属性叫 current 指向了 RootFiber
  • 这个 RootFiber 在更新完一次之后,它会有一个childFiber
  • 在要执行一个更新的过程当中,会把 rootFiber 去创建一个叫做 workInProgress 这么一个对象
  • 通过这个fiber对象的 alternate 属性去指向这个 workInProgress
  • 这个 workInProgress 跟 RootFiber 是两个完全不同的对象,只不过它们对象里面的有一些引用的属性是一样的
  • 比如说 memorizedState,还有 memorizedProps 这些常用的属性,然后在整个更新的过程当中
  • 都会通过 workInProgress 去创建它的 childFiber 的 workInProgress
  • 也就是说目前的 root 的 current 指向的这个 RouterFiber 以及它的childFiber
  • 跟在更新过程当中使用的 workInProgress 和 它的 childFiber 的 workInProgress 都是新的对象,不会相互影响

更新完成之后

    root
          \
           \
            \
             \
              \
               \
                \
                 \
                  \
                   \
                    \
                     \ current
                      \
                       \
                        \
                         \
                          \
                           \
                            \
                             \ 
                              \ 
                               \ 
                                \ 
                                 \ 
                                  ↘
   RootFiber  -----alternate----->  workInProgress
     |                                       |
     |                                       |
     ↓                                       ↓
  childFiber                            childWIP (childFiber 的 workInProgress)
  • 在整个更新完成之后,在执行了 commitRoot 之后,会做一个操作,叫做把 root.current 的指向变成 workInProgress
  • 这个时候因为已经把 workInProgress 上收集到的所有更新提交到 dom 上面了
  • 提交到 dom 上面之后,这个 workInProgress 跟 dom 的对应关系才是一一对应的
  • 而之前的rootFiber已经是一个过时的版本了, 如果当前的 root.current 还指向它的话,跟我们实际的dom的对应关系就不对了
  • 所以这时候,root 就直接修改它的 current 指向 workInProgress,就可以变成一个最新的状态
  • 这个 rootFiber 还会依然存在,存在于 workInProgress.alternate 上面
  • 后续要去继续更新的时候,又会从 workInProgress 上创建一个新的 workInProgress
  • 因为旧的 workInProgress 现在已经变成 root.current了,这个操作在哪里做呢?
    • 在commitRoot里面,完成了第二次commit,也就是去 commitAllLifeCycles 修改了所有dom的更新之后
    • 它会执行一句代码,叫做 root.current = finishedWork (packages/react-reconciler/src/ReactFiberScheduler.js#L730)
    • 这个 finishedWork 就是传入 commitRoot 的时候
    • 在render阶段更新完成的那个 workInProgress 对象
    • 需要注意它们两个对象 workInProgress 和 current,虽然是大部分属性都是一样的
    • 它们最大的区别就是两个完全独立的对象
    • 修改了 root.current 的指向之后,就代表 workInProgress 它的更新已经被提交了
    • 然后变成了一个新的状态,就存在 root.current 上面
  • 这就是在 react当中,有一个叫做 double buffer 的一个概念
  • 在更新完成之后,会修改root的指向来复用我们的workInProgress对象

3 )详解 Suspend

  • 这个过程完成之后,看下 Suspend
  • 在之前的 renderRoot, 在 workLoop 执行完成之后,会执行一堆判断

定位到 packages/react-reconciler/src/ReactFiberScheduler.js#L1381

js 复制代码
// Yield back to main thread.
if (didFatal) {
  const didCompleteRoot = false;
  stopWorkLoopTimer(interruptedBy, didCompleteRoot);
  interruptedBy = null;
  // There was a fatal error.
  if (__DEV__) {
    resetStackAfterFatalErrorInDev();
  }
  // `nextRoot` points to the in-progress root. A non-null value indicates
  // that we're in the middle of an async render. Set it to null to indicate
  // there's no more work to be done in the current batch.
  nextRoot = null;
  onFatal(root);
  return;
}

if (nextUnitOfWork !== null) {
  // There's still remaining async work in this tree, but we ran out of time
  // in the current frame. Yield back to the renderer. Unless we're
  // interrupted by a higher priority update, we'll continue later from where
  // we left off.
  const didCompleteRoot = false;
  stopWorkLoopTimer(interruptedBy, didCompleteRoot);
  interruptedBy = null;
  onYield(root);
  return;
}

// We completed the whole tree.
const didCompleteRoot = true;
stopWorkLoopTimer(interruptedBy, didCompleteRoot);
const rootWorkInProgress = root.current.alternate;
invariant(
  rootWorkInProgress !== null,
  'Finished root should have a work-in-progress. This error is likely ' +
    'caused by a bug in React. Please file an issue.',
);

// `nextRoot` points to the in-progress root. A non-null value indicates
// that we're in the middle of an async render. Set it to null to indicate
// there's no more work to be done in the current batch.
nextRoot = null;
interruptedBy = null;

if (nextRenderDidError) {
  // There was an error
  if (hasLowerPriorityWork(root, expirationTime)) {
    // There's lower priority work. If so, it may have the effect of fixing
    // the exception that was just thrown. Exit without committing. This is
    // similar to a suspend, but without a timeout because we're not waiting
    // for a promise to resolve. React will restart at the lower
    // priority level.
    markSuspendedPriorityLevel(root, expirationTime);
    const suspendedExpirationTime = expirationTime;
    const rootExpirationTime = root.expirationTime;
    onSuspend(
      root,
      rootWorkInProgress,
      suspendedExpirationTime,
      rootExpirationTime,
      -1, // Indicates no timeout
    );
    return;
  } else if (
    // There's no lower priority work, but we're rendering asynchronously.
    // Synchronsouly attempt to render the same level one more time. This is
    // similar to a suspend, but without a timeout because we're not waiting
    // for a promise to resolve.
    !root.didError &&
    isYieldy
  ) {
    root.didError = true;
    const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime);
    const rootExpirationTime = (root.expirationTime = Sync);
    onSuspend(
      root,
      rootWorkInProgress,
      suspendedExpirationTime,
      rootExpirationTime,
      -1, // Indicates no timeout
    );
    return;
  }
}

if (isYieldy && nextLatestAbsoluteTimeoutMs !== -1) {
  // The tree was suspended.
  const suspendedExpirationTime = expirationTime;
  markSuspendedPriorityLevel(root, suspendedExpirationTime);

  // Find the earliest uncommitted expiration time in the tree, including
  // work that is suspended. The timeout threshold cannot be longer than
  // the overall expiration.
  const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
    root,
    expirationTime,
  );
  const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime);
  if (earliestExpirationTimeMs < nextLatestAbsoluteTimeoutMs) {
    nextLatestAbsoluteTimeoutMs = earliestExpirationTimeMs;
  }

  // Subtract the current time from the absolute timeout to get the number
  // of milliseconds until the timeout. In other words, convert an absolute
  // timestamp to a relative time. This is the value that is passed
  // to `setTimeout`.
  const currentTimeMs = expirationTimeToMs(requestCurrentTime());
  let msUntilTimeout = nextLatestAbsoluteTimeoutMs - currentTimeMs;
  msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;

  // TODO: Account for the Just Noticeable Difference

  const rootExpirationTime = root.expirationTime;
  onSuspend(
    root,
    rootWorkInProgress,
    suspendedExpirationTime,
    rootExpirationTime,
    msUntilTimeout,
  );
  return;
}

// Ready to commit.
onComplete(root, rootWorkInProgress, expirationTime);
  • 比如说 if (didFatal) {},就是说有致命错误的时候,会执行 onFatal

    js 复制代码
    if (didFatal) {
      const didCompleteRoot = false;
      stopWorkLoopTimer(interruptedBy, didCompleteRoot);
      interruptedBy = null;
      // There was a fatal error.
      if (__DEV__) {
        resetStackAfterFatalErrorInDev();
      }
      // `nextRoot` points to the in-progress root. A non-null value indicates
      // that we're in the middle of an async render. Set it to null to indicate
      // there's no more work to be done in the current batch.
      nextRoot = null;
      onFatal(root);
      return;
    }
    • onFatal 是给 root.finishedWork,给它直接设为null,那么这个不算
  • 还有就是 if (nextUnitOfWork !== null) {}

    js 复制代码
    if (nextUnitOfWork !== null) {
      // There's still remaining async work in this tree, but we ran out of time
      // in the current frame. Yield back to the renderer. Unless we're
      // interrupted by a higher priority update, we'll continue later from where
      // we left off.
      const didCompleteRoot = false;
      stopWorkLoopTimer(interruptedBy, didCompleteRoot);
      interruptedBy = null;
      onYield(root);
      return;
    }
    • 这个情况是说这个更新过程是通过 reactScheduler 它进行时间片的更新的一个过程
    • 而这个root的更新又因为比较的长,一个时间片没更新完, 这个时候跳出更新的时候,它就是 onYield
    • 它下一次等浏览器执行完动画之类的操作之后,有新的一个时间片进来的时候,我们会继续在 nextUnitOfWork 上面进行一个更新
    • 这就是类似于中断,再继续的一个流程,这个跟 suspend 其实也没有什么关系
    • 真正跟 suspend 有关的是下面几个
  • if(nextRenderDidError),会把提交放到第一优先级的任务上

    js 复制代码
    if (nextRenderDidError) {
      // There was an error
      if (hasLowerPriorityWork(root, expirationTime)) {
        // There's lower priority work. If so, it may have the effect of fixing
        // the exception that was just thrown. Exit without committing. This is
        // similar to a suspend, but without a timeout because we're not waiting
        // for a promise to resolve. React will restart at the lower
        // priority level.
        markSuspendedPriorityLevel(root, expirationTime);
        const suspendedExpirationTime = expirationTime;
        const rootExpirationTime = root.expirationTime;
        onSuspend(
          root,
          rootWorkInProgress,
          suspendedExpirationTime,
          rootExpirationTime,
          -1, // Indicates no timeout
        );
        return;
      } else if (
        // There's no lower priority work, but we're rendering asynchronously.
        // Synchronsouly attempt to render the same level one more time. This is
        // similar to a suspend, but without a timeout because we're not waiting
        // for a promise to resolve.
        !root.didError &&
        isYieldy
      ) {
        root.didError = true;
        const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime);
        const rootExpirationTime = (root.expirationTime = Sync);
        onSuspend(
          root,
          rootWorkInProgress,
          suspendedExpirationTime,
          rootExpirationTime,
          -1, // Indicates no timeout
        );
        return;
      }
    }
    • 也就是说, 这边有一个判断 if(hasLowerPriorityWork) {}, 如果它是有一个低优先级的任务,还没有被更新的

    • 这个时候调用了 markSuspendedPriorityLevel,这代表要把当前的这一次更新的内容去给它 suspend 了

    • 然后标记这个更新被 suspend了, 它会调用一个叫做 onSuspend 的方法

      js 复制代码
      // packages/react-reconciler/src/ReactFiberScheduler.js#L1954
      function onSuspend(
        root: FiberRoot,
        finishedWork: Fiber,
        suspendedExpirationTime: ExpirationTime,
        rootExpirationTime: ExpirationTime,
        msUntilTimeout: number,
      ): void {
        root.expirationTime = rootExpirationTime;
        // !shouldYieldToRenderer() 表示任务还没有超时
        // 并且 msUntilTimeout === 0 直接设置了 finishedwork
        // 这个时候最终会直接调用 commitRoot
        if (msUntilTimeout === 0 && !shouldYieldToRenderer()) {
          // Don't wait an additional tick. Commit the tree immediately.
          root.pendingCommitExpirationTime = suspendedExpirationTime;
          root.finishedWork = finishedWork;
        } else if (msUntilTimeout > 0) {
          // Wait `msUntilTimeout` milliseconds before committing.
          // 这个 scheduleTimeout 就是 window.setTimeout 方法
          root.timeoutHandle = scheduleTimeout(
            onTimeout.bind(null, root, finishedWork, suspendedExpirationTime),
            msUntilTimeout,
          );
        }
      }
      • 进入 onTimeout

        js 复制代码
        function onTimeout(root, finishedWork, suspendedExpirationTime) {
          // The root timed out. Commit it.
          root.pendingCommitExpirationTime = suspendedExpirationTime;
          root.finishedWork = finishedWork; // 注意,这里
          // Read the current time before entering the commit phase. We can be
          // certain this won't cause tearing related to batching of event updates
          // because we're at the top of a timer event.
          recomputeCurrentRendererTime();
          currentSchedulerTime = currentRendererTime;
          flushRoot(root, suspendedExpirationTime); // flushRoot 强制调用 commitRoot
        }
        • 进入 flushRoot

          js 复制代码
          function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
            invariant(
              !isRendering,
              'work.commit(): Cannot commit while already rendering. This likely ' +
                'means you attempted to commit from inside a lifecycle method.',
            );
            // Perform work on root as if the given expiration time is the current time.
            // This has the effect of synchronously flushing all work up to and
            // including the given time.
            nextFlushedRoot = root;
            nextFlushedExpirationTime = expirationTime;
            performWorkOnRoot(root, expirationTime, false); // 在这里面,存在 finishedWork 则直接调用 `completeRoot`
            // Flush any sync work that was scheduled by lifecycles
            performSyncWork();
          }
          • 是否要调用completeRoot, 都是通过判断 root.finishedWork 来完成的
          • 只要 root 它有已经完成的更新的工作,那么它就要去调用 commitRoot 来进行一个commit
          • 而通过给 root.finishedWork设置成null,就是告诉后续的代码,没有任务要提交
          • 所以不会执行 completeRoot, 所以不会 commitRoot
      • 看到这个方法,它只是设置了 root.expirationTime = rootExpirationTime

      • 然后接下去, 因为传进来的 msUnitlTimeout 是 -1,所以接下去这两个判断是都不符合的

      • 也就是说它接下去什么任务都没有做,而且它也没有去调用 commitRoot

      • 那么这个更新流程不就是被白费了吗?事实上也确实是如此的, 这边回头看 react 上写的相关注释

        js 复制代码
        // There's lower priority work. If so, it may have the effect of fixing
        // the exception that was just thrown. Exit without committing. This is
        // similar to a suspend, but without a timeout because we're not waiting
        // for a promise to resolve. React will restart at the lower
        // priority level.
      • 如果这边有低优先级的任务,就不提交这次更新,因为没有提交这个更新

      • 而且在 current 上面它的 updateQueen 是没有被删除的

      • 也就是说这些 update 它依然存在, 这些 update 存在的话

      • 在下一次低优先级的任务去执行更新的时候,它依然会执行update

      • 这个时候它有可能可以修复在这一次渲染当中出现的问题

      • 所以只要在之前的渲染当中出现了错误,而且有低优先级的任务在

      • react会直接不提交,而是把这个提交的更新放到低优先级的任务上,再去渲染一次

  • 如果没有低优先级的任务,react 会直接发起一个新的同步更新

  • 就是在 else if 下面, 这边的判断条件是比较苛刻的 else if(!root.didError && isYieldy) {}

    js 复制代码
    else if (
      // There's no lower priority work, but we're rendering asynchronously.
      // Synchronsouly attempt to render the same level one more time. This is
      // similar to a suspend, but without a timeout because we're not waiting
      // for a promise to resolve.
      !root.didError &&
      isYieldy
    ) {
      root.didError = true;
      const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime);
      const rootExpirationTime = (root.expirationTime = Sync);
      onSuspend(
        root,
        rootWorkInProgress,
        suspendedExpirationTime,
        rootExpirationTime,
        -1, // Indicates no timeout
      );
      return;
    }
    • 在没有错误和存在需要被中断和恢复的任务的条件下,可以再去发起一次,
    • 如果符合这个条件,会设置 root.expirationTime = (root.exirationTime = Sync)
    • 下一次更新的过程,会走 Sync 的流程
    • 不会走时间片更新,而会强制进行一个同步的直接更新以及渲染的过程
    • 同时,这边又设置了 root.nextExpirationTimeToWorkOn = expirationTime
    • 这个 expirationTime 就是这一次 renderRoot 的时候,它的 nextExpirationTimeToWorkOn
    • 然后它调用了 onSuspend, 这里注意传入的是 -1,所以不会做任何的事情
    • 为何要设置 root.expirationTime = (root.exirationTime = Sync)
      • 在 performWork 里面,在执行 performWorkOnRoot, 在这个函数里面会有这么一段

        js 复制代码
        renderRoot(root, isYieldy); // 这边调用 renderRoot 返回了
        finishedWork = root.finishedWork;
        // 如果没有 finishedWork 就不会执行 completeRoot
        if (finishedWork !== null) {
          // We've completed the root. Commit it.
          completeRoot(root, finishedWork, expirationTime);
        }
      • 在 performWork 里面,在执行完 performWorkOnRoot 之后, 会有这么一段

        js 复制代码
        performWorkOnRoot(
          nextFlushedRoot,
          nextFlushedExpirationTime,
          currentRendererTime > nextFlushedExpirationTime,
        );
        findHighestPriorityRoot();
      • 执行 findHighestPriorityRoot 会去再次找一次优先级最高的 root

      • 找优先级最高的root这个方法,是根据 expirationTime 的大小来进行一个判断的

      • 在这个过程当中,Sync 的 expirationTime 的优先级是最高的

      • 而在 renderRoot 里面设置了这个 root.exirationTime = Sync

      • 说明这个 root 会立马就被执行一个更新, 因为那边的循环它是没有结束的

      • 所以这边强制发起了一次对这个 nextExpirationTimeToWorkOn

      • 优先级的任务进行一次同步的更新这么一个操作,来强制再次进行更新

      • 通过这样来重新渲染一次,看一下是否在重新渲染的过程当中能够解决前一次渲染当中出现的这个错误

      • 一开始 root.didError 肯定是 false,所以这边是可以符合条件的

      • 这边在发起同步过程中,设置它的 didError 为 true

      • 这样的话,如果这一次同步更新,它依然没有完成任务的话,再后来是进不来这个判断的,它还是会被强制提交的

      • 这两种的任务都是被挂起的, 而且可以发现的是,被挂起的任务是根本不会走 commitRoot 的流程的

      • 也就是说这个 render 更新流程直接被抛弃了,我们的 workInProgress 已经没有用了

      • 因为下一次要重新进行render的时候,我们的 workInProgress 也就是 nextUnitOfWork 是会通过 nextRoot.current 来进行创建的

        js 复制代码
        // packages/react-reconciler/src/ReactFiberScheduler.js#L1230
        nextUnitOfWork = createWorkInProgress(
          nextRoot.current,
          null,
          nextRenderExpirationTime,
        );
      • 这个时候因为直接 onSuspend 了,没有执行commit

      • 这个时候 root.current 指针是没有改变的

      • 所以它还会从老的状态上重新发起一次更新,这就是 onSuspend 任务挂起它的一个意思

  • 对于Suspend 的来说,最大的一个情况,就是在下面这个情况下面, if (!isExpired && nextLatestAbsoluteTimeoutMs !== -1) {}

    js 复制代码
    if (isYieldy && nextLatestAbsoluteTimeoutMs !== -1) {
      // The tree was suspended.
      const suspendedExpirationTime = expirationTime;
      markSuspendedPriorityLevel(root, suspendedExpirationTime);
    
      // Find the earliest uncommitted expiration time in the tree, including
      // work that is suspended. The timeout threshold cannot be longer than
      // the overall expiration.
      const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
        root,
        expirationTime,
      );
      const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime);
      if (earliestExpirationTimeMs < nextLatestAbsoluteTimeoutMs) {
        nextLatestAbsoluteTimeoutMs = earliestExpirationTimeMs;
      }
    
      // Subtract the current time from the absolute timeout to get the number
      // of milliseconds until the timeout. In other words, convert an absolute
      // timestamp to a relative time. This is the value that is passed
      // to `setTimeout`.
      const currentTimeMs = expirationTimeToMs(requestCurrentTime());
      let msUntilTimeout = nextLatestAbsoluteTimeoutMs - currentTimeMs;
      msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;
    
      // TODO: Account for the Just Noticeable Difference
    
      const rootExpirationTime = root.expirationTime;
      onSuspend(
        root,
        rootWorkInProgress,
        suspendedExpirationTime,
        rootExpirationTime,
        msUntilTimeout,
      );
      return;
    }
    • 这边会执行一个 onSuspend,并且会传入一个 msUntilTimeout
    • 就这个 timeoutout,可以看上面这个条件,msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout
  • 如果以上条件都不满足,不再发起新的同步更新,就会直接走到后面准备进入commit阶段

    js 复制代码
    // Ready to commit.
    onComplete(root, rootWorkInProgress, expirationTime);
    • 调用 onComplete, 而 onComplete,就会调用 commitRoot

总结3种 Suspend 的场景

  • 1 )把优先级放到低优先级的任务上
    • 会把当前 render 直接废弃,让低优先级的任务
    • 再次去渲染这些更新来查看他是否可以把错误解决掉
  • 2 )直接发起一个新的同步更新
    • 没有低优先级的任务,会重新发起的是同步更新来强制再次去渲染一次来看是否可以解决这个问题
    • 如果不能解决,那么就代表不能解决这个问题,只能按照错误的方式去把内容渲染出来
  • 3 )设置timeout然后提交
    • 只有在 throw 的是 Promise 的情况
    • 也就是通过 Suspense 这个功能去实现 throw 一个 Promise
    • 然后等到 Promise 解决之后再去渲染新的内容的一个情况
    • 这种情况会设置 timeout,这里先跳过
相关推荐
我要洋人死4 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人16 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人16 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR22 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香24 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q24985969327 分钟前
前端预览word、excel、ppt
前端·word·excel
小华同学ai32 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_91541 分钟前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
小牛itbull3 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress