组件化

回忆

????????首先,render函数中手写h=>h(app),new Vue()实例初始化init()和原来一样。$mount执行到第一个$mount,判断有无render函数,没有就生成render函数,这里我们是有的。执行第二个$mount,调用mountComponent,到了vm._update(vm._render(), hydrating)。

????????下面正式开始组件处理,先是vm._render()生成组件vnode.这个过程主要做了三件事,1、创建组件构造函数(继承于Vue)。2、安装组件钩子函数(在patch流程中触发)。3、组件vnode实例化

? ? ? ? 其中细节,vm._render()会调用vm.$createElement. 其中会判断参数tag,若是普通html标签则实例化一个普通vnode节点,否则调用creatComponent创建组件vnode。1、通过Vue.extend得到一个继承于Vue的组件构造器(组件实例化时会执行_init())。2、遍历组件钩子函数(componentVNodeHooks中),若vnode相关data(即vNodeData)中当前hook存在,把组件hook,merge到当前hook上,不然直接赋值。3、通过new VNode()生成组件vnode并返回。

? ? ? ? 我们通过?createComponent?创建了组件 VNode,接下来会走到?vm._update,执行?vm.__patch__?去把 VNode 转换成真正的 DOM 节点。patch 的过程会调用?createElm?创建元素节点,而createElm中会判断?createComponent,true的话直接返回,只执行createComponent中逻辑。createComponent中会判断是否是组件vnode,是的话?先执行init(vnode,hydrating)。

? ? ? ? init(vnode,hydrating),通过?createComponentInstanceForVnode(vnode, activeInstance)创建一个 继承于Vue 的实例,然后调用?$mount?方法挂载子组件。

????????createComponentInstanceForVnode(vnode, activeInstance)执行new vnode.componentOptions.Ctor(options)(这里的?vnode.componentOptions.Ctor?对应的就是继承于 Vue的子组件的构造函数)。其中options参数,_isComponent?为?true?表示它是一个组件,_parentVnode(new Vue的vnode,其实为$el挂载点的vnode。如果为子组件,为子组件在父组件中的占位符),parent(activeInstance)?表示当前激活的组件实例(即new Vue实例,我们现在 在处理的是new Vue实例中render中的app对象)(注意,这里比较有意思的是如何拿到组件实例,后面会介绍。所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的?_init?方法(在extend时定义的)。

? ? ? ?_init(),和new Vue初始化时有些不同,这里首先是合并?options?的过程有变化,_isComponent?为 true,所以走到了?initInternalComponent?过程。

? ??????initInternalComponent(),?opt即是当前组件app实例的vm.$options。这个过程我们重点记住以下几个点即可:opts.parent = options.parent、opts._parentVnode = parentVnode。它们其实是把之前我们通过?createComponentInstanceForVnode?函数传入的几个参数(vnode,activeInstance)合并到内部的选项?$options?里了。

? ??????_init()最后会执行$mount,由于组件初始化的时候是不传 el?的,因此组件是自己接管了?$mount?的过程,这个过程的主要流程在上一章介绍过了?;氐阶榧?init?的过程,componentVNodeHooks?的?init?钩子函数,在完成实例化的?_init?后,接着会执行?child.$mount(hydrating ? vnode.elm : undefined, hydrating)。这里?hydrating?为 true 一般是服务端渲染的情况,我们只考虑客户端渲染,所以这里?$mount?相当于执行?child.$mount(undefined, false),它最终会调用?mountComponent?方法,进而执行?vm._render()?方法。

? ?????vm._render() 中取到了vm.$options的render和_parentVnode(new Vue的vnode,其实为$el挂载点的vnode),把_parentVnode赋值给vm.$vnode(当前vm实例是app组件实例),通过vm.$creatElement取得组件的vnode(这个app组件的vnode)。通过vnode.parent=_parentVnode建立父子关系。

? ?????我们知道在执行完?vm._render?生成 VNode 后,接下来就要执行?vm._update?去渲染 VNode 了。

????????_update() 过程中有几个关键的代码,首先?vm._vnode = vnode?的逻辑,这个?vnode?是通过?vm._render()?返回的组件渲染 VNode,vm._vnode(组件app实际渲染vnode) 和?vm.$vnode(new Vue的vnode,其实为$el挂载点的vnode) 的关系就是一种父子关系,用代码表达就是?vm._vnode.parent === vm.$vnode。另外,_update()中这个?activeInstance(vm)?作用就是保持当前上下文的 Vue 实例(在处理app组件时,它是new Vue实例,在处理app组件进行_update时它又赋值成当前app组件, 若下面又有组件,把它作为parent传下去),它是在?lifecycle???榈娜直淞? export let activeInstance: any = null),并且在之前我们调用?createComponentInstanceForVnode?方法的时候从?lifecycle???榛袢?/b>,并且作为参数parent传入的。因为实际上 JavaScript 是一个单线程,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。之前我们提到过对子组件的实例化过程先会调用?initInternalComponent(vm, options)?合并?options,把?parent?存储在?vm.$options?中,在 _init()中会调用?initLifecycle(vm)?

?????????initLifecycle(vm)中可以看到?vm.$parent?就是用来保留当前?vm?的父实例,并且通过?parent.$children.push(vm)?来把当前的?vm?存储到父实例的?$children?中。

????????在?vm._update?的过程中,把当前的?vm?赋值给?activeInstance,同时通过?const?prevActiveInstance=activeInstance用?prevActiveInstance?保留上一次的?activeInstance。实际上,prevActiveInstance?和当前的?vm?是一个父子关系,当一个?vm?实例完成它的所有子树的 patch 或者 update 过程后,activeInstance?会回到它的父实例,这样就完美地保证了?createComponentInstanceForVnode?整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在?_init?的过程中,通过?vm.$parent?把这个父子关系保留.

????????那么回到?_update,最后就是调用?__patch__?渲染 VNode 了。这里又回到了本节开始的过程,之前分析过负责渲染成 DOM 的函数是?createElm(),注意这里我们只传了 2 个参数,所以对应的?parentElm?是?undefined。

????????createElm(),这里我们传入的?vnode?是组件渲染的?vnode,也就是我们之前说的?vm._vnode,如果组件的根节点是个普通元素,那么?vm._vnode?也是普通的?vnode,这里?createComponent(vnode, insertedVnodeQueue, parentElm, refElm)?的返回值是 false。? ? ? ?

????????接下来的过程就和我们上一章一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用?createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。

????????由于我们这个时候传入的?parentElm?是空,所以对组件的插入,在?createComponent 中,在完成组件的整个?patch?过程后,最后执行?insert(parentElm, vnode.elm, refElm)?完成组件的 DOM 插入,如果组件?patch?过程中又创建了子组件,那么DOM 的插入顺序是先子后父。

概述:

????????Vue.js 另一个核心思想是组件化。所谓组件化,就是把页面拆分成多个组件 (component),每个组件依赖的 CSS、JavaScript、模板、图片等资源放在一起开发和维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。

????????我们在用 Vue.js 开发实际项目的时候,就是像搭积木一样,编写一堆组件拼装生成页面。在 Vue.js 的官网中,也是花了大篇幅来介绍什么是组件,如何编写组件以及组件拥有的属性和特性。

????????在这一章节,我们将从源码的角度来分析 Vue 的组件内部是如何工作的,只有了解了内部的工作原理,才能让我们使用它的时候更加得心应手。

? ??????接下来我们会用 Vue-cli 初始化的代码为例,来分析一下 Vue 组件初始化的一个过程。

vue-cli初始化代码

????????这段代码相信很多同学都很熟悉,它也是通过?render?函数去渲染的,不同的这次通过?createElement?传的参数是一个组件而不是一个原生的标签,那么接下来我们就开始分析这一过程。

createComponent

? ? ? ? 首先生成vnode?,会先执行render函数,render对于自己写render函数的会执行vm.$createElement ,而它最终会调用?_createElement?方法,其中有一段逻辑是对参数?tag?的判断,如果是一个普通的 html 标签,像上一章的例子那样是一个普通的 div,则会实例化一个普通 VNode 节点,否则通过?createComponent?方法创建一个组件 VNode

?createElement?

????????在组件render函数中传入的是一个 App 对象,它本质上是一个?Component?类型,那么它会走到上述代码的 else 逻辑,直接通过?createComponent?方法来创建?vnode。所以接下来我们来看一下?createComponent?方法的实现,它定义在?src/core/vdom/create-component.js?文件中:

????????createComponent?的逻辑也会有一些复杂,但是分析源码比较推荐的是只分析核心流程,分支流程可以之后针对性的看,所以这里针对组件渲染这个 case 主要就 3 个关键步骤:构造子类构造函数,安装组件钩子函数和实例化?vnode

构造子类构造函数

构造子类构造函数

????????我们在编写一个组件的时候,通常都是创建一个普通对象,还是以我们的 App.vue 为例,代码如下:

例子

????????这里 export 的是一个对象,所以?createComponent?里的代码逻辑会执行到?baseCtor.extend(Ctor),在这里?baseCtor?实际上就是 Vue,这个的定义是在最开始初始化 Vue 的阶段,在?src/core/global-api/index.js?中的?initGlobalAPI?函数有这么一段逻辑:

baseCtor?实际上就是 Vue

????????这里定义的是?Vue.option,而我们的?createComponent?取的是?context.$options,实际上在?src/core/instance/init.js?里 Vue 原型上的?_init?函数中有这么一段逻辑:

?Vue 上的一些?option?扩展到了 vm.$option?

????????这样就把 Vue 上的一些?option?扩展到了 vm.$option 上,所以我们也就能通过?vm.$options._base?拿到 Vue 这个构造函数了。mergeOptions?的实现我们会在后续章节中具体分析,现在只需要理解它的功能是把 Vue 构造函数的?options?和用户传入的?options?做一层合并,到?vm.$options?上。

????????在了解了?baseCtor?指向了 Vue 之后,我们来看一下?Vue.extend?函数的定义,在?src/core/global-api/extend.js?中。

extend

? ??????Vue.extend?的作用就是构造一个?Vue?的子类,它使用一种非常经典的原型继承的方式把一个纯对象转换一个继承于?Vue?的构造器?Sub?并返回,然后对?Sub?这个对象本身扩展了一些属性,如扩展?options、添加全局 API 等;并且对配置中的?props?和?computed?做了初始化工作;最后对于这个?Sub?构造函数做了缓存,避免多次执行?Vue.extend?的时候对同一个子组件重复构造。

????????这样当我们去实例化?Sub?的时候,就会执行?this._init?逻辑再次走到了?Vue?实例的初始化逻辑,实例化子组件的逻辑在之后的章节会介绍。

在extend中定义,实例化sub时会执行this._init

安装组件钩子函数

????????我们之前提到 Vue.js 使用的 Virtual DOM 参考的是开源库?snabbdom,它的一个特点是在 VNode 的 patch 流程中对外暴露了各种时机的钩子函数方便我们做一些额外的事情,Vue.js 也是充分利用这一点,在初始化一个 Component 类型的 VNode 的过程中实现了几个钩子函数:

安装组件钩子
componentVNodeHooks?的钩子函数
安装过程

????????整个?mergeHooks?的过程就是把?componentVNodeHooks?的钩子函数合并到?data.hook?中,在 VNode 执行?patch?的过程中执行相关的钩子函数,具体的执行我们稍后在介绍?patch?过程中会详细介绍。这里要注意的是合并策略,在合并过程中,如果某个时机的钩子已经存在?data.hook?中,那么通过执行?mergeHook?函数做合并,这个逻辑很简单,就是在最终执行的时候,依次执行这两个钩子函数即可。

实例化 VNode

通过?new VNode?实例化一个?vnode?并返回

????????最后一步非常简单,通过?new VNode?实例化一个?vnode?并返回。需要注意的是和普通元素节点的?vnode?不同,组件的?vnode?是没有?children?的,这点很关键,在之后的?patch?过程中我们会再提。

patch

? ? ? ? 当我们通过?createComponent?创建了组件 VNode,接下来会走到?vm._update,执行?vm.__patch__?去把 VNode 转换成真正的 DOM 节点。这个过程我们在前一章已经分析过了,但是针对一个普通的 VNode 节点,接下来我们来看看组件的 VNode 会有哪些不一样的地方。

? ??????patch 的过程会调用?createElm?创建元素节点,回顾一下?createElm?的实现,它的定义在?src/core/vdom/patch.js?中:

createElm

????????我们删掉多余的代码,只保留关键的逻辑,这里会判断?createComponent(vnode, insertedVnodeQueue, parentElm, refElm)?的返回值,如果为?true?则直接结束,那么接下来看一下?createComponent?方法的实现:

createComponent

createComponent

首先对?vnode.data?做了一些判断:

createComponent首先对?vnode.data?做了一些判断

????????如果?vnode?是一个组件 VNode,那么条件会满足,并且得到?i?就是?init?钩子函数,回顾上节我们在创建组件 VNode 的时候合并钩子函数中就包含?init?钩子函数,定义在?src/core/vdom/create-component.js?中:

?init?钩子函数

????????init?钩子函数执行也很简单,我们先不考虑?keepAlive?的情况,它是通过?createComponentInstanceForVnode?创建一个 继承于Vue 的实例,然后调用?$mount?方法挂载子组件,先来看一下?createComponentInstanceForVnode?的实现:

?createComponentInstanceForVnode?

????????createComponentInstanceForVnode?函数构造的一个内部组件的参数,然后执行?new vnode.componentOptions.Ctor(options)。这里的?vnode.componentOptions.Ctor?对应的就是子组件的构造函数,我们上一节分析了它实际上是继承于 Vue 的一个构造器?Sub,相当于?new Sub(options)?这里有几个关键参数要注意几个点,_isComponent?为?true?表示它是一个组件,parent(activeInstance)?表示当前激活的组件实例(即new Vue实例,我们现在 在处理的是new Vue实例中render中的app对象)(注意,这里比较有意思的是如何拿到组件实例,后面会介绍。

????????所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的?_init?方法(在extend时定义的),这个过程有一些和之前不同的地方需要挑出来说,代码在?src/core/instance/init.js?中:

组件实例初始化执行_init.js

????????这里首先是合并?options?的过程有变化,_isComponent?为 true,所以走到了?initInternalComponent?过程,这个函数的实现也简单看一下:

initInternalComponent

? ? ? ? opt即是当前处理实例app的vm.$options。这个过程我们重点记住以下几个点即可:opts.parent = options.parent、opts._parentVnode = parentVnode,它们是把之前我们通过?createComponentInstanceForVnode?函数传入的几个参数(vnode,activeInstance)合并到内部的选项?$options?里了。

? ? ? ? _init最后会进行挂载。

_init挂载

????????由于组件初始化的时候是不传 el 的,因此组件是自己接管了?$mount?的过程,这个过程的主要流程在上一章介绍过了,回到组件?init?的过程,componentVNodeHooks?的?init?钩子函数,在完成实例化的?_init?后,接着会执行?child.$mount(hydrating ? vnode.elm : undefined, hydrating)。这里?hydrating?为 true 一般是服务端渲染的情况,我们只考虑客户端渲染,所以这里?$mount?相当于执行?child.$mount(undefined, false),它最终会调用?mountComponent?方法,进而执行?vm._render()?方法

执行?child.$mount,最终会调用?mountComponent?方法,进而执行?vm._render()?方法
取到了vm.$options的render和_parentVnode,把_parentVnode赋值给vm.$vnode,通过vm.$creatElement取得组件的vnode。通过vnode.parent=_parentVnode建立父子关系

????????我们知道在执行完?vm._render?生成 VNode 后,接下来就要执行?vm._update?去渲染 VNode 了。来看一下组件渲染的过程中有哪些需要注意的,vm._update的定义在?src/core/instance/lifecycle.js?中:

_update

????????_update?过程中有几个关键的代码,首先?vm._vnode = vnode?的逻辑,这个?vnode?是通过?vm._render()?返回的组件渲染 VNode,vm._vnode?和?vm.$vnode?的关系就是一种父子关系,用代码表达就是?vm._vnode.parent === vm.$vnode?;褂幸欢伪冉嫌幸馑嫉拇耄?/p>

_update中的activeInstance即为父实例

????????这个?activeInstance?作用就是保持当前上下文的 Vue 实例,它是在?lifecycle???榈娜直淞浚ㄒ迨?export let activeInstance: any = null,并且在之前我们调用?createComponentInstanceForVnode?方法的时候从?lifecycle?模块获取,并且作为参数传入的。因为实际上 JavaScript 是一个单线程,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。之前我们提到过对子组件的实例化过程先会调用?initInternalComponent(vm, options)?合并?options,把?parent?存储在?vm.$options?中,在?$mount?之前会调用?initLifecycle(vm)?方法:

initLifecycle

????????可以看到?vm.$parent?就是用来保留当前?vm?的父实例,并且通过?parent.$children.push(vm)?来把当前的?vm?存储到父实例的?$children?中。

????????在?vm._update?的过程中,把当前的?vm?赋值给?activeInstance,同时通过?const prevActiveInstance=activeInstance?用?prevActiveInstance?保留上一次的?activeInstance。实际上,prevActiveInstance?和当前的?vm?是一个父子关系,当一个?vm?实例完成它的所有子树的 patch 或者 update 过程后,activeInstance?会回到它的父实例,这样就完美地保证了?createComponentInstanceForVnode?整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在?_init?的过程中,通过?vm.$parent?把这个父子关系保留.

那么回到?_update,最后就是调用?__patch__?渲染 VNode 了。

这里又回到了本节开始的过程,之前分析过负责渲染成 DOM 的函数是?createElm,注意这里我们只传了 2 个参数,所以对应的?parentElm?是?undefined。

我们再来看看它的定义:

回顾creatElm

????????注意,这里我们传入的?vnode?是组件渲染的?vnode,也就是我们之前说的?vm._vnode,如果组件的根节点是个普通元素,那么?vm._vnode?也是普通的?vnode,这里?createComponent(vnode, insertedVnodeQueue, parentElm, refElm)?的返回值是 false。

????????接下来的过程就和我们上一章一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用?createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。

????????由于我们这个时“候传入的?parentElm?是空,所以对组件的插入,在?createComponent?有这么一段逻辑:

在完成组件的整个?patch?过程后,最后执行?insert(parentElm, vnode.elm, refElm)?完成组件的 DOM 插入,如果组件?patch?过程中又创建了子组件,那么DOM 的插入顺序是先子后父。

总结

????????那么到此,一个组件的 VNode 是如何创建、初始化、渲染的过程也就介绍完毕了。在对组件化的实现有一个大概了解后,接下来我们来介绍一下这其中的一些细节。我们知道编写一个组件实际上是编写一个 JavaScript 对象,对象的描述就是各种配置,之前我们提到在?_init?的最初阶段执行的就是?merge options?的逻辑,那么下一节我们从源码角度来分析合并配置的过程。

?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,992评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,212评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事?!?“怎么了?”我有些...
    开封第一讲书人阅读 159,535评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,197评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,310评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,383评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,409评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,191评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,621评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,910评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,084评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,763评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,403评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,083评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,318评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,946评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,967评论 2 351

推荐阅读更多精彩内容

  • 本文章是我最近在公司的一场内部分享的内容。我有个习惯就是每次分享都会先将要分享的内容写成文章。所以这个文集也是用来...
    Awey阅读 9,437评论 4 67
  • 什么是组件? 组件 (Component) 是 Vue.js 最强大的功能之一。组件可以扩展 HTML 元素,封装...
    youins阅读 9,470评论 0 13
  • 这篇笔记主要包含 Vue 2 不同于 Vue 1 或者特有的内容,还有我对于 Vue 1.0 印象不深的内容。关于...
    云之外阅读 5,048评论 0 29
  • [文/原创]阿九的树屋 今天我接到一位来树屋的客人。他同我讲已经高三了,离高考只剩107天,压力很大。不得不说我也...
    阿九的树屋阅读 1,014评论 12 24
  • 媒体报道:6月29日晚8点41分许,河南省驻马店汽车运输公司豫Q52298号大型客车,由南往北行驶至京港澳高速衡东...
    大树的森林阅读 274评论 0 0