专栏首页极乐技术社区详解:如何用好React跨端框架开发小程序

详解:如何用好React跨端框架开发小程序

在上一篇推文中,我们详细讲解了Vue跨端框架的原理。下面,我们将从React跨端框架,进入到小程序跨端原理的世界,讲解这些跨端框架的核心原理,深入到源码底层去分析,揭开他们神秘的面纱。

1

类 React 跨端框架

类 React 框架存在一个最棘手的问题:如何把灵活的 jsx 和动态的 react 语法转为静态的小程序模板语法。

为了解决问题,不同的的团队实践了不同的方案,大体上可以把所有的类 React 框架分类两类:

  • 静态编译型。代表有:京东的 Taro 1/2 , 去哪儿的 Nanachi,淘宝的rax
  • 运行时型。代表有: 京东的 Taro Next ,蚂蚁的 remax

静态编译型小程序框架

所谓静态编译,就是上面说的这些框架会把用户写的业务代码解析成 AST 树,然后通过语法分析强行把用户写的类 react 的代码转换成可运行的小程序代码。

如下图所示的Taro1版本或者2版本的逻辑图,整个跨端的核心逻辑是落在编译过程中的抽象语法树转化中做的。

Taro 1/2 在编译的时候,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。

有过 Babel 插件开发经验的同学应该对上面流程十分熟悉了,无非就是调用 babel 提供的 API 匹配不同的情况,然后修改 AST 树。

下面我们来举一个例子,如果我们使用 Taro 1/2 框架来写小程序页面组件,很可能是长成下面这样:

可以看到上面组件非常像一个 React 组件,你需要定义一个 Componentrender 方法,并且需要返回一段 JSX

这段的代码,会在 Taro1/2 编译打包的时候,被框架编译成小程序代码。具体来说, render 方法中的 JSX 会被提取出来,经过一系列的重重转换,转换成小程序的静态模板,其他 JS 的部分则会保留成为小程序页面的定义,如下图所示:

这听上去是一件很美好的事情,但是现实很骨感,为啥呢?

JSX 的语法过于灵活。

JSX 的灵活是一个双刃剑,它可以让我们写出非常复杂灵活的组件,但是也增加了编译阶段框架去分析和优化的难度。

你在使用 JavaScript 的时候,编译器不可能hold住所有可能发生的事情,因为 JavaScript 太过于动态化。你想用静态的方式去分析它是非常复杂一件事情,我们只要稍微在上面的图中例子中加入一点动态的写法,这些框架就可能编译失败。

虽然这块很多框架已经做了很多尝试,但从本质上来说,框架很难通过这种方式对其提供安全的优化。

这也是 React 团队花了3 年的时候搞出来 fiber 的意义, React 的优化方案并不是在编译时优化,而是在运行时通过时间分片不阻塞用户的操作让页面感觉快起来。

所以,React 解决不了的问题,这些小程序跨端框架同样也解决不了。

他们都会告诉开发者要去避免很多的动态写法。比如说 Taro 1 /2 版本的文档里面就给出了非常清晰的提示

Taro 1/2 的弯路

Taro 发展到了2019年,他们终于意识到了上面问题的紧迫性:JSX 适配工作量大,很难追上 react 的更新。

这些问题归根到底,很大一部分是 Taro 1/2 的架构问题。Taro 1/2 用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,完全就是堆人力去适配 jsx ,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法

于此同时,蚂蚁金服的@边柳在第三届 SEE Conf 介绍了 Remax ,走了不同于静态编译的一条路,推广的口号是 『使用真正的 React 来构建小程序』。因为 Taro 1/2是假的 React,只是在开发时遵循了 React 的语法,在代码编译之后实际运行时的和 React 并没有半毛钱关系,因此也没法支持 React 最新的特性。

Taro 团队从活跃的社区中受到了启发 ( ~~抄了人家的 remax ~~),完全重写了 Taro 的架构,带来了 Taro Next 版本。

接下来,我们会一点点揭开 React 运行时跨端框架的面纱。Taro NextRemax 原理相似,Remax 已经比较稳定了,下面会着重讲解 Remax 的原理,Taro Next 放在最后作为比较。

你需要对 React 的基本原理有一定的了解。

React 前置知识

在深入阅读本文之前,先要确保你能够理解以下几个基本概念:

Element

通过 JSX 或者 React.createElement 来创建 Element,比如:

JSX 会被转义译为:

React.createElement 最终构建出类似这样的对象:

Reconciler 调和器 & Renderer 渲染器

React 16版本带来了全新的 fiber 的架构,代码拆分也非常清晰,大体上可以拆分成这三大块:

  • React component API 代码量比较少
  • Reconciler 调和器 代码量非常大,是fiber 调度的核心
  • Renderer 渲染器,负责具体到某一个平台的渲染,最常见的 ReactDOM 就是 web 浏览器平台的自定义渲染器

ReconcilerRenderer 的关系可以通过下图缕清楚

  • Reconciler调和器的职责是负责React的调度和更新,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。
  • Renderer自定义渲染器,负责具体到哪一个平台的渲染工作,它会提供宿主组件、处理事件等等。

Renderer 自定义渲染器里面定义了一堆方法,是提供给 React 的 reconciler 使用的。React 的 reconciler 会调用渲染器中的定义一系列方法来更新最后的页面。

我们接下来会重点介绍Renderer自定义渲染器, 暂且先不管 Reconciler 调和器 ,就先认为它是一个React 提供的黑盒。这个黑盒里面帮我们做了时间分片、任务的优先级调度和 fiber 节点 diff 巴拉巴拉一系列的是事情,我们都不关心。

我们只需要知道 Reconcier 调和器在做完 current fiber tree 和 workIn progress fiber tree 的 diff 工作后,收集到 effects 准备 commit 到真实的 DOM 节点,是调用了的自定义渲染器中提供的方法。

如果在自定义渲染器中,你调用了操作 WEB 浏览器 web DOM的方法,诸如我们很熟悉的 createElementappendhild,那么就创建/更新浏览器中的 web 页面;如果渲染器中你调用了iOS UI Kit API,那么则更新 ios ,如果渲染器中调用了 Android UI API, 则更新 Android。

Renderer 自定义渲染器有很多种,我们最常见的ReactDOM就是一个渲染器,不同的平台有不同的 React 的渲染器,其他还有很多有意思的自定义渲染器,可以让 React 用在TV 上,Vr 设备上等等,可以点击这个链接进行了解:github.com/chentsulin/…

事实上,Remax 和 Taro Next 相当于是自己实现了一套可以在 React 中用的,且能渲染到小程序页面的自定义渲染器。

总结来说,React 核心调度工作是在 Reconciler 中完成;『画』到具体的平台上,是自定义渲染器的工作。

关于React渲染器的基本原理,如果对这个话题感兴趣的同学推荐观看前React Team 成员 Sophie Alpert 在 React Conf 上分享的《Building a Custom React Renderer》,也特别推荐这个系列的文章 Beginners guide to Custom React Renderers,讲解的比较细致

Fiber 架构的两个阶段

React 16 版本Fiber 架构之后,更新过程被分为两个阶段:

  • 协调阶段(Reconciliation Phase) 这个阶段 Reconciler 调度器会根据事件切片,按照任务的优先级来调度任务,最终会找出需要更新的节点。协调阶段是可以被打断的,比如有优先级更高的事件要处理时。
  • 提交阶段(Commit Phase) 将协调阶段计算出来的需要处理的副作用(Effects)一次性执行,也就是把需要做出的更改,一下子应用到 dom 节点上,去修改真实的 DOM 节点。这个阶段必须同步执行,不能被打断

这两个阶段按照render为界,可以将生命周期函数按照两个阶段进行划分:

  • 协调阶段
    • constructor
    • componentWillMount 废弃
    • componentWillReceiveProps 废弃
    • static getDerivedStateFromProps
    • shouldComponentUpdate
    • componentWillUpdate 废弃
    • render
    • getSnapshotBeforeUpdate()
  • 提交阶段
    • componentDidMount
    • componentDidUpdate
    • componentWillUnmount

自定义渲染器 Rerender

创建一个自定义渲染器只需两步:

  1. 宿主配置HostConfig,也就是下图中绿色方框 HostConfig 的配置
  2. 实现渲染函数,类似于 ReactDOM.render() 方法

宿主配置 HostConfig,这是react-reconciler要求宿主平台提供的一些适配器方法和配置项。这些配置项定义了如何创建节点实例、构建节点树、提交和更新等操作。下文会详细介绍这些配置项

渲染函数就比较套路了,类似于 ReactDOM.render() 方法,本质就是调用了 ReactReconcilerInst 的两个方法 createContainerupdateContainer

容器既是 React 组件树挂载的目标(例如 ReactDOM 我们通常会挂载到 #root 元素,#root 就是一个容器)、也是组件树的 根Fiber节点(FiberRoot)。根节点是整个组件树的入口,它将会被 Reconciler 用来保存一些信息,以及管理所有节点的更新和渲染。

Remax 的自定义渲染器

HostConfig 支持非常多的参数,这些参数非常多,而且处于 API 不稳定的状态,大家稍微了解一下即可,不用深究。另外,没有详细的文档,你需要查看源代码或者其他渲染器实现。

如果感兴趣的同学可以移步这篇文章 react 渲染器了解一下?。常见配置可以按照下面的阶段来划分:

通过上面代码,我们可以知道 HostConfig 配置比较丰富,涉及节点操作、挂载、更新、调度、以及各种生命周期钩子, Reconciler 会在不同的阶段调用配置方法。比如说在协调阶段会新建节点,在提交阶段会修改子节点的关系。

为了思路清晰,我们按照 【协调阶段】——【提交阶段】—— 【提交完成】这三个阶段来看,我们接下来先看一下协调阶段。

协调阶段

在协调阶段, Reconciler 会调用 HostConfig 配置里面的 createInstancecreateTextInstance 来创建节点。我们接下俩看看 Remax 源码是怎么样子的

大家可以回想一下,如果是原本的 ReactDOM 中的话,上面两个方法应该是通过 javascript 原生的 API document.createElementdocument.createTextNode 来创建浏览器环境的中的DOM节点

因为在小程序的环境中,我们没有办法操作小程序的原生节点,所以Remax 在这里,不是直接去改变 DOM,而创建了自己的 VNode 节点。

你可能会感到惊讶,还能这样玩,不是说好要操作平台的节点嘛,这样不会报错吗?

原因是,React 的 Reconciler 调和器在调度更新时,不关心 hostConifg 里你新建的一个节点到底是啥,也不会改写你在 hostConifg 中定义的节点属性。

所以自定义渲染器Renderer中一个节点可以是一个 DOM 节点,也可以是自己定义的一个普通 javascript 对象,也可以是 VR 设备上的一个元素。

总而言之,React 的 Reconciler 调度器并不关心自定义渲染器 Renderer 中的节点是什么形状的,只会把这个节点透传到 hostConfig 中定义的其他方法中,比如说 appendChildremoveChildinsertBefore 这些方法中。

上面 Remax 的代码中创建了自己的 VNode 节点, VNode 的基本结构如下:

友情提示:这里的 VNode 是 Remax 中自己搞出来的一个对象,和 React 或者 Vue 中的 virtual dom 没有半毛钱的关系

可以看到,VNode 其实通过 childrenparent 组成了一个树状结构,我们把它称为一颗镜像树(Mirror Tree),这颗镜像树最终会渲染成小程序的界面。VNode就是镜像树中的虚拟节点,主要用于保存一些节点信息。

所以, Remax在 HostConfig 配置的方法中,并没有真正的操作 DOM 节点,而是先构成一颗镜像树(Mirror Tree), 然后再同步到渲染进程中,如下图绿色的方框所示的那样,我们会使用 React 构成一个镜像树的 Vnode Tree,然后交给小程序平台把这个树给渲染出来。

提交阶段

提交阶段也就是 commit 阶段,react 会把 effect list 中存在的变更同步到渲染环境的 DOM 节点上去,会分别调用 appendChildremoveChildinsertBefore 这些方法

下面我们看,Remax 源码里面究竟是如何实现这些方法的。

appendChild

如果是原生的浏览器环境中,appendChild 比较简单,直接调用 javascript 原生操作 DOM 的方法即可。如果是小程序的环境中,你得自己实现 hostConfig 中定义的 VNode 节点上的 appendChild 的方法,源码实现如下:

上面代码中,并没有直接操作小程序的 DOM ,而是操作存内存中的 VNode 组成的镜像树:

  1. 把入参 node 挂载到 child 链表上 ;
  2. 最后调用了 requestUpdate 这个方法,下面会有详细的讲到。

removeChild

removeChild 方法和上面是同一个套路,先是修改了 VNode 镜像树上的节点关系,然后调用了 requestUpdate 这个方法

insertBefore

insertBefore 方法和上面是同一个套路,先是修改了 VNode 镜像树上的节点关系,然后调用了 requestUpdate 这个方法

上面介绍的这些方法,都是对节点位置关系的更新,比如说子节点位置的移动啊之类的。

现实中肯定也会有一些更新是不涉及到节点移动,而是比如说,节点上的属性发生了变化、节点的文本发生了变化,Reconciler 就会在协调阶段调用下面的这些方法。

commitUpdate

上面调用了 node.update 方法,定义如下

真神奇鸭,最后还是调用了 requestUpdate 方法,殊途同归的感觉。

上面的方法中,最后都调用了神奇的 requestUpdate 方法,我们看一下这个方法里面做了什么

requestUpdate

requestUpdate 方法定义如下:

没想到吧, 这个requestUpdate方法那么简单。

  1. 接受一个对象作为参数
  2. 然后把接收的参数 update 推入到 this.updateQueue 这个数组里面,暂存起来,之后会在【提交完成阶段】派上大用场。

提交完成阶段

在这个阶段之前,Remax 构成的 VNode镜像树的这个JSON 数据还是在 Remax 世界中被管理和维护,接下来,我们会看如何更新 小程序的世界中。

React 会在提交完成阶段执行 hostConfig 中定义的 resetAfterCommit 方法,这个方法原本是用React 想来做一些善后的工作。但是Remax在这个resetAfterCommit 方法做了一个及其重要的工作,那就是同步镜像树到小程序** data**。

接下来我们来看 resetAfterCommit 方法的源码

上面代码的意思是, 通过之前缓存的updateQueue 计算出来 updatePayloadupdatePayload 是一个什么东东呢?我们可以通过 debug 断点来一览它的风采。

在某一次更新之后的断点:

updatePayload 是一个 javascript 的对象,对象的 key 是数据在小程序世界中的路径,对象的 value 就是要更新的值。

小程序的 setData 是支持这样的写法:setData({ root.a.b.c: 10 }), key 可以表达层次关系

在第一次 mount 时的断点:

在第一次 mount 时,Remax 运行时初始化时会通过小程序的 setData 初始化小程序的 JSON 树状数据

然后,Remax 运行时在数据发生更新时,就会通过小程序的 setData更新上面小程序的 JSON 树状数据

那么,剩下最后一个问题,现在我们知道了,小程序实例上有了一个 JSON 的树状对象,如何渲染成小程序的页面呢?

从 JSON 数据到小程序渲染

如果在浏览器环境下,这个问题非常简单,JavaScript 可以直接创建 DOM 节点,只要我们实现使用递归,便可完成从 VNodeDOM 的还原,渲染代码如下:

但在小程序环境中,不支持直接创建 DOM ,仅支持模板渲染,该如何处理?

上文中,我们讲到类 Vue 的小程序框架的模板是从 Vue 的 template 部分转成的;

类 React 的运行时小程序框架,jsx 很难转成模板,只有一个 Vnode 节点组成的镜像树。

如果我们去看 Remax 打包之后的模板代码,也会发现空空如也,只有三行代码,第一行引用了一个 base.wxml 文件,第二行是一个叫 REMAX_TPL 的模板

<template is="REMAX_TPL" data={{root: root}}>  </template>复制代码

第二行代码表示使用 REMAX_TPL 模板,传入的数据是 root, root 是小程序实例上维护的数据,就是上面我们提到的小程序的 JSON 树状数据,每一个节点上保存了一些信息。

我们来看 base.wxml 里面是什么内容,发现 base.wxml 内容超级多,有3000多行。如下图:

这个 base.wxml 文件是固定的,每一次打包都会生成那么代码,代码中定义了好几种的小程序的 template 类型,然后重复定义了好几遍,只是 name 名字的值不同。这是为了兼容某一些小程序平台不允许 <template> 组件自己嵌套自己,用来模拟递归嵌套的。

我们回到刚才的那一行代码,有一个名字是 REMAX_TPL 的模板组件。

<template is="REMAX_TPL" data={{root: root}}>  </template>复制代码

REMAX_TPL 的模板组件定义在base.wxml 里面,如下所示:

上面代码,首先遍历了 root 数据中的 children 数组,遍历到每一项的话,用名字是 REMAX_TPL_1_CONTAINER 的模板组件继续渲染数据中的 root.[item] 属性

REMAX_TPL_1_CONTAINER 的模板组件的定义,其实是用当前数据的节点的类型——也就是调用 _h.tid(i.type, a) 方法来算出节点类型,可能是 text, button ——找到节点类型对应的 template 模板,再次递归的遍历下去。

_h.tid 的方法定义如下,其实就是拼接了两个值:1. 递归的深度deep的值,2. 节点的 type

可以看到,Remax 会根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。

在第一次 mount 时,Remax 运行时初始化时会通过小程序的 setData 初始化小程序的 JSON 树状数据, 在小程序加载完毕后, Remax 通过递归模板的形式,把JSON 树状数据渲染为小程序的页面,用户就可以看到页面啦。

然后,Remax 运行时在数据发生更新时,就会通过小程序的 setData更新上面小程序的 JSON 树状数据, JSON 树状数据被更新了,小程序自然会触发更新数据对应的那块视图的渲染。

Remax 创造性的用递归模板的方式,用相对静态的小程序模板语言实现了动态的模板渲染的特性。

3

总结

看到这里,我们已经对 remax 这种类 react 的跨端框架整体流程有了大概的了解

Taro Next 的实现原理

Taro Next 的原理和 Remax 是很像的,这里我就偷懒一下,直接把 Taro 团队在 GMTC大会上的 ppt 贴过来了,高清版本的 ppt 可以点击这个链接下载:程帅-小程序跨框架开发的探索与实践-GMTC 终稿.pdf

下面发现和 remax 是很像的。

Taro 团队实现了 taro-react 包,用来连接 react-reconcilertaro-runtime 的 BOM/DOM API

Taro-react 就做了两件事情:

  1. 实现 hostConfig 配置,我们上面已经介绍过了
  2. 实现render函数(类似于ReactDOM.render)方法,我们上面也已经介绍过了

在更新的过程中,同样是在 appendChild、 insertBefore、removeChild 这些方法里面调用了 enqueueUpdate 方法(人家 remax 叫updateQueue)

渲染的话,和 Remax 的做法一样,基于组件的 template 动态 “递归” 渲染整棵树。

具体流程为先去遍历 Taro DOM Tree( 对应 Remax 中叫镜像树 )根节点的子元素,再根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。

基本上和 remax 一样,换汤不换药。

本文分享自微信公众号 - 极乐技术社区(wxapp-union),作者:小程序公社

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-10-24

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 小程序开发框架对比(wepy/mpvue/uni-app/taro)

    uni-app 团队投入两周完成了这个深度评测,下面我们就分享下,实际开发不同框架的测试例时遇到的问题,以及在各端的兼容测试结果。在本文里,我们团队基于真实测...

    码客张
  • 【2万字长文】深入浅出主流的几款小程序跨端框架原理

    https://juejin.im/post/6881597846307635214

    桃翁
  • 2019年,Flutter 和 React Native 谁主沉浮?

    本文是帮助你了解这两个应用程序开发框架区别指南。咱们知道,几年前开发和维护iOS和Android的应用程序曾经是一项艰巨的任务(独立的代码库|独立的开发团队|开...

    前端小智@大迁世界
  • 真实测评揭秘:开发小程序用原生还是选框架?

    自 2017-1-9微信小程序诞生以来,历经几年的迭代升级,已有数百万小程序上线,成为继Web、iOS、Android之后,第四大主流开发技术。

    极乐君
  • 深入分析小程序主流跨端框架原理(一)

    我们将从框架的语法这两个维度进行讲解。第一部主讲Vue跨端框架,第二部主讲类 React 跨端框架。

    极乐君
  • 最火移动端跨平台方案盘点

    跨平台一直是老生常谈的话题,cordova、ionic、react-native、weex、kotlin-native、flutter等跨平台框架的百花齐放,颇...

    JackJiang
  • 最火移动端跨平台方案盘点:React Native、weex、Flutter

    跨平台一直是老生常谈的话题,cordova、ionic、react-native、weex、kotlin-native、flutter等跨平台框架的百花齐放,颇...

    JackJiang
  • 热门跨平台方案对比:WEEX、React Native、Flutter和PWA

    本文主要对WEEX、React Native、Flutter和PWA几大热门跨平台方案进行简单的介绍和对比。内容选自《WEEX跨平台开发实战》 (WEEX项目负...

    博文视点Broadview
  • 美团民宿跨端复用框架设计与实践

    从 PC 时代、移动时代到万物互联的 IoT 时代,伴随终端设备的日趋多样化,跨端复用的种子自此落地,开始生根发芽。从依靠容器能力、各类离线化预装包的 Hybr...

    前端森林
  • 程序员,2017年你的技能树上增加了哪些新技能?

    每一年,我们都在学习新的东西;每一年,我们都想学习新的技术;每一年,我们都要接触新的技术。那么,2017 年你 GET 到什么技能呢? ? 这一年里,在工作上,...

    Phodal
  • 跨平台解决方案的技术分析

    近 20 年是中国互联网蓬勃发展的时代,以 2010 年为界限,前 10 年是 PC 互联网时代,PC 互联网时代培养了国民上网冲浪的用户习惯,为后 10 多年...

    winty
  • 原生小程序怎样跨平台实现(微信/支付宝/百度)?

    而这繁荣的背后也显得杂乱,影响开发者选择适合的技术方案。基于此,我们做了一次小程序跨平台开发方向的调研,并得出如下建议:

    极乐君
  • React Native 的未来与React Hooks

    近期和一些朋友聊到了 React-Native 的官方重构状态,而刚好近期发布的 0.59.x 系列版本中,上层设计出现了比较大的调整,结合体验之后的状态,就想...

    GSYTech
  • Ionic vs React Native: 移动开发哪家强 ?

    选择合适的平台是开发人员在创建移动应用程序时面临的主要问题之一。据统计,iOS 和 Android 两大巨头已经有超过了十年的竞争。为了从软件开发的预算效益和时...

    IT派
  • 小程序跨端开发框架深度横评之2020版

    这一年,小程序在用户规模及商业化方面都取得了极大的成功。微信小程序日活超过3亿,支付宝、百度、字节跳动小程序的月活也纷纷超过3亿。

    CHB
  • 在应用开发中,我为什么选择 Flutter 而不是 React Native ?

    作为一位开发人员,我想在本文中与大家聊聊跨平台开发领域的两大核心选项——Flutter 与 React Native 框架,并介绍我自己为什么更偏爱 Flutt...

    逆锋起笔
  • 【前端】wepy/mpvue/taro/uni-app,多端开发框架哪家强?

    ⭕ 表示支持且功能正常,❌ 表示不支持,其它则表示支持但存在部分bug或兼容问题 wepy 2.0 宣称版已支持其他家小程序,本测试基于wepy官网指引安装的...

    瑞新
  • 最新前沿:2019 年大前端技术趋势分析

    一晃眼 2019 年已过大半,年初信誓旦旦要学习新技能的小伙伴们立的 flag 都完成的怎样了?2019 年对于大前端技术领域而言变化不算太大,目前三大技术框架...

    用户4962466
  • 10个最受欢迎的 JavaScript 框架,以及它们的主要特征和功能

    多年来,业界已经发布了大量 JavaScript 框架,怎样进行选择可能是一个挑战。如果你感到困惑,不知道应该选哪个或者究竟哪个适合你,那么我已经帮你解决了问题...

    用户5827212

扫码关注云+社区

领取腾讯云代金券