• 【React 源码】(八)fiber 树构造(基础准备)


    fiber 树构造(基础准备)

    在 React 运行时中, fiber树构造位于react-reconciler包.

    在正式解读fiber树构造之前, 再次回顾一下reconciler 运作流程的 4 个阶段:

    1. 输入阶段: 衔接react-dom包, 承接fiber更新请求(可以参考React 应用的启动过程).
    2. 注册调度任务: 与调度中心(scheduler包)交互, 注册调度任务task, 等待任务回调(可以参考React 调度原理(scheduler)).
    3. 执行任务回调: 在内存中构造出fiber树DOM对象, 也是fiber 树构造的重点内容.
    4. 输出: 与渲染器(react-dom)交互, 渲染DOM节点.

    fiber树构造处于上述第 3 个阶段, 可以通过不同的视角来理解fiber树构造React运行时中所处的位置:

    • scheduler调度中心的角度来看, 它是任务队列taskQueue中的一个具体的任务回调(task.callback).
    • React 工作循环的角度来看, 它属于fiber树构造循环.

    由于fiber 树构造源码量比较大, 本系列根据React运行的内存状态, 分为 2 种情况来说明:

    1. 初次创建: 在React应用首次启动时, 界面还没有渲染, 此时并不会进入对比过程, 相当于直接构造一棵全新的树.
    2. 对比更新: React应用启动后, 界面已经渲染. 如果再次发生更新, 创建新fiber之前需要和旧fiber进行对比. 最后构造的 fiber 树有可能是全新的, 也可能是部分更新的.

    无论是初次创建还是对比更新, 基础概念都是通用的, 本节将介绍这些基础知识, 为正式进入fiber树构造做准备.

    ReactElement, Fiber, DOM 三者的关系

    React 应用中的高频对象一文中, 已经介绍了ReactElementFiber对象的数据结构. 这里我们梳理出ReactElement, Fiber, DOM这 3 种对象的关系

    1. ReactElement 对象(type 定义在shared 包中)

      • 所有采用jsx语法书写的节点, 都会被编译器转换, 最终会以React.createElement(...)的方式, 创建出来一个与之对应的ReactElement对象
    2. fiber 对象(type 类型的定义在ReactInternalTypes.js中)

      • fiber对象是通过ReactElement对象进行创建的, 多个fiber对象构成了一棵fiber树fiber树是构造DOM树的数据模型, fiber树的任何改动, 最后都体现到DOM树.
    3. DOM 对象: 文档对象模型

      • DOM将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合, 也就是常说的DOM树.
      • JavaScript可以访问和操作存储在 DOM 中的内容, 也就是操作DOM对象, 进而触发 UI 渲染.

    它们之间的关系反映了我们书写的 JSX 代码到 DOM 节点的转换过程:

    注意:

    • 开发人员能够控制的是JSX, 也就是ReactElement对象.
    • fiber树是通过ReactElement生成的, 如果脱离了ReactElement,fiber树也无从谈起. 所以是ReactElement树(不是严格的树结构, 为了方便也称为树)驱动fiber树.
    • fiber树DOM树的数据模型, fiber树驱动DOM树

    开发人员通过编程只能控制ReactElement树的结构, ReactElement树驱动fiber树fiber树再驱动DOM树, 最后展现到页面上. 所以fiber树的构造过程, 实际上就是ReactElement对象到fiber对象的转换过程.

    全局变量

    React 工作循环的角度来看, 整个构造过程被包裹在fiber树构造循环中(对应源码位于ReactFiberWorkLoop.js).

    React运行时, ReactFiberWorkLoop.js闭包中的全局变量会随着fiber树构造循环的进行而变化, 现在查看其中重要的全局变量(源码链接):

    1. // 当前React的执行栈(执行上下文)
    2. let executionContext: ExecutionContext = NoContext;
    3. // 当前root节点
    4. let workInProgressRoot: FiberRoot | null = null;
    5. // 正在处理中的fiber节点
    6. let workInProgress: Fiber | null = null;
    7. // 正在渲染的车道(复数)
    8. let workInProgressRootRenderLanes: Lanes = NoLanes;
    9. // 包含所有子节点的优先级, 是workInProgressRootRenderLanes的超集
    10. // 大多数情况下: 在工作循环整体层面会使用workInProgressRootRenderLanes, 在begin/complete阶段层面会使用 subtreeRenderLanes
    11. let subtreeRenderLanes: Lanes = NoLanes;
    12. // 一个栈结构: 专门存储当前节点的 subtreeRenderLanes
    13. const subtreeRenderLanesCursor: StackCursor<Lanes> = createCursor(NoLanes);
    14. // fiber构造完后, root节点的状态: completed, errored, suspended等
    15. let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
    16. // 重大错误
    17. let workInProgressRootFatalError: mixed = null;
    18. // 整个render期间所使用到的所有lanes
    19. let workInProgressRootIncludedLanes: Lanes = NoLanes;
    20. // 在render期间被跳过(由于优先级不够)的lanes: 只包括未处理的updates, 不包括被复用的fiber节点
    21. let workInProgressRootSkippedLanes: Lanes = NoLanes;
    22. // 在render期间被修改过的lanes
    23. let workInProgressRootUpdatedLanes: Lanes = NoLanes;
    24. // 防止无限循环和嵌套更新
    25. const NESTED_UPDATE_LIMIT = 50;
    26. let nestedUpdateCount: number = 0;
    27. let rootWithNestedUpdates: FiberRoot | null = null;
    28. const NESTED_PASSIVE_UPDATE_LIMIT = 50;
    29. let nestedPassiveUpdateCount: number = 0;
    30. // 发起更新的时间
    31. let currentEventTime: number = NoTimestamp;
    32. let currentEventWipLanes: Lanes = NoLanes;
    33. let currentEventPendingLanes: Lanes = NoLanes;

    在源码中, 大部分变量都带有英文注释(读者可自行查阅), 此处只列举了fiber树构造循环中最核心的变量

    执行上下文

    在全局变量中有executionContext, 代表渲染期间执行栈(或叫做执行上下文), 它也是一个二进制表示的变量, 通过位运算进行操作(参考React 算法之位运算). 在源码中一共定义了 8 种执行栈:

    1. type ExecutionContext = number;
    2. export const NoContext = /* */ 0b0000000;
    3. const BatchedContext = /* */ 0b0000001;
    4. const EventContext = /* */ 0b0000010;
    5. const DiscreteEventContext = /* */ 0b0000100;
    6. const LegacyUnbatchedContext = /* */ 0b0001000;
    7. const RenderContext = /* */ 0b0010000;
    8. const CommitContext = /* */ 0b0100000;

    上文回顾了reconciler 运作流程的 4 个阶段, 这 4 个阶段只是一个整体划分. 如果具体到每一次更新, 是有差异的. 比如说: Legacy模式下的首次更新, 不会经过调度中心(第 2 阶段),而是直接进入fiber树构造(第 3 阶段).

    事实上正是executionContext在操控reconciler 运作流程(源码体现在scheduleUpdateOnFiber 函数).

    1. export function scheduleUpdateOnFiber(
    2. fiber: Fiber,
    3. lane: Lane,
    4. eventTime: number,
    5. ) {
    6. if (lane === SyncLane) {
    7. // legacy或blocking模式
    8. if (
    9. (executionContext & LegacyUnbatchedContext) !== NoContext &&
    10. (executionContext & (RenderContext | CommitContext)) === NoContext
    11. ) {
    12. performSyncWorkOnRoot(root);
    13. } else {
    14. // 后续的更新
    15. // 进入第2阶段, 注册调度任务
    16. ensureRootIsScheduled(root, eventTime);
    17. if (executionContext === NoContext) {
    18. // 如果执行上下文为空, 会取消调度任务, 手动执行回调
    19. // 进入第3阶段, 进行fiber树构造
    20. flushSyncCallbackQueue();
    21. }
    22. }
    23. } else {
    24. // concurrent模式
    25. // 无论是否初次更新, 都正常进入第2阶段, 注册调度任务
    26. ensureRootIsScheduled(root, eventTime);
    27. }
    28. }

    在 render 过程中, 每一个阶段都会改变executionContext(render 之前, 会设置executionContext |= RenderContext; commit 之前, 会设置executionContext |= CommitContext), 假设在render过程中再次发起更新(如在UNSAFE_componentWillReceiveProps生命周期中调用setState)则可通过executionContext来判断当前的render状态.

    双缓冲技术(double buffering)

    在全局变量中有workInProgress, 还有不少以workInProgress来命名的变量. workInProgress的应用实际上就是React的双缓冲技术(double buffering).

    在上文我们梳理了ReactElement, Fiber, DOM三者的关系fiber树的构造过程, 就是把ReactElement转换成fiber树的过程. 在这个过程中, 内存里会同时存在 2 棵fiber树:

    • 其一: 代表当前界面的fiber树(已经被展示出来, 挂载到fiberRoot.current上). 如果是初次构造(初始化渲染), 页面还没有渲染, 此时界面对应的 fiber 树为空(fiberRoot.current = null).
    • 其二: 正在构造的fiber树(即将展示出来, 挂载到HostRootFiber.alternate上, 正在构造的节点称为workInProgress). 当构造完成之后, 重新渲染页面, 最后切换fiberRoot.current = workInProgress, 使得fiberRoot.current重新指向代表当前界面的fiber树.

    此处涉及到 2 个全局对象fiberRootHostRootFiber, 在React 应用的启动过程中有详细的说明.

    用图来表述double buffering的概念如下:

    1. 构造过程中, fiberRoot.current指向当前界面对应的fiber树.

    1. 构造完成并渲染, 切换fiberRoot.current指针, 使其继续指向当前界面对应的fiber树(原来代表界面的 fiber 树, 变成了内存中).

    优先级 {#lanes}

    在全局变量中有不少变量都以 Lanes 命名(如workInProgressRootRenderLanes,subtreeRenderLanes其作用见上文注释), 它们都与优先级相关.

    在前文React 中的优先级管理中, 我们介绍了React中有 3 套优先级体系, 并了解了它们之间的关联. 现在fiber树构造过程中, 将要深入分析车道模型Lane的具体应用.

    在整个react-reconciler包中, Lane的应用可以分为 3 个方面:

    update优先级(update.lane) {#update-lane}

    React 应用中的高频对象一文中, 介绍过update对象, 它是一个环形链表. 对于单个update对象来讲, update.lane代表它的优先级, 称之为update优先级.

    观察其构造函数(源码链接),其优先级是由外界传入.

     
    
    1. export function createUpdate(eventTime: number, lane: Lane): Update<*> {
    2. const update: Update<*> = {
    3. eventTime,
    4. lane,
    5. tag: UpdateState,
    6. payload: null,
    7. callback: null,
    8. next: null,
    9. };
    10. return update;
    11. }
    12. React体系中, 有 2 种情况会创建update对象:
    13. 应用初始化: 在react-reconciler包中的updateContainer函数中(源码)
    14. 发起组件更新: 假设在 class 组件中调用setState(源码)
    15. const classComponentUpdater = {
    16. isMounted,
    17. enqueueSetState(inst, payload, callback) {
    18. const fiber = getInstance(inst);
    19. const eventTime = requestEventTime(); // 根据当前时间, 创建一个update优先级
    20. const lane = requestUpdateLane(fiber); // lane被用于创建update对象
    21. const update = createUpdate(eventTime, lane);
    22. update.payload = payload;
    23. enqueueUpdate(fiber, update);
    24. scheduleUpdateOnFiber(fiber, lane, eventTime);
    25. },
    26. };

    可以看到, 无论是应用初始化或者发起组件更新, 创建update.lane的逻辑都是一样的, 都是根据当前时间, 创建一个 update 优先级.

    requestUpdateLane:

     
    
    1. export function requestUpdateLane(fiber: Fiber): Lane {
    2. // Special cases
    3. const mode = fiber.mode;
    4. if ((mode & BlockingMode) === NoMode) {
    5. // legacy 模式
    6. return (SyncLane: Lane);
    7. } else if ((mode & ConcurrentMode) === NoMode) {
    8. // blocking模式
    9. return getCurrentPriorityLevel() === ImmediateSchedulerPriority
    10. ? (SyncLane: Lane)
    11. : (SyncBatchedLane: Lane);
    12. }
    13. // concurrent模式
    14. if (currentEventWipLanes === NoLanes) {
    15. currentEventWipLanes = workInProgressRootIncludedLanes;
    16. }
    17. const isTransition = requestCurrentTransition() !== NoTransition;
    18. if (isTransition) {
    19. // 特殊情况, 处于suspense过程中
    20. if (currentEventPendingLanes !== NoLanes) {
    21. currentEventPendingLanes =
    22. mostRecentlyUpdatedRoot !== null
    23. ? mostRecentlyUpdatedRoot.pendingLanes
    24. : NoLanes;
    25. }
    26. return findTransitionLane(currentEventWipLanes, currentEventPendingLanes);
    27. }
    28. // 正常情况, 获取调度优先级
    29. const schedulerPriority = getCurrentPriorityLevel();
    30. let lane;
    31. if (
    32. (executionContext & DiscreteEventContext) !== NoContext &&
    33. schedulerPriority === UserBlockingSchedulerPriority
    34. ) {
    35. // executionContext 存在输入事件. 且调度优先级是用户阻塞性质
    36. lane = findUpdateLane(InputDiscreteLanePriority, currentEventWipLanes);
    37. } else {
    38. // 调度优先级转换为车道模型
    39. const schedulerLanePriority = schedulerPriorityToLanePriority(
    40. schedulerPriority,
    41. );
    42. lane = findUpdateLane(schedulerLanePriority, currentEventWipLanes);
    43. }
    44. return lane;
    45. }

    可以看到requestUpdateLane的作用是返回一个合适的 update 优先级.

    1. legacy 模式: 返回SyncLane
    2. blocking 模式: 返回SyncLane
    3. concurrent 模式:
      • 正常情况下, 根据当前的调度优先级来生成一个lane.
      • 特殊情况下(处于 suspense 过程中), 会优先选择TransitionLanes通道中的空闲通道(如果所有TransitionLanes通道都被占用, 就取最高优先级. 源码).

    最后通过scheduleUpdateOnFiber(current, lane, eventTime);函数, 把update.lane正式带入到了输入阶段.

    scheduleUpdateOnFiber输入阶段的必经函数, 在本系列的文章中已经多次提到, 此处以update.lane的视角分析:

     
    
    1. export function scheduleUpdateOnFiber(
    2. fiber: Fiber,
    3. lane: Lane,
    4. eventTime: number,
    5. ) {
    6. if (lane === SyncLane) {
    7. // legacy或blocking模式
    8. if (
    9. (executionContext & LegacyUnbatchedContext) !== NoContext &&
    10. (executionContext & (RenderContext | CommitContext)) === NoContext
    11. ) {
    12. performSyncWorkOnRoot(root);
    13. } else {
    14. ensureRootIsScheduled(root, eventTime); // 注册回调任务
    15. if (executionContext === NoContext) {
    16. flushSyncCallbackQueue(); // 取消schedule调度 ,主动刷新回调队列,
    17. }
    18. }
    19. } else {
    20. // concurrent模式
    21. ensureRootIsScheduled(root, eventTime);
    22. }
    23. }

    lane === SyncLane也就是 legacy 或 blocking 模式中, 注册完回调任务之后(ensureRootIsScheduled(root, eventTime)), 如果执行上下文为空, 会取消 schedule 调度, 主动刷新回调队列flushSyncCallbackQueue().

    这里包含了一个热点问题(setState到底是同步还是异步)的标准答案:

    • 如果逻辑进入flushSyncCallbackQueue(executionContext === NoContext), 则会主动取消调度, 并刷新回调, 立即进入fiber树构造过程. 当执行setState下一行代码时, fiber树已经重新渲染了, 故setState体现为同步.
    • 正常情况下, 不会取消schedule调度. 由于schedule调度是通过MessageChannel触发(宏任务), 故体现为异步.

    渲染优先级(renderLanes)

    这是一个全局概念, 每一次render之前, 首先要确定本次render的优先级. 具体对应到源码如下:

     
    
    1. // ...省略无关代码
    2. function performSyncWorkOnRoot(root) {
    3. let lanes;
    4. let exitStatus;
    5. // 获取本次`render`的优先级
    6. lanes = getNextLanes(root, lanes);
    7. exitStatus = renderRootSync(root, lanes);
    8. }
    9. // ...省略无关代码
    10. function performConcurrentWorkOnRoot(root) {
    11. // 获取本次`render`的优先级
    12. let lanes = getNextLanes(
    13. root,
    14. root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
    15. );
    16. if (lanes === NoLanes) {
    17. return null;
    18. }
    19. let exitStatus = renderRootConcurrent(root, lanes);
    20. }
    21. 可以看到, 无论是Legacy还是Concurrent模式, 在正式render之前, 都会调用getNextLanes获取一个优先级(源码链接).
    22. // ...省略部分代码
    23. export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
    24. // 1. check是否有等待中的lanes
    25. const pendingLanes = root.pendingLanes;
    26. if (pendingLanes === NoLanes) {
    27. return_highestLanePriority = NoLanePriority;
    28. return NoLanes;
    29. }
    30. let nextLanes = NoLanes;
    31. let nextLanePriority = NoLanePriority;
    32. const expiredLanes = root.expiredLanes;
    33. const suspendedLanes = root.suspendedLanes;
    34. const pingedLanes = root.pingedLanes;
    35. // 2. check是否有已过期的lanes
    36. if (expiredLanes !== NoLanes) {
    37. nextLanes = expiredLanes;
    38. nextLanePriority = return_highestLanePriority = SyncLanePriority;
    39. } else {
    40. const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
    41. if (nonIdlePendingLanes !== NoLanes) {
    42. // 非Idle任务 ...
    43. } else {
    44. // Idle任务 ...
    45. }
    46. }
    47. if (nextLanes === NoLanes) {
    48. return NoLanes;
    49. }
    50. return nextLanes;
    51. }

    getNextLanes会根据fiberRoot对象上的属性(expiredLanessuspendedLanespingedLanes等), 确定出当前最紧急的lanes.

    此处返回的lanes会作为全局渲染的优先级, 用于fiber树构造过程中. 针对fiber对象update对象, 只要它们的优先级(如: fiber.lanesupdate.lane)比渲染优先级低, 都将会被忽略.

    fiber优先级(fiber.lanes)

    React 应用中的高频对象一文中, 介绍过fiber对象的数据结构. 其中有 2 个属性与优先级相关:

    1. fiber.lanes: 代表本节点的优先级
    2. fiber.childLanes: 代表子节点的优先级 从FiberNode的构造函数中可以看出, fiber.lanesfiber.childLanes的初始值都为NoLanes, 在fiber树构造过程中, 使用全局的渲染优先级(renderLanes)和fiber.lanes判断fiber节点是否更新(源码地址).
      • 如果全局的渲染优先级renderLanes不包括fiber.lanes, 证明该fiber节点没有更新, 可以复用.
      • 如果不能复用, 进入创建阶段.
     
    
    1. function beginWork(
    2. current: Fiber | null,
    3. workInProgress: Fiber,
    4. renderLanes: Lanes,
    5. ): Fiber | null {
    6. const updateLanes = workInProgress.lanes;
    7. if (current !== null) {
    8. const oldProps = current.memoizedProps;
    9. const newProps = workInProgress.pendingProps;
    10. if (
    11. oldProps !== newProps ||
    12. hasLegacyContextChanged() ||
    13. // Force a re-render if the implementation changed due to hot reload:
    14. (__DEV__ ? workInProgress.type !== current.type : false)
    15. ) {
    16. didReceiveUpdate = true;
    17. } else if (!includesSomeLane(renderLanes, updateLanes)) {
    18. didReceiveUpdate = false;
    19. // 本`fiber`节点的没有更新, 可以复用, 进入bailout逻辑
    20. return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    21. }
    22. }
    23. // 不能复用, 创建新的fiber节点
    24. workInProgress.lanes = NoLanes; // 重置优先级为 NoLanes
    25. switch (workInProgress.tag) {
    26. case ClassComponent: {
    27. const Component = workInProgress.type;
    28. const unresolvedProps = workInProgress.pendingProps;
    29. const resolvedProps =
    30. workInProgress.elementType === Component
    31. ? unresolvedProps
    32. : resolveDefaultProps(Component, unresolvedProps);
    33. return updateClassComponent(
    34. current,
    35. workInProgress,
    36. Component,
    37. resolvedProps,
    38. // 正常情况下渲染优先级会被用于fiber树的构造过程
    39. renderLanes,
    40. );
    41. }
    42. }
    43. }

    栈帧管理

    React源码中, 每一次执行fiber树构造(也就是调用performSyncWorkOnRoot或者performConcurrentWorkOnRoot函数)的过程, 都需要一些全局变量来保存状态. 在上文中已经介绍最核心的全局变量.

    如果从单个变量来看, 它们就是一个个的全局变量. 如果将这些全局变量组合起来, 它们代表了当前fiber树构造的活动记录. 通过这一组全局变量, 可以还原fiber树构造过程(比如时间切片的实现过程(参考React 调度原理), fiber树构造过程被打断之后需要还原进度, 全靠这一组全局变量). 所以每次fiber树构造是一个独立的过程, 需要独立的一组全局变量, 在React内部把这一个独立的过程封装为一个栈帧stack(简单来说就是每次构造都需要独立的空间. 对于栈帧的深入理解, 请读者自行参考其他资料).

    所以在进行fiber树构造之前, 如果不需要恢复上一次构造进度, 都会刷新栈帧(源码在prepareFreshStack 函数)

     
    
    1. function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
    2. const prevExecutionContext = executionContext;
    3. executionContext |= RenderContext;
    4. const prevDispatcher = pushDispatcher();
    5. // 如果fiberRoot变动, 或者update.lane变动, 都会刷新栈帧, 丢弃上一次渲染进度
    6. if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    7. resetRenderTimer();
    8. // 刷新栈帧
    9. prepareFreshStack(root, lanes);
    10. startWorkOnPendingInteractions(root, lanes);
    11. }
    12. }
    13. /**
    14. 刷新栈帧: 重置 FiberRoot上的全局属性 和 `fiber树构造`循环过程中的全局变量
    15. */
    16. function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
    17. // 重置FiberRoot对象上的属性
    18. root.finishedWork = null;
    19. root.finishedLanes = NoLanes;
    20. const timeoutHandle = root.timeoutHandle;
    21. if (timeoutHandle !== noTimeout) {
    22. root.timeoutHandle = noTimeout;
    23. cancelTimeout(timeoutHandle);
    24. }
    25. if (workInProgress !== null) {
    26. let interruptedWork = workInProgress.return;
    27. while (interruptedWork !== null) {
    28. unwindInterruptedWork(interruptedWork);
    29. interruptedWork = interruptedWork.return;
    30. }
    31. }
    32. // 重置全局变量
    33. workInProgressRoot = root;
    34. workInProgress = createWorkInProgress(root.current, null); // 给HostRootFiber对象创建一个alternate, 并将其设置成全局 workInProgress
    35. workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
    36. workInProgressRootExitStatus = RootIncomplete;
    37. workInProgressRootFatalError = null;
    38. workInProgressRootSkippedLanes = NoLanes;
    39. workInProgressRootUpdatedLanes = NoLanes;
    40. workInProgressRootPingedLanes = NoLanes;
    41. }

    注意其中的createWorkInProgress(root.current, null), 其参数root.currentHostRootFiber, 作用是给HostRootFiber创建一个alternate副本.workInProgress指针指向这个副本(即workInProgress = HostRootFiber.alternate), 在上文double buffering中分析过, HostRootFiber.alternate正在构造的fiber树的根节点.

    总结

    本节是fiber树构造的准备篇, 首先在宏观上从不同的视角(任务调度循环fiber树构造循环)介绍了fiber树构造React体系中所处的位置, 然后深入react-reconciler包分析fiber树构造过程中需要使用到的全局变量, 并解读了双缓冲技术优先级(车道模型)的使用, 最后解释栈帧管理的实现细节. 有了这些基础知识, fiber树构造的具体实现过程会更加简单清晰.

  • 相关阅读:
    Java8的SerializedLambda详解
    Java 8 Time API
    基于POI的可快速定制Excel导出脚本设想
    【毕业设计】基于大数据的招聘职业爬取与分析可视化
    Metabase学习教程:提问-6
    基于模板匹配算法的交通标志识别(MATLAB源码)
    前向星(Forward Star)
    时序预测 | MATLAB实现贝叶斯优化CNN-BiLSTM时间序列预测(股票价格预测)
    国外问卷调查赚钱靠谱吗?
    CLIP改进工作串讲(上)
  • 原文地址:https://blog.csdn.net/weixin_44828588/article/details/126500528