React核心技术浅析

news2025/7/12 9:29:24

1. JSX与虚拟DOM

我们从React官方文档开头最基本的一段Hello World代码入手:

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

这段代码的意思是通过 ReactDOM.render() 方法将 h1 包裹的JSX元素渲染到id为“root”的HTML元素上. 除了在JS中早已熟知的 document.getElementById() 方法外, 这段代码中还包含两个知识点:

  • h1 标签包裹的JSX元素
  • ReactDOM.render() 方法

而这两个知识点则对应着React中要解决的核心问题:

  • 为何以及如何使用(JSX表示的)虚拟DOM?
  • 如何对虚拟DOM进行处理, 使其高效地渲染出来?

1.1 虚拟DOM是什么? 为何要使用虚拟DOM?

虚拟DOM其实就是用JavaScript对象表示的一个DOM节点, 内部包含了节点的 tag , propschildren .

为何使用虚拟DOM? 因为直接操作真实DOM繁琐且低效, 通过虚拟DOM, 将一部分昂贵的浏览器重绘工作转移到相对廉价的存储和计算资源上.

1.2 如何将JSX转换成虚拟DOM?

通过babel可以将JSX编译为特定的JavaScript对象, 示例代码如下:

// JSX
const e = (
    <div id="root">
        <h1 className="title">Title</h1>
  </div>
);
// babel编译结果(React17之前), 注意子元素的嵌套结构
var e = React.createElement(
    "div",
  { id: "root"},
    React.createElement(
        "h1",
        { className: "title" },
        "Title"
    )
);
// React17之后编译结果有所区别, 创建节点的方法由react导出, 但基本原理大同小异

1.3 如何将虚拟DOM渲染出来?

从上一节babel的编译结果可以看出, 虚拟DOM中包含了创建DOM所需的各种信息, 对于首次渲染, 直接依照这些信息创建DOM节点即可.

但虚拟DOM的真正价值在于“更新”: 当一个list中的某些项发生了变化, 或删除或增加了若干项, 如何通过对比前后的虚拟DOM树, 最小化地更新真实DOM? 这就是React的核心目标.

2. React Diffing

"Diffing"即“找不同”, 就是解决上文引出的React的核心目标——如何通过对比新旧虚拟DOM树, 以在最小的操作次数下将旧DOM树转换为新DOM树.

在算法领域中, 两棵树的转换目前最优的算法复杂度为 O(n**3) , n为节点个数. 这意味着当树上有1000个元素时, 需要10亿次比较, 显然远远不够高效.

React在基于以下两个假设的基础上, 提出了一套复杂度为 O(n) 的启发式算法

  1. 不同类型(即标签名、组件名)的元素会产生不同的树;
  2. 通过设置 key 属性来标识一组同级子元素在渲染前后是否保持不变.

在实践中, 以上两个假设在绝大多数场景下都成立

2.1 Diffling算法描述

不同类型的元素/组件

当元素的标签或组件名发生变化, 直接卸载并替换以此元素作为根节点的整个子树.

同一类型的元素

当元素的标签相同时, React保留此DOM节点, 仅对比和更新有改变的属性, 如className、title等, 然后递归对比其子节点.

对于 style 属性, React会继续深入对比, 仅更新有改变的属性, 如color、fontSize等.

同一类型的组件

当组件的props更新时, 组件实例保持不变, React调用组件的 componentWillReceiveProps() componentWillUpdate()componentDidUpdate() 生命周期方法, 并执行 render() 方法.

Diffing算法会递归比对新旧 render() 执行的结果.

参考React实战视频讲解:进入学习

对子节点的递归

当一组同级子节点(列表)的末尾添加了新的子节点时, 上述Diffing算法的开销较小; 但当新元素被插入到列表开头时, Diffing算法只能按顺序依次比对并重建从新元素开始的后续所有子节点, 造成极大的开销浪费.

解决方案是为一组列表项添加 key 属性, 这样React就可以方便地比对出插入或删除项了.

关于 key 属性, 应稳定、可预测且在列表内唯一(无需全局唯一), 如果数据有ID的话直接使用此ID作为 key, 或者利用数据中的一部分字段哈希出一个key值.

避免使用数组索引值作为 key, 因为当插入或删除元素后, 之后的元素和索引值的对应关系都会发生错乱, 导致错误的比对结果.

避免使用不稳定的key(如随机数), 因为每次渲染都会发生改变, 从而导致列表项被不必要地重建.

2.2 递归的Diffing

在1.2节中的虚拟DOM对象中可以得知: 虚拟DOM树的每个节点通过 children 属性构成了一个嵌套的树结构, 这意味着要以递归的形式遍历和比较新旧虚拟DOM树.

2.1节的策略解决了Diffing算法的时间复杂度的问题, 但我们还面临着另外一个重大的性能问题——浏览器的渲染线程和JS的执行线程是互斥的, 这意味着DOM节点过多时, 虚拟DOM树的构建和处理会长时间占用主线程, 使得一些需要高优先级处理的操作如用户输入、平滑动画等被阻塞, 严重影响使用体验.

时间切片(Time Slice)

为了解决浏览器主线程的阻塞问题, 引出 时间切片 的策略——将整个工作流程分解成小的工作单元, 并在浏览器空闲时交由浏览器执行这些工作单元, 每个执行单元执行完毕后, 浏览器都可以选择中断渲染并处理其他需要更高优先级处理的工作.

浏览器中提供了 requestIdleCallback 方法实现此功能, 将待调用的函数加入执行队列, 浏览器将在不影响关键事件处理的情况下逐个调用.

考虑到浏览器的兼容性以及 requestIdleCallback 方法的不稳定性, React自己实现了专用于React的类似 requestIdleCallback 且功能更完备的 Scheduler 来实现空闲时触发回调, 并提供了多种优先级供任务设置.

递归与时间切片

时间切片策略要求我们将虚拟DOM的更新操作分解为小的工作单元, 同时具备以下特性:

  • 可暂停、可恢复的更新;
  • 可跳过的重复性、覆盖性更新;
  • 具备优先级的更新.

对于递归形式的程序来说, 这些是难以实现的. 于是就需要一个处于递归形式的虚拟DOM树上层的数据结构, 来辅助完成这些特性.

这就是React16引入的重构后的算法核心——Fiber.

3. Fiber

从概念上来说, Fiber就是重构后的虚拟DOM节点, 一个Fiber就是一个JS对象.

Fiber节点之间构成 单向链表 结构, 以实现前文提到的几个特性: 更新可暂停/恢复、可跳过、可设优先级.

3.1 Fiber节点

一个Fiber节点就是一个JS对象, 其中的关键属性可分类列举如下:

  • 结构信息(构成链表的指针属性)
    • return: 父节点
    • child: 第一个子节点
    • sibling: 右侧第一个兄弟节点
    • alternate: 本节点在相邻更新时的状态, 用于比较节点前后的变化, 3.3节详述
  • 组件信息
    • tag: 组件创建类型, 如FunctionComponent、ClassComponent、HostComponent等
    • key: 即key属性
    • type: 组件类型, Function/Class组件的type就是对应的Function/Class本身, Host组件的type就是对应元素的TagName
    • stateNode: 对应的真实DOM节点
  • 本次更新的props和state相关信息
    • pendingProps、memoizedProps
    • memoizedState
    • dependencies
    • updateQueue
  • 更新标记
    • effectTag: 节点更新类型, 如替换、更新、删除等
    • nextEffect、firstEffect、lastEffect
  • 优先级相关: lanes、childrenLanes

3.2 Fiber树

前文说到, Fiber节点通过 return , childslibling 属性构成了单向链表结构, 为了与DOM树对应, 习惯上仍称其为“树”.

如一棵DOM树:

<div>
    <h1>Title</h1>
    <section>
        <h2>Section</h2>
        <p>Content</p>
    </section>
    <footer>Footer</footer>
</div>

section 节点的Fiber可表示为:

const sectionFiber = {
    key: "SECTION_KEY",
    child: h2Fiber,
    sibling: footerFiber,
    return: divFiber,
    alternate: oldSectionFiber,  
    ...otherFiberProps,
}

整体的Fiber结构:

DOM.png

3.3 Fiber架构

基于Fiber构成的虚拟DOM树就是Fiber架构.

在3.1节中我们介绍过, 在Fiber节点中有一个重要属性 alternate , 单词意为“备用”.

实际上, 在React中最多会同时存在两棵Fiber树:

  • 当前显示在屏幕上、已经构建完成的Fiber树称为“Current Fiber Tree”, 我们将其中的Fiber节点简写为 currFiber;
  • 当前正在构建的Fiber树称为“WorkInProgress Fiber Tree”, 我们将其Fiber节点节点简写为 wipFiber.

而这两棵树中节点的 alternate 属性互相指向对方树中的对应节点, 即: currFiber.alternate === wipFiber; wipFiber.alternate === currFber; 他们用于对比更新前后的节点以决定如何更新此节点.

在React中, 整个应用的根节点为 fiberRoot , 当wipFiber树构建完成后, fiberRoot.current 将从currFiber树的根节点切换为wipFiber的根节点, 以完成更新操作.

3.1 基于Fiber的调度——时间切片

在2.2节我们讨论了采用拆分工作单元并以时间切片的方式执行, 以避免阻塞主线程. 在Fiber架构下, 每个Fiber节点就是一个工作单元.

在以下示例代码中, 我们使用浏览器提供的 requestIdleCallback 方法演示这个过程, 它会在浏览器空闲时执行一个workLoop、处理一个Fiber节点, 然后可以根据实际情况继续执行或暂停等待执行下一个workLoop.

function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
        // 处理一个Fiber节点, 返回下一个Fiber节点, 详见3.3节
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    // 暂停处理的演示: 当时间不足时取消循环处理过程
    shouldYield = deadline.timeRemaining() < 1;
  }
  // 当执行完毕(不存在下一个执行单元), 提交整个DOM树
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
  requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);

3.2 对Fiber节点的处理顺序——DFS

由前文我们可知, Fiber节点通过 return child sibling 三个属性相互连接, 整体构成一个单向链表结构,其调度方式就是 深度优先遍历 :

  1. 以wipFiber树的Root节点作为第一个执行单元;
  2. 若当前执行单元存在child节点, 则将child节点作为下一个执行单元;
  3. 重复2, 直至当前执行单元无child;
  4. 若当前执行单元存在sibling节点, 则将sibling节点作为下一个执行单元, 并回到2;
  5. 若当前执行单元无child且无sibling, 返回到父节点, 并回到4;
  6. 重复5; 直至回到Root节点, 执行完毕, 将 fiberRoot.current 只为wipFiber树的根节点.

以上步骤说明, Fiber节点通过 childsiblingreturn 的顺序进行深度优先遍历“处理”, 而后更新Fiber树. 那么如何“处理”Fiber节点呢?

3.3 对Fiber节点的处理过程

对Fiber节点的处理就是执行一个 performUnitOfWork 方法, 它接收一个将要处理的Fiber节点, 然后完成以下工作:

  1. 完善构建Fiber节点: 创建DOM并获取 children

    • 对于HostComponent和ClassComponent, 根据Fiber中的相关属性, 创建DOM节点并赋给 Fiber.stateNode 属性;
    • 对于FunctionComponent, 直接通过函数调用获取其children: Fiber.type(Fiber.props)
    // 执行工作单元,并返回下一个工作单元
    function performUnitOfWork(fiber) {
     // 构建当前节点的fiber
     const isFunctionComponent = fiber.type instanceof Function;
     if (isFunctionComponent) {
       updateFunctionComponent(fiber);
     } else {
       updateHostComponent(fiber);
     }
    
     // 处理子节点, 构建Fiber树
     const elements = fiber.props.children;
     reconcileChildren(fiber, elements);
    
     // TODO: 返回下一个执行单元
     // fiber.child || fiber.sibling || fiber.return
    }
    
    // Class/Host组件: 创建DOM
    function updateHostComponent(fiber) {
     if (!fiber.dom) {
       fiber.dom = createDom(fiber);
     }
     reconcileChildren(fiber, fiber.props.children);
    }
    
    // 更新Function组件, Function组件需要从返回值获取子组件
    // 注意: Function组件无DOM
    function updateFunctionComponent(fiber) {
     // 初始化hooks
     wipFiber = fiber;
     hookIndex = 0;
     fiber.hooks = [];
     const children = [fiber.type(fiber.props)]; // Function组件返回children
     reconcileChildren(fiber, children);
    }
    // TODO: reconcileChildren处理子节点,见第3步
    
  2. 通过 Fiber.alternate 获取 oldFiber , 即上一次更新后的Fiber值, 然后在下一步中构建和Diff当前Fiber的 children .

    function reconcileChildren(wipFiber, elements) {
     let oldFiber = wipFiber.alternate
               && wipFiber.alternate.child;
     // ...
    }
    
  3. 构建 children Fibers, 对于每个子Fiber, 同步地完成以下工作:

    • 构建Fiber链表: 为每个子元素创建Fiber, 并将父Fiber的 child 属性指向第一个子Fiber, 然后按顺序将子Fiber的 sibling 属性指向下一个子Fiber;
    • 对比(Diffing)新旧Fiber节点的 type props key 等属性, 确定节点是可以直接复用、替换、更新还是删除, 需要更新的Fiber节点在其 effectTag 属性中打上 Update Placement PlacementAndUpdate Deletion 等标记, 以在提交更新阶段进行处理.
    function reconcileChildren(wipFiber, elements) {
     let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
     let index = 0;
     let prevSibling = null;
    
     while (index < elements.length || oldFiber !== null) {
       const element = elements[index];
       let newFiber = null;
    
       // Compare oldFiber to element
       const sameType = oldFiber && element && element.type === oldFiber.type;
    
       if (sameType) {
         // update the node
         newFiber = {
           type: oldFiber.type,
           props: element.props,
           dom: oldFiber.dom,
           parent: wipFiber,
           alternate: oldFiber,
           effectTag: "UPDATE",
         };
       }
    
       if (element && !sameType) {
         // add this node
         newFiber = {
           type: element.type,
           props: element.props,
           dom: null,
           parent: wipFiber,
           alternate: null,
           effectTag: "PLACEMENT",
         };
       }
    
       if (oldFiber && !sameType) {
         // delete the oldFiber's node
         oldFiber.effectTag = "DELETION";
         deletions.push(oldFiber);
       }
    
       if (oldFiber) {
         oldFiber = oldFiber.sibling;
       }
    
       if (index === 0) {
         wipFiber.child = newFiber;
       } else {
         prevSibling = newFiber;
       }
    
       prevSibling = newFiber;
       index++;
     }
    
  4. 按DFS顺序返回下一个工作单元, 示例代码如下:

    if (fiber.child) {
       return fiber.child;
     }
     let nextFiber = fiber;
     while (nextFiber) {
       if (nextFiber.sibling) {
         return nextFiber.sibling;
       }
       nextFiber = nextFiber.parent;
     }
    

当DFS过程回到根节点时, 表明本次更新的 wipFiber树 构建完成, 进入下一步的提交更新阶段.

3.4 提交更新阶段

在进入本阶段时, 新的Fiber树已构建完成, 需要进行替换、更新或删除的Fiber节点也在其 effectTag 中进行了标记, 所以本阶段第一个工作就是根据 effectTag 操作真实DOM.

为了避免从头再遍历Fiber树寻找具有 effectTag 属性的Fiber, 在上一步Fiber树的构建过程中保存了一条需要更新的Fiber节点的单向链表 effectList , 并将此链表的头节点存储在Fiber树根节点的 firstEffect 属性中, 同时这些Fiber节点的 updateQueue 属性中也保存了需要更新的 props .

除了更新真实DOM外, 在提交更新阶段还需要在特定阶段调用和处理生命周期方法、执行Hooks操作, 本文不再详述.

在此参考了 pomb.us/build-your-… 中提供的 useState Hook的实现代码, 有助于理解在执行 setState 方法后都发生了什么:

function useState(initial) {
  // 判断上一次渲染是否存在此Hook,如果存在就使用上一个state,否则创建新的hook并更新索引
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [], // 每次执行setState时,将action加入此队列,并在下一次渲染时执行
  };

  // 下一次渲染时, 获取执行队列并逐步执行, 使得state保持最新
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {
    hook.state = action(hook.state);
  });

  // setState方法: 将action添加到执行队列并触发渲染, 在下一次渲染时执行此action
  const setState = (action) => {
    hook.queue.push(action);
    // 执行setState后应重新触发渲染
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };

  wipFiber.hooks.push(hook);
  hookIndex++;
  return [hook.state, setState];
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/8743.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

NVIDIA Grace Hopper架构深度解析

NVIDIA Grace Hopper架构深度解析 NVIDIA Grace Hopper Superchip 架构是第一个真正的异构加速平台&#xff0c;适用于高性能计算 (HPC) 和 AI 工作负载。 它利用 GPU 和 CPU 的优势加速应用程序&#xff0c;同时提供迄今为止最简单、最高效的分布式异构编程模型。 科学家和工程…

Python~Pandas 小白避坑之常用笔记

Python~Pandas 小白避坑之常用笔记 提示&#xff1a;该文章仅适合小白同学&#xff0c;如有错误的地方欢迎大佬在评论处赐教 文章目录Python~Pandas 小白避坑之常用笔记前言一、pandas安装二、数据读取1.读取xlsx文件2.读取csv文件三、重复值、缺失值、异常值处理、按行、按列剔…

pytest allure 生成报告过程

allure 下载地址&#xff1a;Releases allure-framework/allure2 GitHub 下载好后配置环境变量执行&#xff1a; allure --version 看见版本号就算配置成功了 pytest allure 生成报告过程 allure添加测试类名&#xff0c;方法名&#xff0c;步骤&#xff1a; allure.fea…

【附源码】计算机毕业设计JAVA教学成果管理平台录像演示

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat8.5 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; Springboot mybatis Maven Vue 等等组成&#xff0c;B/…

教你一招轻松搞定mp3格式转换

第一种&#xff1a;ncm转mp3 经常使用网易云音乐的朋友应该会发现&#xff0c;网易云VIP音乐下载后&#xff0c;有些音乐是ncm格式的&#xff0c;无法导入PR或者一些编辑软件。 解决方法如下&#xff1a; 利用在线网站处理——Convertio 第一步&#xff1a;打开谷歌浏览器客户端…

9家美发连锁店老板一天剪辑1000个视频,用呆头鹅批量剪辑软件剪

1.1呆头鹅批量剪辑软件核心优势 01.我们的产品是经过市场考验的&#xff0c;像有结果的人学习&#xff0c;买有结果的产品。 02.3年的打磨&#xff0c;更新和删除了200多个模块 03.100多次持续优化更新 04.10000个公司和工作室和个人的使用和建议。 05.一个用户至少做出100…

磁盘有空间但无法创建文件

面试原题 我们去面试的时候,面试官通常会问一个问题, “小伙子,你在这些年的工作中,遇到过什么棘手的问题没有? 面试官问这个问题,无非想知道以下几件事情 你有没有过处理疑难问题的经验你解决问题的思路和能力如何你是怎么解决的你解决完这个问题有哪些收获 面试错误示范 …

Java生成验证码+动态分析技术+【实训10】HTML信息隐藏(信息安全技术作业)

Java生成验证码 第1关&#xff1a;使用Servlet生成验证码 任务要求参考答案评论 任务描述相关知识 为什么要有验证码&#xff0c;什么是验证码如何使用Servlet生成验证码编程要求测试说明任务描述 本关任务&#xff1a;使用servlet生成验证码。 相关知识 验证码在我们登陆…

硬链接及软连接引出的inode

inode定义 inode是linux系统中用作数据索引的标识符。简单来说&#xff0c;inode指示了一个文件的基本信息&#xff0c;如inode编号、修改时间、文件的位置等。 如同一本书的目录&#xff0c;会直接告诉你想看的章节是在第几页。不同的是&#xff0c;书是以页为单位的&#x…

软考 - 数据结构与算法

数据结构 线性结构 线性表 存储结构 顺序存储&#xff1a;用一组地址连续的存储单元 依次存储线性表中的数据元素&#xff0c;使得逻辑上相邻的元素物理上也相邻。 链式存储&#xff1a;存储各数据元素的结点的地址并不要求是连续的&#xff0c;数据元素逻辑上相邻&#xff…

提升Mac运行速度的三大方法

任何一部电子设备在使用多年之后都会出现性能下降的问题&#xff0c;苹果的Mac计算机自然也不例外。当你发现Mac运行缓慢&#xff0c;因为有太多文件或缓存垃圾将Mac的运行速度拖了下来。 要想提高生活和工作效率&#xff0c;必须对Mac进行优化&#xff0c;提升一下Mac 的使用性…

全业务链管理平台Odoo

什么是 Odoo ? Odoo 是一款非常容易使用又完全集成的商业应用&#xff0c;是一站式全业务链管理平台。 docker cli 安装 本项目涉及到 2 个容器&#xff0c;之前我们在下面&#x1f447;这些文章中 开源的看板管理工具Wekan类Trello的看板软件Planka群晖上安装MediaWiki的简…

linux系统java环境变量的下载与安装

由于目前好多工具的安全使用需要安装java环境&#xff0c;所有今天就分享一下java环境变量的安装与配置下载地址&#xff1a; https://download.oracle.com/otn/java/jdk/8u351-b10/10e8cce67c7843478f41411b7003171c/jdk-8u351-linux-i586.tar.gz?AuthParam1668564371_517fa4…

【2022.11.16】Lammps+Python+MATLAB在绘制维诺图时遇到的问题

目录写在前面绘制g6(r)执行步骤问题1&#xff1a;数据导入问题2&#xff1a;利用Python选取想要的数据问题3&#xff1a;如何找到每个点的最近邻问题4&#xff1a;绘制维诺图写在前面 记录一下做毕设时候遇到的问题 大目标是绘制g6的图片 相关文章&#xff1a; [1] Zu M , Li…

艾美捷可电离脂质SM-102解决方案

艾美捷SM-102是一种用于脂质纳米颗粒&#xff08;LNP&#xff09;的可电离脂质。LNP组合物已被证明有效地作为生物活性物质如小分子药物、蛋白质和核酸的运输载体进入细胞和/或细胞内隔室。这是一种试剂级产品&#xff0c;仅供研究使用。 艾美捷SM-102基本参数&#xff1a; 类…

企业管理 - 波司登战略管理解析

波司登战略管理解析 领导人讲话&#xff0c;视频&#xff1a;国家的战略&#xff0c;建设有中国特色的社会主义 这是一个文化大过滤时代&#xff1a;从宏观来讲&#xff0c;大的社会环境&#xff0c;包括现象&#xff0c;反腐&#xff0c;教育改革&#xff0c;把中华民族优秀…

Go(八)函数

目录 1. 函数 1.1 函数定义 1.2 函数的调用 1.3 参数 1.3.1 类型简写 1.3.2 可变参数 1.4 返回值 1.4.1 多返回值 1.4.2 返回值命名 1.4.3 返回值补充 2. 函数进阶 2.1 变量作用域 2.1.1 全局变量 2.1.2 局部变量 2.2 函数类型与变量 2.2.1 定义函数类型 2.2.…

FL Studio21免许可证完整版数字音频工作站(DAW)

如果你一直梦想制作自己的音乐(无论是作为一名制作人还是艺术家)&#xff0c;你可能会想你出生在这个时代是你的幸运星。这个水果圈工作室和上一版之间的改进水平确实令人钦佩。这仅仅是FL Studio 21所提供的皮毛。你的音乐项目的选择真的会让你大吃一惊。你以前从未有过这样的…

K3s离线部署

下载离线镜像包 离线镜像包下载地址 因为我的环境是CentOS 7.7&#xff0c;所以下载红框里的两个 下载部署脚本 部署脚本下载地址&#xff08;https://get.k3s.io&#xff09; 将准备好的文件上传到服务器上后&#xff0c;将k3s二进制文件及镜像包放到指定目录&#xff08;每…

大数据在线实习项目能收获什么呢?

大数据在线实习项目提供在线大数据项目&#xff0c;参与真实企业项目&#xff0c;可提供实习证明。 项目实习过程会根据项目背景、项目目标、项目数据来逐一展开&#xff0c;一个项目的项目周期大概为4周时间&#xff0c;同时也可以根据自身时间条件来调整。 项目涉及多方面知识…