尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

图解 React 源码:副作用 Hook(useEffect / useLayoutEffect)从创建到销毁的全过程

图解 React 源码:副作用 Hook(useEffect / useLayoutEffect)从创建到销毁的全过程 教程前端【免费下载链接】react-illustration-series图解react源码, 用大量配图的方式, 致力于将react原理表述清楚.项目地址https://gitcode.com/gh_mirrors/re/react-illustration-series点击查看免费下载本篇基于「react-illustration-series图解 React 源码」系列中的「Hook 原理副作用 Hook」章节以react17.0.2的react-reconciler源码为依据从fiber、hook、effect三者的引用关系出发完整剖析useEffect、useLayoutEffect在初次创建mount、更新update、提交渲染commit与组件销毁unmount四个阶段中的底层运作机制。读完本文你将掌握副作用 Hook 依赖fiber.flags与effect.tag实现精准识别、回调按Layout/Passive两类分同步与异步两条链路执行的完整原理并能对照源码逐行验证。本系列文档位于仓库 docs/main 目录本文对应 docs/main/hook-effect.md其前置章节为 Hook 原理概览 与 Hook 原理状态 Hook阅读时建议按序进行。副作用 Hook 在 React 运行时中的位置在阅读本文之前先回顾两个关键背景function组件不实例化只被直接调用因此无法像class组件那样维护独立的局部状态与生命周期必须依靠Hook对象间接实现。在 状态与副作用 一节中已总结状态 Hook如useState维护fiber.memoizedState实现数据持久化副作用 Hook如useEffect则修改fiber.flags并提供副作用回调类似于class组件的生命周期回调。副作用回调真正的执行时机不在fiber树构造阶段而在后续的fiber树渲染commit阶段。commitRoot的三个子函数会对副作用队列进行处理完整流程见 fiber 树渲染。在react内部useEffect就是最标准的副作用 Hook其他如useLayoutEffect以及自定义 Hook 若要实现副作用都必须直接或间接地调用useEffect详见 Hook 原理概览 的「副作用 Hook」小节。创建 HookmountEffect 与 mountLayoutEffect在fiber初次构造阶段useEffect对应源码mountEffectpackages/react-reconciler/src/ReactFiberHooks.old.jsL1232-L1248useLayoutEffect对应源码mountLayoutEffect同文件 L1268-L1273。两者的实现如下function mountEffect( create: () (() void) | void, deps: Arraymixed | void | null, ): void { return mountEffectImpl( UpdateEffect | PassiveEffect, // fiberFlags HookPassive, // hookFlags create, deps, ); } function mountLayoutEffect( create: () (() void) | void, deps: Arraymixed | void | null, ): void { return mountEffectImpl( UpdateEffect, // fiberFlags HookLayout, // hookFlags create, deps, ); }可见mountEffect和mountLayoutEffect内部都直接调用mountEffectImpl只是参数不同useEffect传入UpdateEffect | PassiveEffect与HookPassive而useLayoutEffect只传入UpdateEffect与HookLayout。这正是两者行为差异的源头。mountEffectImpl同文件 L1193-L1203function mountEffectImpl(fiberFlags, hookFlags, create, deps): void { // 1. 创建hook const hook mountWorkInProgressHook(); const nextDeps deps undefined ? null : deps; // 2. 设置workInProgress的副作用标记 currentlyRenderingFiber.flags | fiberFlags; // fiberFlags 被标记到workInProgress // 3. 创建Effect, 挂载到hook.memoizedState上 hook.memoizedState pushEffect( HookHasEffect | hookFlags, // hookFlags用于创建effect create, undefined, nextDeps, ); }mountEffectImpl的逻辑分三步调用mountWorkInProgressHook()创建hook并将其挂载到fiber.memoizedState链表中关于 Hook 链表结构见 Hook 原理概览 的「链表存储」小节。设置workInProgress的副作用标记currentlyRenderingFiber.flags | fiberFlags。这里要特别注意currentlyRenderingFiber这个全局变量——它等同于workInProgress是为了和currentHook区分而改名。在pushEffect中创建effect并挂载到hook.memoizedState上即hook.memoizedState effect。注意与状态 Hook 的区别状态 Hook 中hook.memoizedState state而副作用 Hook 中hook.memoizedState effect。创建 EffectpushEffect 与环形链表pushEffect同文件 L1150-L1176负责创建effect对象并将它添加到以fiber.updateQueue.lastEffect为尾节点的环形链表末尾function pushEffect(tag, create, destroy, deps) { // 1. 创建effect对象 const effect: Effect { tag, create, destroy, deps, next: (null: any), }; // 2. 把effect对象添加到环形链表末尾 let componentUpdateQueue: null | FunctionComponentUpdateQueue (currentlyRenderingFiber.updateQueue: any); if (componentUpdateQueue null) { // 新建 workInProgress.updateQueue 用于挂载effect对象 componentUpdateQueue createFunctionComponentUpdateQueue(); currentlyRenderingFiber.updateQueue (componentUpdateQueue: any); // updateQueue.lastEffect是一个环形链表 componentUpdateQueue.lastEffect effect.next effect; } else { const lastEffect componentUpdateQueue.lastEffect; if (lastEffect null) { componentUpdateQueue.lastEffect effect.next effect; } else { const firstEffect lastEffect.next; lastEffect.next effect; effect.next firstEffect; componentUpdateQueue.lastEffect effect; } } // 3. 返回effect return effect; }pushEffect逻辑同样清晰创建effect对象把effect对象添加到fiber.updateQueue.lastEffect环形链表末尾返回effect。effect的数据结构如下packages/react-reconciler/src/ReactFiberHooks.old.js中的类型定义export type Effect {| tag: HookFlags, create: () (() void) | void, destroy: (() void) | void, deps: Arraymixed | null, next: Effect, |};各字段含义effect.tag使用位掩码形式代表effect的类型。位定义在packages/react-reconciler/src/ReactHookEffectTags.jsL12-L19export const NoFlags /* */ 0b000; export const HasEffect /* */ 0b001; // 有副作用, 可以被触发 export const Layout /* */ 0b010; // Layout, dom突变后同步触发 export const Passive /* */ 0b100; // Passive, dom突变前异步触发effect.create实际上就是通过useEffect()/useLayoutEffect()传入的那个函数effect.destroycreate的返回值即清理函数首次创建时为undefinedeffect.deps依赖项数组如果依赖项变动会创建新的effect。renderWithHooks执行完成后fiber、hook、effect三者的引用关系如下图所示——fiber.memoizedState指向 Hook 链表每个副作用 Hook 的memoizedState指向自身对应的effect多个effect通过next指针构成环形链表此时workInProgress.flags已经被打上了标记最后会在fiber树渲染阶段的commitRoot函数中处理这期间的所有过程可回顾 fiber 树构造初次创建 与 fiber 树渲染。useEffect 与 useLayoutEffect 的差异站在fiber、hook、effect的视角无需关心这个hook是通过useEffect还是useLayoutEffect创建的只需要关心内部fiber.flags、effect.tag的状态。两者区别总结如下1.fiber.flags不同使用useEffect时fiber.flags UpdateEffect | PassiveEffect。使用useLayoutEffect时fiber.flags UpdateEffect。2.effect.tag不同使用useEffect时effect.tag HookHasEffect | HookPassive。使用useLayoutEffect时effect.tag HookHasEffect | HookLayout。Passive标记只会在使用了 Hook 的function类型节点上存在而Update标记是useEffect、useLayoutEffect都会设置的这也决定了它们都会进入 commit 阶段的Update分支处理。处理 Effect 回调commit 三阶段完成fiber树构造后逻辑进入渲染阶段。根据 fiber 树渲染 的介绍在commitRootImpl函数中整个渲染过程由 3 个函数分布实现均在packages/react-reconciler/src/ReactFiberWorkLoop.old.jscommitBeforeMutationEffectsL2256-L2300dom 变更之前commitMutationEffectsL2302-L2383dom 变更界面得到更新commitLayoutEffectsL2385-L2432dom 变更后。这 3 个函数会处理fiber.flags也会根据情况处理fiber.updateQueue.lastEffect。第一阶段commitBeforeMutationEffects —— 注册异步被动任务第一阶段发生在 dom 变更之前处理副作用队列中带有Passive标记的fiber节点function commitBeforeMutationEffects() { while (nextEffect ! null) { // ...省略无关代码, 只保留Hook相关 // 处理Passive标记 const flags nextEffect.flags; if ((flags Passive) ! NoFlags) { if (!rootDoesHavePassiveEffects) { rootDoesHavePassiveEffects true; scheduleCallback(NormalSchedulerPriority, () { flushPassiveEffects(); return null; }); } } nextEffect nextEffect.nextEffect; } }注意由于flushPassiveEffects被包裹在scheduleCallback回调中由调度中心来处理且参数是NormalSchedulerPriority因此这是一个异步回调具体原理可回顾 React 调度原理scheduler。调度中心通过MessageChannel触发回调属于浏览器事件循环中的宏任务所以flushPassiveEffects不会立即执行。这里先跳过flushPassiveEffects的分析继续跟进commitRoot。可以借助一个输出顺序小测试验证useEffect回调的异步性输出顺序为 1, 3, 4, 2function Test() { console.log(1); useEffect(() { console.log(2); }); console.log(3); Promise.resolve(() { console.log(4); }); return divtest/div; }第二阶段commitMutationEffects —— 同步执行 useLayoutEffect 的 destroy第二阶段是 dom 变更界面得到更新。对于副作用 Hook 而言这一阶段的核心是在突变阶段调用销毁函数保证所有effect.destroy都会在effect.create之前执行function commitMutationEffects( root: FiberRoot, renderPriorityLevel: ReactPriorityLevel, ) { // ...省略无关代码, 只保留Hook相关 while (nextEffect ! null) { const flags nextEffect.flags; const primaryFlags flags (Placement | Update | Deletion | Hydrating); switch (primaryFlags) { case Update: { // useEffect,useLayoutEffect都会设置Update标记 // 更新节点 const current nextEffect.alternate; commitWork(current, nextEffect); break; } } nextEffect nextEffect.nextEffect; } } function commitWork(current: Fiber | null, finishedWork: Fiber): void { // ...省略无关代码, 只保留Hook相关 switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case MemoComponent: case SimpleMemoComponent: case Block: { // 在突变阶段调用销毁函数, 保证所有的effect.destroy函数都会在effect.create之前执行 commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork); return; } } } // 依次执行: effect.destroy function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) { const updateQueue: FunctionComponentUpdateQueue | null (finishedWork.updateQueue: any); const lastEffect updateQueue ! null ? updateQueue.lastEffect : null; if (lastEffect ! null) { const firstEffect lastEffect.next; let effect firstEffect; do { if ((effect.tag tag) tag) { // 根据传入的tag过滤 effect链表. const destroy effect.destroy; effect.destroy undefined; if (destroy ! undefined) { destroy(); } } effect effect.next; } while (effect ! firstEffect); } }调用关系为commitMutationEffects - commitWork - commitHookEffectListUnmount。关键点调用commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork)时参数是HookLayout | HookHasEffectHookLayout | HookHasEffect正是通过useLayoutEffect创建的effect的 tag。所以commitHookEffectListUnmount在这个阶段只能处理由useLayoutEffect()创建的effect同步调用effect.destroy()并把effect.destroy置为undefined防止重复销毁。也就是说useLayoutEffect的清理函数会在 DOM 变更突变阶段同步执行而useEffect的清理函数此时还不会执行。第三阶段commitLayoutEffects —— 同步执行 useLayoutEffect 的 create第三阶段是 dom 变更后。核心逻辑在commitLayoutEffects - commitLayoutEffectOnFibercommitLifeCycles- commitHookEffectListMount这条调用链上function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) { // ...省略无关代码, 只保留Hook相关 while (nextEffect ! null) { const flags nextEffect.flags; if (flags (Update | Callback)) { // useEffect,useLayoutEffect都会设置Update标记 const current nextEffect.alternate; commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes); } nextEffect nextEffect.nextEffect; } } function commitLifeCycles( finishedRoot: FiberRoot, current: Fiber | null, finishedWork: Fiber, committedLanes: Lanes, ): void { // ...省略无关代码, 只保留Hook相关 switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case SimpleMemoComponent: case Block: { // 在此之前commitMutationEffects函数中, effect.destroy已经被调用, 所以effect.destroy永远不会影响到effect.create commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork); schedulePassiveEffects(finishedWork); return; } } } function commitHookEffectListMount(tag: number, finishedWork: Fiber) { const updateQueue: FunctionComponentUpdateQueue | null (finishedWork.updateQueue: any); const lastEffect updateQueue ! null ? updateQueue.lastEffect : null; if (lastEffect ! null) { const firstEffect lastEffect.next; let effect firstEffect; do { if ((effect.tag tag) tag) { const create effect.create; effect.destroy create(); } effect effect.next; } while (effect ! firstEffect); } }本阶段有两个职责1. 同步执行useLayoutEffect的create调用commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork)时参数是HookLayout | HookHasEffect所以只处理由useLayoutEffect()创建的effect调用effect.create()之后将返回值赋值到effect.destroy。至此useLayoutEffect的destroy与create都已在 commit 阶段同步完成闭环。2. 为flushPassiveEffects做准备commitLifeCycles中的schedulePassiveEffects(finishedWork)其形参finishedWork实际上指代当前正在被遍历的有副作用的 fiberschedulePassiveEffects的逻辑很简单把带有Passive标记的effect即由useEffect创建筛选出来添加到两个全局数组pendingPassiveHookEffectsUnmount和pendingPassiveHookEffectsMount中function schedulePassiveEffects(finishedWork: Fiber) { // 1. 获取 fiber.updateQueue const updateQueue: FunctionComponentUpdateQueue | null (finishedWork.updateQueue: any); // 2. 获取 effect环形队列 const lastEffect updateQueue ! null ? updateQueue.lastEffect : null; if (lastEffect ! null) { const firstEffect lastEffect.next; let effect firstEffect; do { const { next, tag } effect; // 3. 筛选出由useEffect()创建的effect if ( (tag HookPassive) ! NoHookEffect (tag HookHasEffect) ! NoHookEffect ) { // 把effect添加到全局数组, 等待flushPassiveEffects处理 enqueuePendingPassiveHookEffectUnmount(finishedWork, effect); enqueuePendingPassiveHookEffectMount(finishedWork, effect); } effect next; } while (effect ! firstEffect); } } export function enqueuePendingPassiveHookEffectUnmount( fiber: Fiber, effect: HookEffect, ): void { // unmount effects 数组 pendingPassiveHookEffectsUnmount.push(effect, fiber); } export function enqueuePendingPassiveHookEffectMount( fiber: Fiber, effect: HookEffect, ): void { // mount effects 数组 pendingPassiveHookEffectsMount.push(effect, fiber); }综上经过commitMutationEffects和commitLayoutEffects两个函数带有Layout标记的effect由useLayoutEffect创建已经得到了完整的回调处理destroy和create均已被同步调用。下图示意了commitLayoutEffects阶段useLayoutEffect的执行——其中第一个effect拥有Layout标记会执行effect.destroy(); effect.destroy effect.create()而带Passive标记的effect的destroy尚未被处理flushPassiveEffects异步执行 useEffect 的 destroy 与 create在上文commitBeforeMutationEffects阶段异步调用了flushPassiveEffects。在这期间带有Passive标记的effect已经被添加到pendingPassiveHookEffectsUnmount和pendingPassiveHookEffectsMount全局数组中。接下来flushPassiveEffects就可以脱离fiber节点直接访问effectsexport function flushPassiveEffects(): boolean { // Returns whether passive effects were flushed. if (pendingPassiveEffectsRenderPriority ! NoSchedulerPriority) { const priorityLevel pendingPassiveEffectsRenderPriority NormalSchedulerPriority ? NormalSchedulerPriority : pendingPassiveEffectsRenderPriority; pendingPassiveEffectsRenderPriority NoSchedulerPriority; // runWithPriority设置Schedule中的调度优先级, 如果在flushPassiveEffectsImpl中处理effect时又发起了新的更新, 那么新的update.lane将会受到这个priorityLevel影响. return runWithPriority(priorityLevel, flushPassiveEffectsImpl); } return false; } // ...省略无关代码, 只保留Hook相关 function flushPassiveEffectsImpl() { if (rootWithPendingPassiveEffects null) { return false; } rootWithPendingPassiveEffects null; pendingPassiveEffectsLanes NoLanes; // 1. 执行 effect.destroy() const unmountEffects pendingPassiveHookEffectsUnmount; pendingPassiveHookEffectsUnmount []; for (let i 0; i unmountEffects.length; i 2) { const effect ((unmountEffects[i]: any): HookEffect); const fiber ((unmountEffects[i 1]: any): Fiber); const destroy effect.destroy; effect.destroy undefined; if (typeof destroy function) { destroy(); } } // 2. 执行新 effect.create(), 重新赋值到 effect.destroy const mountEffects pendingPassiveHookEffectsMount; pendingPassiveHookEffectsMount []; for (let i 0; i mountEffects.length; i 2) { const effect ((mountEffects[i]: any): HookEffect); const fiber ((mountEffects[i 1]: any): Fiber); effect.destroy create(); } }其核心逻辑遍历pendingPassiveHookEffectsUnmount中的所有effect调用effect.destroy()同时清空pendingPassiveHookEffectsUnmount遍历pendingPassiveHookEffectsMount中的所有effect调用effect.create()并更新effect.destroy同时清空pendingPassiveHookEffectsMount。所以带有Passive标记的effect在flushPassiveEffects函数中得到了完整的回调处理。下图示意了flushPassiveEffects阶段useEffect的执行——所有拥有Passive标记的effect都会执行effect.destroy(); effect.destroy effect.create()这里有几个细节值得注意之所以把useEffect的回调拆成destroy和create两个数组先后执行是为了保证同一轮渲染中所有旧effect.destroy都先执行完再统一执行新effect.create与useLayoutEffect的处理顺序保持一致destroy 永远先于 create。flushPassiveEffects中通过runWithPriority设置调度优先级如果在处理 effect 时又发起了新的更新那么新的update.lane会受到这个priorityLevel影响关于优先级体系见 React 中的优先级管理。因为useEffect回调经过了调度中心scheduleCallback(NormalSchedulerPriority, ...)所以它是异步执行的而useLayoutEffect与class组件的componentDidMount、componentDidUpdate一样在commitRoot - commitLayoutEffects中同步调用详见 状态与副作用 的「细节与误区」小节。这也意味着完全用useEffect替代componentDidMount/componentDidUpdate并不准确useLayoutEffect在调用时机上更贴合这两个生命周期。更新 Hook依赖比对与 effect 重建假设在初次调用之后发起更新会再次执行function这时function中使用的useEffect、useLayoutEffect等 API 也会再次执行。在更新过程中useEffect对应源码updateEffectReactFiberHooks.old.jsL1250-L1266useLayoutEffect对应源码updateLayoutEffect同文件 L1275-L1280。它们内部都会调用updateEffectImpl与初次创建时一样只是参数不同。updateEffectImpl依赖不变则跳过updateEffectImpl同文件 L1205-L1230function updateEffectImpl(fiberFlags, hookFlags, create, deps): void { // 1. 获取当前hook const hook updateWorkInProgressHook(); const nextDeps deps undefined ? null : deps; let destroy undefined; // 2. 分析依赖 if (currentHook ! null) { const prevEffect currentHook.memoizedState; // 继续使用先前effect.destroy destroy prevEffect.destroy; if (nextDeps ! null) { const prevDeps prevEffect.deps; // 比较依赖是否变化 if (areHookInputsEqual(nextDeps, prevDeps)) { // 2.1 如果依赖不变, 新建effect(tag不含HookHasEffect) pushEffect(hookFlags, create, destroy, nextDeps); return; } } } // 2.2 如果依赖改变, 更改fiber.flag, 新建effect currentlyRenderingFiber.flags | fiberFlags; hook.memoizedState pushEffect( HookHasEffect | hookFlags, create, destroy, nextDeps, ); }updateEffectImpl与mountEffectImpl逻辑有所不同如果useEffect/useLayoutEffect的依赖不变新建的effect对象不带HookHasEffect标记——它在 commit 阶段不会被识别为有副作用的 effectdestroy和create都不会被调用实现跳过执行如果依赖改变则设置currentlyRenderingFiber.flags | fiberFlags并新建带HookHasEffect标记的effectcommit 阶段会对其执行回调无论依赖是否变化都复用之前的effect.destroy等待commitRoot阶段的调用上文已说明。下图展示了更新阶段renderWithHooks完成后的结构图中第 1、2 个hook的deps没变故effect.tag中不包含HookHasEffect第 3 个hook的deps改变故effect.tag中继续含有HookHasEffect关于依赖比对的实现细节areHookInputsEqual使用Object.is逐个比较新旧依赖项在ReactFiberHooks.old.js中实现。因此依赖数组中的每一项都必须是稳定引用如useMemo/useCallback的返回值直接内联对象、数组或函数字面量会导致每次渲染都判定依赖改变从而每次都触发 effect 执行。处理 Effect 回调新的hook以及新的effect创建完成之后余下逻辑与初次渲染完全一致。处理 Effect 回调时同样根据effect.tag进行判断只有effect.tag包含HookHasEffect时才会调用effect.destroy和effect.create()。组件销毁Deletion 标记下的清理当function组件被销毁时fiber节点必然会被打上Deletion标记即fiber.flags | Deletion。带有Deletion标记的fiber在commitMutationEffects中被处理// ...省略无关代码 function commitMutationEffects( root: FiberRoot, renderPriorityLevel: ReactPriorityLevel, ) { while (nextEffect ! null) { const primaryFlags flags (Placement | Update | Deletion | Hydrating); switch (primaryFlags) { case Deletion: { commitDeletion(root, nextEffect, renderPriorityLevel); break; } } } }在commitDeletion函数之后继续调用unmountHostComponents - commitUnmount在commitUnmountpackages/react-reconciler/src/ReactFiberCommitWork.old.jsL866-L963中会遍历该fiber的updateQueue.lastEffect环形链表对每个带HookHasEffect的effect执行effect.destroy()完成整个副作用 Hook 生命周期的最后闭环——组件卸载时清理函数如取消订阅、清除定时器等在这里被调用。总结本节分析了副作用 Hook 从创建到销毁的全部过程核心结论可以归纳为双标记驱动在react内部依靠fiber.flagsUpdateEffect | PassiveEffect和effect.tagHookHasEffect | HookLayout | HookPassive实现对effect的精准识别fiber与effect两个维度的标记共同决定回调在 commit 哪个阶段执行。两条执行链路在commitRoot阶段对不同类型的effect进行处理先后调用effect.destroy()和effect.create()useLayoutEffectHookLayout在commitMutationEffects中同步调用destroy在commitLayoutEffects中同步调用createuseEffectHookPassive的destroy/create由flushPassiveEffects异步执行经由调度中心scheduleCallback(NormalSchedulerPriority, ...)。依赖驱动更新更新阶段通过areHookInputsEqual比对deps依赖不变时新建的effect不带HookHasEffect标记从而跳过回调依赖变化时重建带标记的effect。销毁兜底组件销毁时通过Deletion标记进入commitDeletion - commitUnmount对每个有HookHasEffect标记的effect调用destroy保证清理函数一定执行。作为延伸阅读可以继续学习 Hook 原理状态 Hook对比状态 Hook 的update合并机制、fiber 树渲染commit 三阶段的完整职责以及 React 调度原理scheduler异步回调与时间切片机制。本系列完整目录见仓库 readme.md。赞分享教程前端【免费下载链接】react-illustration-series图解react源码, 用大量配图的方式, 致力于将react原理表述清楚.项目地址https://gitcode.com/gh_mirrors/re/react-illustration-series点击查看免费下载相关推荐HubProxy 缓存机制深度剖析Token 缓存与 Manifest 缓存的性能优化HubProxy 缓存机制深度剖析Token 缓存与 Manifest 缓存的性能优化 HubProxy 作为一款多功能加速服务支持 Docker 镜像加速后端网络API网关Chameleon生命周期全解析组件从创建到销毁的全过程Chameleon生命周期全解析组件从创建到销毁的全过程 你是否曾在开发多端应用时困惑于组件为何时而加载异常时而数据错乱作为一套代码运行多端的跨端解决方跨平台前端移动开发小程序构建工具如何在 awesome-public-datasets 里找到、验证并贡献一个公开数据集如何在 awesome public datasets 里找到、验证并贡献一个公开数据集 awesome public datasets 是一个按主题分类的高质文档知识库数据集上一篇DarkPoseDARK分布感知坐标表示在 MMPose 中的原理、源码实现与训练配置实战下一篇如何在PPT演示中实现时间掌控自如PPTTimer智能计时器全攻略创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表