绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
Taro Next 架构揭秘 | GMTC《小程序跨框架开发的探索与实践》万字无删减
2020-01-02 12:42:51

前言:随着小程序开发的热度上升,小程序开发框架也层出不穷。但目前每个框架都会绑定一个专属 DSL,如类 React 或者类 Vue,在一个框架内,开发者无法根据团队技术栈自由选择 DSL,同时也无法共享框架本身的生态与工具。

本次分享将为大家介绍 Taro 如何将各种语法的前端框架(如:React/Vue等)运行在小程序上,讨论一个框架支持多 DSL 的实现探索,使得开发者可以使用任意热门框架/语法/DSL 来编写小程序应用,同时复用相关生态。

小程序开发的历程

2017 年 1 月 9 日凌晨,万众期待的微信小程序正式上线。

在此之前,京东投入一个前端小团队,经过一个月的封闭式开发,以一周一个版本的速度进行迭代,终于在时间发布了自己的 「京东购物」 小程序,尽管功能和界面现在看起来有些简陋,但在当时是完全符合微信小程序「触手可及,用完即走」的理念。

image

当然,随着整个项目的不断迭代,现在的 「京东购物」小程序在设计、交互以及功能复杂度已经全面向 APP 端看齐,这里面的工程化实践已经由 刘慧敏 老师在 GMTC 全球大前端技术大会(北京站)2019 进行过分享,有兴趣的可以下载 PPT:京东购物小程序工程化之路

当时的微信小程序的开发存在一些缺点,比如依赖管理混乱、工程化流程落后、ES Next 支持不完善、命名规范不统一等。这些问题在现在看来都已经有了各种官方或非官方的解决办法,但是在当时小程序开发的探索阶段,这些问题都是一些痛点问题。

有句话我个人特别喜欢,那就是「当一门语言的能力不足,而用户的运行环境又不支持其它选择的时候,这门语言就会沦为 “编译目标” 语言」。

纵观整个前端的历史,无论是 CSS 预处理器的大行其道、各种模版的流行,还是 CoffeeScript 乃至 *cript 的诞生,都印证了这个说法,微信小程序这里也不例外。因此,各种小程序开发框架如百花齐放,层出不穷。

image

这些小程序开发框架主要的区别是 DSL,这点从 logo 颜色上就可以看出来,除了滴滴的 Chameleon 是自定义 DSL 外,其余的绿色的 logo 是遵循了 Vue 语法(如 mpvue ),蓝色的 logo 是遵循了 React 的语法(如 Taro)。

在微信小程序之后,各大厂商纷纷发布了自己的小程序平台,比如:支付宝、百度、头条、QQ等,再加上快应用、网易、360、京东等,小程序的赛道越来越拥挤,开发人员需要适配的小程序平台越来越多,因此,各大小程序开发框架也纷纷进行了多端适配。

image

因此,站在这个时间节点反过来回顾整个小程序开发框架的进程,你会发现整个 2018 年乃至 2019 年初,小程序的开发框架主要的区别和重心在于:DSL 以及 多端适配

Taro 的起源与初心

正所谓「业务孵化技术,技术服务业务」,Taro 的诞生源自于业务需求的增加,当时我们的团队需要同时负责:京东购物,TOPLIFE 等业。团队人力资源捉襟见肘,与此同时,以上的业务都或多或少存在多端的需求,比如 微信小程序、H5、React Native(京东的主流 APP基本都内置了 React Native 渲染引擎),而且可以预见的是,以后很有可能需要适配更多的小程序平台,而每个端开发一套代码又不现实,会导致:研发成本上升,代码维护困难

当时我们团队自研了一款 类React 框架:Nervjs, 整个团队的技术栈因此全部转向了 React ,而当时市面上又没有一款遵循 React 语法的小程序框架,因此,我们开发了 Taro,希望能够使用 React 语法写小程序的同时,通过「Write once Run anywhere」来实现跨端的。

image

整个 Taro 框架从 2018 年 6 月 7 日开源至今,一致保持着高速迭代,这些迭代主要集中在三个方面:

image

经过团队 一年多的努力,Taro 得到了社区的广泛认可,截止 2019年 12 月 18日,Taro 已拥有 22254 Stars 和 250 名 Contributors,社区主动提交的开发案例 150+:taro-user-cases,其中不乏多端案例。

但是尽管如此,Taro 还是存在一些问题无法解决,或者说:没那么好解决。比如:和 React DSL 强绑定、JSX 适配工作量大、社区贡献复杂等。这些问题归根到底,很大一部分是 Taro 的架构问题。

image

因此我们团队也一直在等待一次合适的机会,对整个架构进行一次提升,同时修复一些项目快速迭代欠下的技术债。

主要的是,单纯的项目维护迭代已经满足不了我们团队躁动的心,我们渴望借此机会进行一次技术突破。

小程序跨框架开发的探索

在讲 Taro 架构之前,我们先来回顾一下小程序的架构。

微信小程序主要分为 逻辑层视图层,以及在他们之下的原生部分。逻辑层主要负责 JS 运行,视图层主要负责页面的渲染,它们之间主要通过 EventData 进行通信,同时通过 JSBridge 调用原生的 API。这也是以微信小程序为首的大多数小程序的架构。

image

由于原生部分对于前端开发者来说就像是一个黑盒,因此,整个架构图的原生部分可以省略。同时,我们我们对 逻辑层 和 视图层 也做一下简化,后可以得到小程序架构图的极简版:

image

也就是说,只需要在逻辑层调用对应的 App()/Page() 方法,且在方法里面处理 data、提供生命周期/事件函数等,同时在视图层提供对应的模版及样式供渲染就能运行小程序了。这也是大多数小程序开发框架重点考虑和处理的部分。

Taro 当前架构

Taro 当前的架构主要分为:编译时运行时

其中编译时主要是将 Taro 代码通过 Babel 转换成 小程序的代码,如:JSWXMLWXSSJSON

运行时主要是进行一些:生命周期、事件、data 等部分的处理和对接。

image

Taro 编译时

有过 Babel 插件开发经验的应该对一下流程十分熟悉,Taro 的编译时也是遵循了此流程,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-* 对抽象语法树进行一系列修改、转换操作,后再通过 babel-generate 生成对应的目标代码。

详情可以参考:babel-handbook

image

整个编译时复杂的部分在于 JSX 编译。

我们都知道 JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。这里我们是采用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法

但尽管如此,我们也不可能完全覆盖所有的情况,因此还是推荐大家按照官方规范书写 React 代码,同时,我们也提供了丰富的 ESlint 插件来辅助大家书写规范的代码。

image

这一块我们团队内部一直有个梗:如果你使用 Taro 开发感觉 Bug 少,那说明你的 React 代码写得很规范

Taro 运行时

接下来,我们可以对比一下编译后的代码,可以发现,编译后的代码中,React 的核心 render 方法 没有了。同时代码里增加了 BaseComponentcreateComponent ,它们是 Taro 运行时的核心。

// 编译前
import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.scss'

export default class Index extends Component {

  config = {
    navigationBarTitleText'首页'
  }

  componentDidMount () { }

  render () {
    return (
      <View className=‘index' onClick={this.onClick}>
        <Text>Hello world!</Text>
      </View>
    )
  }
}

// 编译后
import {BaseComponent, createComponent} from '@tarojs/taro-weapp'

class Index extends BaseComponent {

// ... 

  _createDate(){
    //process state and props
  }
}

export default createComponent(Index)

复制代码

BaseComponent 大概的 UML 图如下,主要是对 React 的一些核心方法:setStateforceUpdate 等进行了替换和重写,结合前面编译后 render 方法被替换,大家不难猜出:Taro 当前架构只是在开发时遵循了 React 的语法,在代码编译之后实际运行时,和 React 并没有关系

image

createComponent 主要作用是调用 Component() 构建页面;对接事件、生命周期等;进行 Diff Data 并调用 setData 方法更新数据。

总结

因此,整个 Taro 当前架构的特点是:

  • 重编译时,轻运行时:这从两边代码行数的对比就可见一斑。
  • 编译后代码与 React 无关:Taro 只是在开发时遵循了 React 的语法。
  • 直接使用 Babel 进行编译:这也导致当前 Taro 在工程化和插件方面的羸弱。

image

其它解决方案的架构

小程序开发框架百花齐放,我们也从社区里得到了不少启发。

接下来我们来看看 遵循 vue 语法的小程序开发框架的代表:mpvue 是怎样实现的。

image

看过 Vue 源码的同学对上面的文件夹和架构肯定熟悉,本质上,mpvue 就是 fork 了一份 vuejs/vue@2.4.1 的代码,保留了 Vue runtime 能力,同时添加了小程序平台的支持。

具体在源码中的表现就是:在 Vue 源码的 platforms 文件夹下面增加了 mp 目录,在里面实现了 complier(编译时)runtime (运行时)支持。

mpvue 的实现同样分为:编译时运行时

mpvue 编译时

其中编译时做的事情和 Taro 很类似:将 Vue SFC 写法的代码编译成 小程序代码文件(JS、WXML、WXSS、JSON)。

大的区别是 Taro 将 JSX 编译成 小程序模版,而 mpvue 是将 Vue 模版编译成 小程序模版。但是由于 Vue 模版和 小程序模版的相似性,mpvue 在这一块的工作量比 Taro 少得多。

image

mpvue 运行时

而 mpvue 的运行时和 Vue 的运行时是强关联的,首先我们来看看 Vue 的运行时。

一个 .vue 的单文件由三部分构成: template, script, style

橙色路径部分, template 会在编译的过程中,在 vue-loader 中通过 ast 进行分析,终生成一段 render 函数,执行 render 函数会生成虚拟dom树,虚拟 DOM 树是对真实 DOM 树的抽象,树中的节点被称作 vnode 。

Vue 拿到 虚拟 DOM 树之后,就可以去和上次老的 虚拟 DOM 树 做 patch diff 对比。patch 阶段之后,vue 就会使用真实的操作DOM 的方法(比如说 insertBefore , appendChild 之类的),去操作DOM结点,更新视图。

同时,绿色路径的部分,在实例化 Vue 的时候,会对数据 data 做响应式的处理,在监测到 data 发生改变时,会调用 render 函数,生成新的虚拟 DOM 树, 接着对比老的虚拟 DOM 树进行 patch, 找出小修改代价的 vnode 节点进行修改。

image

而 mpvue 的运行时,会首先将 patch 阶段的 DOM 操作相关方法置空,也就是什么都不做。其次,在创建 Vue 实例的同时,还会偷偷的调用 Page() 用于生成了小程序的 page 实例。然后 运行时的 patch 阶段会直接调用 $updateDataToMp() 方法,这个方法会获取挂在在 page 实例上维护的数据 ,然后通过 setData 方法更新到视图层。

mpvue 整体原理图也就如下:

image

一些总结与思考

因此,和 Taro 重编译时轻运行时不同,mpvue 算是:半编译时,半运行时。这点从代码量的对比也能大致反映出来。

mpvue 的 WXML 模版和 Taro 一样,也是通过代码编译得到的;不同于 Taro 运行时和 React 无关,mpvue 本质上还是将 Vue 运行在了小程序,且实现了 Vue@2.4.1 绝大部分特性(只有极少数特性由于小程序模版的限制未能实现,如 :filterslotv-html);且整个框架基于 Webpack 实现了较为完善的工程化。

mage

其他小程序框架的实现原理和效果上的差异性,也带来了我们的一些思考:

  • 编译时 OR 运行时:当初 Taro 选择重编译时的主要原因是处于性能考虑,毕竟同等条件下,编译时做的工作越多,也就意味着运行时做的工作越少,性能会更好;另外,重编译时也保证了 Taro 的代码在编译之后的可读性。但是从长远来看,计算机硬件的性能越来越冗余,如果在牺牲一点可以容忍的性能的情况下换来整个框架更大的灵活性和更好的适配性,我们认为是值得的
  • 模版静态编译 OR 动态构建:尽管 Taro 和 mpvue 的模版都是通过静态编译生成的,但是社区也不乏动态构建的例子,比如:Remax
  • DSL 限制:我们能否实现一个小程序开发框架,摆脱 DSL 的限制?

新架构 Taro Next 的适配与实现

这一次,我们站在浏览器的角度来思考前端的本质:无论开发这是用的是什么框架,React 也好,Vue 也罢,终代码经过运行之后都是调用了浏览器的那几个 BOM/DOM 的 API ,如:createElementappendChildremoveChild 等。

image

因此,我们创建了 taro-runtime 的包,然后在这个包中实现了 一套 高效、精简版的 DOM/BOM API(下面的 UML 图只是反映了几个主要的类的结构和关系):

image

然后,我们通过 Webpack 的 ProvidePlugin 插件,注入到小程序的逻辑层。

image

这样,在小程序的运行时,就有了 一套高效、精简版的 DOM/BOM API

React 实现

DOM/BOM 注入之后,理论上来说,Nerv/Preact 就可以直接运行了。但是 React 有点特殊,因为 React-DOM 包含大量浏览器兼容类的代码,导致包太大,而这部分代码我们是不需要的,因此我们需要做一些定制和优化。

在 React 16+ ,React 的架构如下:

image

上层是 React 的核心部分 react-core ,中间是 react-reconciler,其的职责是维护 VirtualDOM 树,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。

Renderer 负责具体平台的渲染工作,它会提供宿主组件、处理事件等等。例如 React-DOM 就是一个渲染器,负责 DOM 节点的渲染和 DOM 事件处理。

因此,我们实现了 taro-react 包,用来连接 react-reconcilertaro-runtime 的 BOM/DOM API:

image

具体的实现主要分为两步:

  1. 实现 react-reconcilerhostConfig 配置,即在 hostConfig 的方法中调用对应的 Taro BOM/DOM 的 API。
  2. 实现 render 函数(类似于 ReactDOM.render)方法,可以看成是创建 Taro DOM Tree 的容器。

image

经过上面的步骤,React 代码实际上就可以在小程序的运行时正常运行了,并且会生成 Taro DOM Tree,那么偌大的 Taro DOM Tree 怎样更新到页面呢?

首先,我们将小程序的所有组件挨个进行模版化处理,从而得到小程序组件对应的模版,如下图就是小程序的 view 组件经过模版化处理后的样子:

image

然后,我们会:基于组件的 template,动态 “递归” 渲染整棵树

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

image

整个 Taro Next 的 React 实现流程图如下:

image

Vue 实现

别看 React 和 Vue 在开发时区别那么大,其实在实现了 BOM/DOM API 之后,它们之间的区别就很小了。

Vue 和 React 大的区别就在于运行时的 CreateVuePage 方法,这个方法里进行了一些运行时的处理,比如:生命周期的对齐。

image

其他的部分,如通过 BOM/DOM 方法构建、修改 DOM Tree 及渲染原理,都是和 React 一致的。

Flutter 实现

提到 Flutter ,就不得不提 Flutter WebFlutter Web 是在标准浏览器 API 之上实现 Flutter 的核心绘图层,本质上也是终调用了 BOM/DOM API。因此,理论来说,也是可以进行适配的,但这一块我们并不会投入太多的精力,终会像快应用一样交给社区来实现和维护。

image

更多细节

接下来和大家展开聊一下 Taro Next 更多的细节实现,比如:事件、更新、生命周期。

事件

首先的 Taro Next 事件,具体的实现方式如下:

  1. 在 小程序组件的模版化过程中,将所有事件方法全部指定为 调用 ev 函数,如:bindtapbindchangebindsubmit 等。
  2. 在 运行时实现 eventHandler 函数,和 eh 方法绑定,收集所有的小程序事件
  3. 通过 document.getElementById() 方法获取触发事件对应的 TaroNode
  4. 通过 createEvent() 创建符合规范的 TaroEvent
  5. 调用 TaroNode.dispatchEvent 重新触发事件

image

可以看到,Taro Next 事件本质上是基于 Taro DOM 实现了一套自己的事件机制,这样做的好处之一是,无论小程序是否支持事件的冒泡与捕获,Taro 都能支持。

更新

无论是 React 还是 Vue ,终都会调用 Taro DOM 方法,如:appendChildinsertChild 等。

这些方法在修改 Taro DOM Tree 的同时,还会调用 enqueueUpdate 方法,这个方法能获取到每一个 DOM 方法终修改的节点路径和值,如:{root.cn.[0].cn.[4].value: "1"},并通过 setData 方法更新到视图层。

image

可以看到,这里更新的粒度是 DOM 级别,只有终发生改变的 DOM 才会被更新过去,相对于之前 data 级别的更新会更加精准,性能更好。

生命周期

相对与其他部分大刀阔斧的升级改造,生命周期可能是变动小的部分之一。和之前类似,生命周期的实现是在运行时维护的 App 实例 / Page 实例进行了生命周期方法的一一对应。

const config: PageInstance = {
  onLoad (this: MpInstance, options) {
    //...
  },
  onUnload () {
    //...
  },
  onShow () {
    safeExecute('onShow')
  },
  onHide () {
    safeExecute('onHide')
  },
  onPullDownRefresh () {
    safeExecute('onPullDownRefresh')
  }
  //...
}

复制代码

新架构特点

和之前的架构不同,Taro Next 是 近乎全运行

新的架构基本解决了之前的遗留问题:

  • 无 DSL 限制:无论是你们团队是 React 还是 Vue 技术栈,都能够使用 Taro 开发
  • 模版动态构建:和之前模版通过编译生成的不同,Taro Next 的模版是固定的,然后基于组件的 template,动态 “递归” 渲染整棵 Taro DOM 树。
  • 新特性无缝支持:由于 Taro Next 本质上是将 React/Vue 运行在小程序上,因此,各种新特性也就无缝支持了。
  • 社区贡献更简单:错误栈将和 React/Vue 一致,团队只需要维护核心的 taro-runtime。
  • 基于 Webpack:Taro Next 基于 Webpack 实现了多端的工程化,提供了插件功能。

性能优化

前面提到,同等条件下,编译时做的工作越多,也就意味着运行时做的工作越少,性能会更好。Taro Next 的新架构变成 近乎全运行 之后,花了很多精力在性能优化上面。

再这之前。可以先看一下 Taro Next 的流程和原生小程序的流程对比。

image

可以发现,相比原生小程序,Taro Next 多了红色部分的带来的性能隐患,如:引入React/Vue 带来的 包的 Size 增加,运行时的损耗、Taro DOM Tree 的构建和更新、DOM data 初始化和更新。

而我们真正能做的,只有绿色部分,也就是:Taro DOM Tree 的构建和更新DOM data 初始化和更新

Size

首先我们来看包 Size,下面的表格是 TodoMVC 的例子,在原生、Taro Old、Taro Next 等情况下的包大小对比,可以看到,引入 React/Vue 后,包大小在 Gzip 情况下大概增加了 30k 左右。

image

不过我们在前面一再强调:和之前模版通过编译生成的不同,Taro Next 的模版是固定的,然后基于组件的 template,动态 “递归” 渲染整棵 Taro DOM 树。也就是说,Taro Next 的 WXML 大小是有上限的

随着项目的增加,页面越来越多,原生的项目 WXML 体积会不断增加,而 Taro Next 不会。也就是说,当页面的数量超过一个临界点时,Taro Next 的包体积可能会更小。因此,包 Size 的问题不足为虑。

image

DOM Tree

在 Taro DOM Tree 的构建和更新阶段,我们实现了一套仅实现了高效的、精简版 DOM/BOM API,而且仅仅实现了必要的。

Github上有一个仓库 jsdom,基本上是在 Node.js 上实现了一套 Web 标准的 DOM/BOM ,这个仓库的代码在压缩前大概有 2.1M,而 Taro Next 的核心的 DOM/BOM API 代码才 1000 行不到。

因此,我们大限度的保证了 Taro DOM Tree 构建和更新阶段的性能。

image

Update Date

在数据更新阶段,首先前面有提到过,Taro Next 的更新是 DOM 级别的,比 Data 级别的更新更加高效,因为 Data 粒度更新实际上是有冗余的,并不是所有的 Data 的改变后都会引起 DOM 的更新

其次,Taro 在更新的时候将 Taro DOM Tree 的 path 进行压缩,这点也极大的提升了性能。

image

终的结果是:在某些业务场景写,addselect 数据,Taro Next 的性能比原生的还要好。

taro-benchmark

当然,实验的数据总归会有缺陷,终具体的性能表现,还要靠各种复杂业务场景的检验。大家如果对 Taro Next 的性能感兴趣的,可以自行跑一下 taro-benchmark 包,对比一下结果。

我们也在一直持续的全方位优化 Taro Next 的性能,具体可以关注 Taro Next 的新的 Commit 。

总结及展望

Taro 未来规划

Taro Next 将会在不久之后的 3.0 版本正式发布,支持使用 React/Vue 开发跨端小程序,然后在会在后续的迭代中拓展至其他端,并完善对应的生态。

image

Taro 团队还是会将支持的重点放在 React/Vue,Flutter 和 Angular 会像快应用一样,交给社区来适配和维护,快应用就是华为的 Qiyu8Issacpeng 在帮我们进行适配,非常感谢他们。

同时,我们还打造了 「Taro 移动端一站式研发平台」,将先前积累的多端开发工作流和工程化的方案进行了统一,并内置了数据监控、组件市场以及可视化搭建,当前正处于内测阶段。

image

一点思考

  • 业务孵化技术,技术服务业务:这也是整个 Taro 项目从创建到迭代至今重要的、感受深的一点。
  • 自上而下 OR 自下而上:从开发者的角度自上而下看,React/Vue 的代码书写方式差异挺大的;然而站在浏览器的角度自下而上的看,它们的差别其实没那么大,都是调用了 BOM/DOM 那几个常用的 API。如果我们再往底层一点,站在渲染层的角度,不同平台之间的差异会不会也没那么大?比如:Flutter。
  • Learn Once Write AnyWhere & Write Once Run AnyWhere:很多开发者更喜欢 React 提出的Learn Once Write AnyWhere,而我们 Taro 的口号是 Write Once Run AnyWhere,这一点也经常导致我们经常被人喷,这里说一点我自己的想法:Learn Once Write AnyWhere其实本质上对开发者更友好,比如开发者只需要学习 React 技术栈,就可以开发 Web/移动端 应用,但是对项目就没那么友好了,每个项目都得维护一份代码;而 Write Once Run AnyWhere 是对开发者没那么友好(适配的端越多,适配的成本必然也会水涨船高,对开发者要求也很变高),但是根据我们的实践,对项目会更友好,「一套代码,多端适配」。当然,这里适配的粒度,并不一定是项目级别的,其实在我们的具体实践中,有相当一部分是:业务级甚至是页面级的

image

写在后

正所谓「单丝不成线,独木不成林」,Taro 发展至今早已不在属于单一团队的项目了,而是整个 Taro 开发社区共同的项目。

后,还是借此机会感谢一些社区所有帮助过 Taro 的成长的人,特别是 Taro 的贡献者们,非常感谢!

image

同时也感谢受邀成为 TaroUI 核心维护人员的 Garfield550 (小姐姐)、梁音ShaoQian Liu,他们将支撑起 TaroUI 的后续迭代与维护。

当然还有在社区中乐于助人、积极贡献的 zacksleoJay Fongloveonelonglolipop99波仔糕原罪lentoo白领夏公子YuanQuantourzelingxiaoZhu 等等。

此外,还要感谢一直默默为 Taro 发展提供宝贵建议的研发团队:腾讯云、数字广东、腾讯CDC、网易严选、华为开源团队、招联消费金融等等。

长风破浪会有时,直挂云帆济沧海。

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

image

分享好友

分享这个小栈给你的朋友们,一起进步吧。

代码实验室
创建时间:2020-06-10 13:46:12
小栈里面各种代码实验的产物。看看各种内容放一起有什么反应吧
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

栈主、嘉宾

查看更多
  • emanda
    栈主

小栈成员

查看更多
  • 嗷嗷嗷安安
戳我,来吐槽~