清晖

清晖 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

认识生活的真相后仍然热爱生活,是真正的英雄

个人动态

清晖 关注了用户 · 1月13日

皮小蛋 @scaukk

The Best Way to Improve Yourself:

  1. Build Stuffs
  2. Help Others
  3. Teach

PS: Shopee 招人, 薪酬福利待遇好

感兴趣的话, 可以联系我内推。

关注 799

清晖 收藏了文章 · 1月13日

全面了解 React 新功能: Suspense 和 Hooks

悄悄的, React v16.7 发布了。 React v16.7: No, This Is Not The One With Hooks.

clipboard.png

最近我也一直在关注这两个功能,也听了程墨大佬的React讲座,十分受用,就花些时间就整理了一下, 在此分享给大家, 希望对大家有所帮助。


引子

为什么不推荐在 componentwillmount 里最获取数据的操作呢?

这个问题被过问很多遍了, 前几天又讨论到这个问题, 就以这个作为切入点吧。

有些朋友可能会想, 数据早点获取回来,页面就能快点渲染出来呀, 提升用户体验, 何乐而为不为?

这个问题, 简单回答起来就是, 因为是可能会调用多次

要深入回答这个问题, 就不得不提到一个React 的核心概念: React Fiber.

一些必须要先了解的背景

React Fiber

React Fiber 是在 v16 的时候引入的一个全新架构, 旨在解决异步渲染问题。

新的架构使得使得 React 用异步渲染成为可能,但要注意,这个改变只是让异步渲染成为可能

但是React 却并没有在 v16 发布的时候立刻开启,也就是说,React 在 v16 发布之后依然使用的是同步渲染

不过,虽然异步渲染没有立刻采用,Fiber 架构还是打开了通向新世界的大门,React v16 一系列新功能几乎都是基于 Fiber 架构。

说到这, 也要说一下 同步渲染异步渲染.

同步渲染 和 异步渲染

同步渲染

我们都知道React 是facebook 推出的, 他们内部也在大量使用这个框架,(个人感觉是很良心了, 内部推动, 而不是丢出去拿用户当小白鼠), 然后就发现了很多问题, 比较突出的就是渲染问题

他们的应用是比较复杂的, 组件树也是非常庞大, 假设有一千个组件要渲染, 每个耗费1ms, 一千个就是1000ms, 由于javascript 是单线程的, 这 1000ms 里 CPU 都在努力的干活, 一旦开始,中间就不会停。 如果这时候用户去操作, 比如输入, 点击按钮, 此时页面是没有响应的。 等更新完了, 你之前的那些输入就会啪啪啪一下子出来了。

这就是我们说的页面卡顿, 用起来很不爽, 体验不好。

这个问题和设备性能没有多大关系, 归根结底还是同步渲染机制的问题。

目前的React 版本(v16.7), 当组件树很大的时候,也会出现这个问题, 逐层渲染, 逐渐深入,不更新完就不会停

函数调用栈如图所示:

clipboard.png

因为JavaScript单线程的特点,每个同步任务不能耗时太长,不然就会让程序不会对其他输入作出相应,React的更新过程就是犯了这个禁忌,而React Fiber就是要改变现状。

异步渲染

Fiber 的做法是:分片。

把一个很耗时的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。 而维护每一个分片的数据结构, 就是Fiber

用一张图来展示Fiber 的碎片化更新过程:

clipboard.png

中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机。

更详细的信息可以看: Lin Clark - A Cartoon Intro to Fiber - React Conf 2017

在React Fiber中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来

因为一个更新过程可能被打断,所以React Fiber一个更新过程被分为两个阶段: render phase and commit phase.

两个重要概念: render phase and commit phase

有了Fiber 之后, react 的渲染过程不再是一旦开始就不能终止的模式了, 而是划分成为了两个过程: 第一阶段和第二阶段, 也就是官网所谓的 render phase and commit phase

在 Render phase 中, React Fiber会找出需要更新哪些DOM,这个阶段是可以被打断的, 而到了第二阶段commit phase, 就一鼓作气把DOM更新完,绝不会被打断。

两个阶段的分界点

这两个阶段, 分界点是什么呢?

其实是 render 函数。 而且, render 函数 也是属于 第一阶段 render phase 的

那这两个 phase 包含的的生命周期函数有哪些呢?

render phase:

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

commit phase:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

clipboard.png

因为第一阶段的过程会被打断而且“重头再来”,就会造成意想不到的情况。

比如说,一个低优先级的任务A正在执行,已经调用了某个组件的componentWillUpdate函数,接下来发现自己的时间分片已经用完了,于是冒出水面,看看有没有紧急任务,哎呀,真的有一个紧急任务B,接下来React Fiber就会去执行这个紧急任务B,任务A虽然进行了一半,但是没办法,只能完全放弃,等到任务B全搞定之后,任务A重头来一遍,注意,是重头来一遍,不是从刚才中段的部分开始,也就是说,componentWillUpdate函数会被再调用一次。

在现有的React中,每个生命周期函数在一个加载或者更新过程中绝对只会被调用一次;在React Fiber中,不再是这样了,第一阶段中的生命周期函数在一次加载和更新过程中可能会被多次调用!

这里也可以回答文行开头的那个问题了, 当然, 在异步渲染模式没有开启之前, 你可以在 willMount 里做ajax (不建议)。 首先,一个组件的 componentWillMount 比 componentDidMount 也早调用不了几微秒,性能没啥提高,而且如果开启了异步渲染, 这就难受了。 React 官方也意识到了这个问题,觉得有必要去劝告(威胁, 阻止)开发者不要在render phase 里写有副作用的代码了(副作用:简单说就是做本函数之外的事情,比如改一个全局变量, ajax之类)。

static getDerivedStateFromProps(nextProps, prevState) {
  //根据nextProps和prevState计算出预期的状态改变,返回结果会被送给setState
}

新的静态方法

为了减少(避免?)一些开发者的骚操作,React v16.3,干脆引入了一个新的生命周期函数 getDerivedStateFromProps, 这个函数是一个 static 函数,也是一个纯函数,里面不能通过 this 访问到当前组件(强制避免一些有副作用的操作),输入只能通过参数,对组件渲染的影响只能通过返回值。目的大概也是让开发者逐步去适应异步渲染。

我们再看一下 React v16.3 之前的的生命周期函数 示意图:

clipboard.png

再看看16.3的示意图:

clipboard.png

上图中并包含全部React生命周期函数,另外在React v16发布时,还增加了一个componentDidCatch,当异常发生时,一个可以捕捉到异常的componentDidCatch就排上用场了。不过,很快React觉着这还不够,在v16.6.0又推出了一个新的捕捉异常的生命周期函数getDerivedStateFromError

如果异常发生在render阶段,React就会调用getDerivedStateFromError,如果异常发生在第commit阶段,React会调用componentDidCatch。 这个异常可以是任何类型的异常, 捕捉到这个异常之后呢, 可以做一些补救之类的事情。

componentDidCatchgetDerivedStateFromError 的 区别

componentDidCatch 和 getDerivedStateFromError 都是能捕捉异常的,那他们有什么区别呢?

我们之前说了两个阶段, render phasecommit phase.

render phase 里产生异常的时候, 会调用 getDerivedStateFromError;

在 commit phase 里产生异常大的时候, 会调用 componentDidCatch

严格来说, 其实还有一点区别:

componentDidCatch 是不会在服务器端渲染的时候被调用的 而 getDerivedStateFromError 会。

背景小结

啰里八嗦一大堆, 关于背景的东西就说到这, 大家只需要了解什么是Fiber: ‘ 哦, 这个这个东西是支持异步渲染的, 虽然这个东西还没开启’。

然后就是渲染的两个阶段:renderphasecommit phase.

  • render phase 可以被打断, 大家不要在此阶段做一些有副作用的操作,可以放心在commit phase 里做。
  • 然后就是生命周期的调整, react 把你有可能在render phase 里做的有副作用的函数都改成了static 函数, 强迫开发者做一些纯函数的操作。

现在我们进入正题: SuspenseHooks

正题


suspense

Suspense要解决的两个问题:

  1. 代码分片;
  2. 异步获取数据。

刚开始的时候, React 觉得自己只是管视图的, 代码打包的事不归我管, 怎么拿数据也不归我管。 代码都打到一起, 比如十几M, 下载就要半天,体验显然不会好到哪里去。

可是后来呢,这两个事情越来越重要, React 又觉得, 嗯,还是要掺和一下,是时候站出来展现真正的技术了。

Suspense 在v16.6的时候 已经解决了代码分片的问题,异步获取数据还没有正式发布。

先看一个简单的例子:

import React from "react";
import moment from "moment";
 
const Clock = () => <h1>{moment().format("MMMM Do YYYY, h:mm:ss a")}</h1>;

export default Clock;

假设我们有一个组件, 是看当前时间的, 它用了一个很大的第三方插件, 而我想只在用的时候再加载资源,不打在总包里。

再看一段代码:

// Usage of Clock
const Clock = React.lazy(() => {
  console.log("start importing Clock");
  return import("./Clock");
});

这里我们使用了React.lazy, 这样就能实现代码的懒加载。 React.lazy 的参数是一个function, 返回的是一个promise. 这里返回的是一个import 函数, webpack build 的时候, 看到这个东西, 就知道这是个分界点。 import 里面的东西可以打包到另外一个包里。

真正要用的话, 代码大概是这个样子的:

<Suspense fallback={<Loading />}>
  { showClock ? <Clock/> : null}
</Suspense>

showClock 为 true, 就尝试render clock, 这时候, 就触发另一个事件: 去加载clock.js 和它里面的 lib momment。

看到这你可能觉得奇怪, 怎么还需要用个<Suspense> 包起来, 有啥用, 不包行不行。

哎嗨, 不包还真是不行。 为什么呢?

前面我们说到, 目前react 的渲染模式还是同步的, 一口气走到黑, 那我现在画到clock 这里, 但是这clock 在另外一个文件里, 服务器就需要去下载, 什么时候能下载完呢, 不知道。 假设你要花十分钟去下载, 那这十分钟你让react 去干啥, 总不能一直等你吧。 Suspens 就是来解决这个问题的, 你要画clock, 现在没有,那就会抛一个异常出来,我们之前说
componentDidCatch 和 getDerivedStateFromError, 这两个函数就是来抓子组件 或者 子子组件抛出的异常的。

子组件有异常的时候就会往上抛,直到某个组件的 getDerivedStateFromError 抓住这个异常,抓住之后干嘛呢, 还能干嘛呀, 忍着。

下载资源的时候会抛出一个promise, 会有地方(这里是suspense)捕捉这个promise, suspense 实现了getDerivedStateFromError,捕获到异常的时候, 一看, 哎, 小老弟,你来啦,还是个promise, 然后就等这个promise resolve, 完成之后,它会尝试重新画一下子组件。

这时候资源已经到本地了,也就能画成功了。

用伪代码 大致实现一下:

getDerivedStateFromError(error) {
   if (isPromise(error)) {
      error.then(reRender);
   }
}

以上大概就是Suspense 的原理, 其实也不是很复杂,就是利用了 componentDidCatch 和 getDerivedStateFromError, 其实刚开始在v16的时候, 是要用componentDidCatch 的, 但它毕竟是commit phase 里的东西, 还是分出来吧, 所以又加了个getDerivedStateFromError来实现 Suspense 的功能。

这里需要注意的是 reRender 会渲染suspense 下面的所有子组件。

异步渲染什么时候开启呢, 根据介绍说是在19年的第二个季度随着一个小版本的升级开启, 让我们提前做好准备。

做些什么准备呢?

  • render 函数之前的代码都检查一边, 避免一些有副作用的操作

到这, 我们说完了Suspense 的一半功能, 还有另一半: 异步获取数据。

目前这一部分功能还没正式发布。 那我们获取数据还是只能在commit phase 做, 也就是在componentDidMount 里 或者 didUpdate 里做。

就目前来说, 如果一个组件要自己获取数据, 就必须实现为一个类组件, 而且会画两次, 第一次没有数据, 是空的, 你可以画个loading, didMount 之后发请求, 数据回来之后, 把数据setState 到组件里, 这时候有数据了, 再画一次,就画出来了。

虽然是一个很简答的功能, 我就想请求个数据, 还要写一堆东西, 很麻烦, 但在目前的正式版里, 不得不这么做。

但以后这种情况会得到改善, 看一段示例:

import {unstable_createResource as createResource} from 'react-cache';

const resource = createResource(fetchDataApi);

const Foo = () => {
  const result = resource.read();
  return (
    <div>{result}</div>
  );

// ...

<Suspense>
   <Foo />
</Suskpense>};

代码里我们看不到任何譬如 async await 之类的操作, 看起来完全是同步的操作, 这是什么原理呢。

上面的例子里, 有个 resource.read(), 这里就会调api, 返回一个promise, 上面会有suspense 抓住, 等resolve 的时候,再画一下, 就达到目的了。

到这,细心的同学可能就发现了一个问题, resource.read(); 明显是一个有副作用的操作, 而且 render 函数又属于render phase, 之前又说, 不建议在 render phase 里做有副作用的操作, 这么矛盾, 不是自己打脸了吗。

这里也能看出来React 团队现在还没完全想好, 目前放出来测试api 也是以unstable_开头的, 不用用意还是跟明显的: 让大家不要写class的组件,Suspense 能很好的支持函数式组件。

hooks

React v16.7.0-alpha 中第一次引入了 Hooks 的概念, 为什么要引入这个东西呢?

有两个原因:

  1. React 官方觉得 class组件太难以理解,OO(面向对象)太难懂了
  2. React 官方觉得 , React 生命周期太难理解。

最终目的就是, 开发者不用去理解class, 也不用操心生命周期方法。

但是React 官方又说, Hooks的目的并不是消灭类组件。此处应手动滑稽。

回归正题, 我们继续看Hooks, 首先看一下官方的API

clipboard.png

乍一看还是挺多的, 其实有很多的Hook 还处在实验阶段,很可能有一部分要被砍掉, 目前大家只需要熟悉的, 三个就够了:

  • useState
  • useEffect
  • useContext

useState

举个例子来看下, 一个简单的counter :

// 有状态类组件
class Counter extends React.Component {
   state = {
      count: 0
   }
   
   increment = () => {
       this.setState({count: this.state.count + 1});
   }
   
   minus = () => {
       this.setState({count: this.state.count - 1});
   }
   
   render() {
       return (
           <div>
               <h1>{this.state.count}</h1>
               <button onClick={this.increment}>+</button>
               <button onClick={this.minus}>-</button>
           </div>
       );
   }
}
// 使用useState Hook
const Counter = () => {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(count + 1);
  
  return (
    <div>
        <h1>{count}</h1>
        <button onClick={increment}>+</button>
    </div>
  );
};

这里的Counter 不是一个类了, 而是一个函数。

进去就调用了useState, 传入 0,对state 进行初始化,此时count 就是0, 返回一个数组, 第一个元素就是 state 的值,第二个元素是更新 state 的函数。

// 下面代码等同于: const [count, setCount] = useState(0);
  const result = useState(0);
  const count = result[0];
  const setCount = result[1];

利用 count 可以读取到这个 state,利用 setCount 可以更新这个 state,而且我们完全可以控制这两个变量的命名,只要高兴,你完全可以这么写:

 const [theCount, updateCount] = useState(0);

因为 useState 在 Counter 这个函数体中,每次 Counter 被渲染的时候,这个 useState 调用都会被执行,useState 自己肯定不是一个纯函数,因为它要区分第一次调用(组件被 mount 时)和后续调用(重复渲染时),只有第一次才用得上参数的初始值,而后续的调用就返回“记住”的 state 值。

读者看到这里,心里可能会有这样的疑问:如果组件中多次使用 useState 怎么办?React 如何“记住”哪个状态对应哪个变量?

React 是完全根据 useState 的调用顺序来“记住”状态归属的,假设组件代码如下:

const Counter = () => {
  const [count, setCount] = useState(0);
  const [foo, updateFoo] = useState('foo');
  
  // ...
}

每一次 Counter 被渲染,都是第一次 useState 调用获得 count 和 setCount,第二次 useState 调用获得 foo 和 updateFoo(这里我故意让命名不用 set 前缀,可见函数名可以随意)。

React 是渲染过程中的“上帝”,每一次渲染 Counter 都要由 React 发起,所以它有机会准备好一个内存记录,当开始执行的时候,每一次 useState 调用对应内存记录上一个位置,而且是按照顺序来记录的。React 不知道你把 useState 等 Hooks API 返回的结果赋值给什么变量,但是它也不需要知道,它只需要按照 useState 调用顺序记录就好了。

你可以理解为会有一个槽去记录状态。

正因为这个原因,Hooks,千万不要在 if 语句或者 for 循环语句中使用!

像下面的代码,肯定会出乱子的:

const Counter = () => {
    const [count, setCount] = useState(0);
    if (count % 2 === 0) {
        const [foo, updateFoo] = useState('foo');
    }
    const [bar, updateBar] = useState('bar');
 // ...
}

因为条件判断,让每次渲染中 useState 的调用次序不一致了,于是 React 就错乱了。

useEffect

除了 useState,React 还提供 useEffect,用于支持组件中增加副作用的支持。

在 React 组件生命周期中如果要做有副作用的操作,代码放在哪里?

当然是放在 componentDidMount 或者 componentDidUpdate 里,但是这意味着组件必须是一个 class。

在 Counter 组件,如果我们想要在用户点击“+”或者“-”按钮之后把计数值体现在网页标题上,这就是一个修改 DOM 的副作用操作,所以必须把 Counter 写成 class,而且添加下面的代码:

componentDidMount() {
  document.title = `Count: ${this.state.count}`;
}

componentDidUpdate() {
  document.title = `Count: ${this.state.count}`;
}

而有了 useEffect,我们就不用写一个 class 了,对应代码如下:

import { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    document.title = `Count: ${this.state.count}`;
  });

  return (
    <div>
       <div>{count}</div>
       <button onClick={() => setCount(count + 1)}>+</button>
       <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
};

useEffect 的参数是一个函数,组件每次渲染之后,都会调用这个函数参数,这样就达到了 componentDidMount 和 componentDidUpdate 一样的效果。

虽然本质上,依然是 componentDidMountcomponentDidUpdate 两个生命周期被调用,但是现在我们关心的不是 mount 或者 update 过程,而是“after render”事件,useEffect 就是告诉组件在“渲染完”之后做点什么事。

读者可能会问,现在把 componentDidMountcomponentDidUpdate 混在了一起,那假如某个场景下我只在 mount 时做事但 update 不做事,用 useEffect 不就不行了吗?

其实,用一点小技巧就可以解决。useEffect 还支持第二个可选参数,只有同一 useEffect 的两次调用第二个参数不同时,第一个函数参数才会被调用. 所以,如果想模拟 componentDidMount,只需要这样写:

  useEffect(() => {
    // 这里只有mount时才被调用,相当于componentDidMount
  }, [123]);

在上面的代码中,useEffect 的第二个参数是 [123],其实也可以是任何一个常数,因为它永远不变,所以 useEffect 只在 mount 时调用第一个函数参数一次,达到了 componentDidMount 一样的效果。

useContext

在前面介绍“提供者模式”章节我们介绍过 React 新的 Context API,这个 API 不是完美的,在多个 Context 嵌套的时候尤其麻烦。

比如,一段 JSX 如果既依赖于 ThemeContext 又依赖于 LanguageContext,那么按照 React Context API 应该这么写:

<ThemeContext.Consumer>
    {
        theme => (
            <LanguageContext.Cosumer>
                language => {
                    //可以使用theme和lanugage了
                }
            </LanguageContext.Cosumer>
        )
    }
</ThemeContext.Consumer>

因为 Context API 要用 render props,所以用两个 Context 就要用两次 render props,也就用了两个函数嵌套,这样的缩格看起来也的确过分了一点点。

使用 Hooks 的 useContext,上面的代码可以缩略为下面这样:

const theme = useContext(ThemeContext);
const language = useContext(LanguageContext);
// 这里就可以用theme和language了

这个useContext把一个需要很费劲才能理解的 Context API 使用大大简化,不需要理解render props,直接一个函数调用就搞定。

但是,useContext也并不是完美的,它会造成意想不到的重新渲染,我们看一个完整的使用useContext的组件。

const ThemedPage = () => {
    const theme = useContext(ThemeContext);
    
    return (
       <div>
            <Header color={theme.color} />
            <Content color={theme.color}/>
            <Footer color={theme.color}/>
       </div>
    );
};

因为这个组件ThemedPage使用了useContext,它很自然成为了Context的一个消费者,所以,只要Context的值发生了变化,ThemedPage就会被重新渲染,这很自然,因为不重新渲染也就没办法重新获得theme值,但现在有一个大问题,对于ThemedPage来说,实际上只依赖于theme中的color属性,如果只是theme中的size发生了变化但是color属性没有变化,ThemedPage依然会被重新渲染,当然,我们通过给Header、Content和Footer这些组件添加shouldComponentUpdate实现可以减少没有必要的重新渲染,但是上一层的ThemedPage中的JSX重新渲染是躲不过去了。

说到底,useContext 需要一种表达方式告诉React:“我没有改变,重用上次内容好了。”

希望Hooks正式发布的时候能够弥补这一缺陷。

Hooks 带来的代码模式改变

上面我们介绍了 useStateuseEffectuseContext 三个最基本的 Hooks,可以感受到,Hooks 将大大简化使用 React 的代码。

首先我们可能不再需要 class了,虽然 React 官方表示 class 类型的组件将继续支持,但是,业界已经普遍表示会迁移到 Hooks 写法上,也就是放弃 class,只用函数形式来编写组件。

对于 useContext,它并没有为消除 class 做贡献,却为消除 render props 模式做了贡献。很长一段时间,高阶组件和 render props 是组件之间共享逻辑的两个武器,但如同我前面章节介绍的那样,这两个武器都不是十全十美的,现在 Hooks 的出现,也预示着高阶组件和 render props 可能要被逐步取代。

但读者朋友,不要觉得之前学习高阶组件和 render props 是浪费时间,相反,你只有明白 React 的使用历史,才能更好地理解 Hooks 的意义。

可以预测,在 Hooks 兴起之后,共享代码之间逻辑会用函数形式,而且这些函数会以 use- 前缀为约定,重用这些逻辑的方式,就是在函数形式组件中调用这些 useXXX 函数。

例如,我们可以写这样一个共享 Hook useMountLog,用于在 mount 时记录一个日志,代码如下:

const useMountLog = (name) => {
    useEffect(() => {
        console.log(`${name} mounted`);    
    }, [123]);
}

任何一个函数形式组件都可以直接调用这个 useMountLog 获得这个功能,如下:

const Counter = () => {
    useMountLog('Counter');
    
    ...
}

对了,所有的 Hooks API 都只能在函数类型组件中调用,class 类型的组件不能用,从这点看,很显然,class 类型组件将会走向消亡。

如何用Hooks 模拟旧版本的生命周期函数

Hooks 未来正式发布后, 我们自然而然的会遇到这个问题, 如何把写在旧生命周期内的逻辑迁移到Hooks里面来。下面我们就简单说一下,

模拟整个生命周期中只运行一次的方法

useMemo(() => {
  // execute only once
}, []);

我们可以看到useMemo 接收两个参数, 第一个参数是一个函数, 第二个参数是一个数组。

这里有个地方要注意, 就是, 第二个参数的数组里的元素和上一次执行useMemo的第二个参数的数组的元素 完全一样的话,那就表示没有变化, 就不用执行第一个参数里的函数了。 如果有不同, 说明有变化, 就执行。

上面的例子里, 我们只传入了一个空数组, 不会有变化, 也就是只会执行一次。

模拟shouldComponentUpdate

const areEqual = (prevProps, nextProps) => {
   // 返回结果和shouldComponentUpdate正好相反
   // 访问不了state
}; 
React.memo(Foo, areEqual);

模拟componentDidMount

useEffect(() => {
    // 这里在mount时执行一次
}, []);

模拟componentDidUpdate

const mounted = useRef();
useEffect(() => {
  if (!mounted.current) {
    mounted.current = true;
  } else {
    // 这里只在update是执行
  }
});

模拟componentDidUnmount

useEffect(() => {
    // 这里在mount时执行一次
    return () => {
       // 这里在unmount时执行一次
    }
}, []);

未来的代码形势

Hooks 未来发布之后, 我们的代码会写成什么样子呢? 简单设想一下:

// Hooks之后的组件逻辑重用形态

const XXXX = () => {
  const [xx, xxx, xxxx] = useX();
  
  useY();
  
  const {a, b} = useZ();
  

  return (
    <>
     //JSX
    </>
  );
};

内部可能用各种Hooks, 也可能包含第三方的Hooks。 分享Hooks 就是实现代码重用的一种形势。 其实现在已经有人在做这方面的工作了: useHooks.com, 有兴趣的朋友可以去看下。

Suspense 和 Hooks 带来的改变

Suspense 和 Hooks 发布后, 会带来什么样的改变呢? 毫无疑问, 未来的组件, 更多的将会是函数式组件。

原因很简单, 以后大家分享出来的都是Hooks,这东西只能在函数组件里用啊, 其他地方用不了,后面就会自然而然的发生了。

但函数式组件和函数式编程还不是同一个概念。 函数式编程必须是纯的, 没有副作用的, 函数式组件里, 不能保证, 比如那个resource.read(), 明显是有副作用的。

关于好坏

既然这两个东西是趋势, 那这两个东西到底好不好呢 ?

个人理解, 任何东西都不是十全十美。 既然大势所趋, 我们就努力去了解它,学会它, 努力用它好的地方, 避免用不好的地方。

React 发布路线图

最新的消息: https://reactjs.org/blog/2018...

  • React 16.6 with Suspense for Code Splitting (already shipped)
  • A minor 16.x release with React Hooks (~Q1 2019)
  • A minor 16.x release with Concurrent Mode (~Q2 2019)
  • A minor 16.x release with Suspense for Data Fetching (~mid 2019)

明显能够看到资源在往 Suspense 和 Hooks 倾斜。

结语

看到这, 相信大家都Suspense 和 Hooks 都有了一个大概的了解了。

收集各种资料花费了挺长时间,大概用了两三天写出来,中间参考了很多资料, 一部分是摘录到了上面的内容里。

在这里整理分享一下, 希望对大家有所帮助。

才疏学浅, 难免会有纰漏, 欢迎指正:)。

最后

觉得内容有帮助可以关注下我的公众号 「 前端e进阶 」,一起学习成长

clipboard.png

参考资料

查看原文

清晖 收藏了文章 · 1月8日

使用ESLint+Prettier来统一前端代码风格

加分号还是不加分号?tab还是空格?你还在为代码风格与同事争论得面红耳赤吗?

正文之前,先看个段子放松一下: 去死吧!你这个异教徒!

想起自己刚入行的时候,从svn上把代码checkout下来,看到同事写的代码,大括号居然换行了。心中暗骂,这个人是不是个**,大括号为什么要换行?年轻气盛的我,居然满腔怒火,将空行一一删掉。
但是关于代码风格,我们很难区分谁对谁错,不同的人有不同偏好,唯有强制要求才能规避争论。

所以,团队关于代码风格必须遵循两个基本原则:

  1. 少数服从多数;
  2. 用工具统一风格。

本文将介绍,如何使用ESLint + Prettier来统一我们的前端代码风格。

Prettier是什么?

首先,对应ESLint大多都很熟悉,用来进行代码的校验,但是Prettier(直译过来就是"更漂亮的"😂)听得可能就比较少了。js作为一门灵活的弱类型语言,代码风格千奇百怪,一千个人写js就有一千种写法。虽然js没有官方推荐的代码规范,不过社区有些比较热门的代码规范,比如standardjsairbnb。使用ESLint配合这些规范,能够检测出代码中的潜在问题,提高代码质量,但是并不能完全统一代码风格,因为这些代码规范的重点并不在代码风格上(虽然有一些限制)。

下面开始安利,Prettier。

Prettier是一个能够完全统一你和同事代码风格的利器,假如你有个c++程序员转行过来写前端的同事,你发现你们代码风格完全不一样,你难道要一行行去修改他的代码吗,就算你真的去改,你的需求怎么办,所以没有人真的愿意在保持代码风格统一上面浪费时间。选择Prettier能够让你节省出时间来写更多的bug(不对,是修更多的bug),并且统一的代码风格能保证代码的可读性。

看看Prettier干的好事。

gif
gif

能支持jsx

gif

也能支持css

gif

唯一的遗憾是,暂时还不能格式化vue模版文件中template部分。

ESLint 与 Prettier配合使用

首先肯定是需要安装prettier,并且你的项目中已经使用了ESLint,有eslintrc.js配置文件。

npm i -D prettier

配合ESLint检测代码风格

安装插件:

npm i -D eslint-plugin-prettier

eslint-plugin-prettier插件会调用prettier对你的代码风格进行检查,其原理是先使用prettier对你的代码进行格式化,然后与格式化之前的代码进行对比,如果过出现了不一致,这个地方就会被prettier进行标记。

接下来,我们需要在rules中添加,"prettier/prettier": "error",表示被prettier标记的地方抛出错误信息。

//.eslintrc.js
{
  "plugins": ["prettier"],
  "rules": {
    "prettier/prettier": "error"
  }
}

借助ESLint的autofix功能,在保存代码的时候,自动将抛出error的地方进行fix。因为我们项目是在webpack中引入eslint-loader来启动eslint的,所以我们只要稍微修改webpack的配置,就能在启动webpack-dev-server的时候,每次保存代码同时自动对代码进行格式化。

const path = require('path')
module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|vue)$/,
        loader: 'eslint-loader',
        enforce: 'pre',
        include: [path.join(__dirname, 'src')],
        options: {
          fix: true
        }
      }
    ]
}

如果你的eslint是直接通过cli方式启动的,那么只需要在后面加上fix即可,如:eslint --fix

如果与已存在的插件冲突怎么办

npm i -D eslint-config-prettier

通过使用eslint-config-prettier配置,能够关闭一些不必要的或者是与prettier冲突的lint选项。这样我们就不会看到一些error同时出现两次。使用的时候需要确保,这个配置在extends的最后一项。

//.eslintrc.js
{
  extends: [
    'standard', //使用standard做代码规范
    "prettier",
  ],
}

这里有个文档,列出了会与prettier冲突的配置项。

同时使用上面两项配置

如果你同时使用了上述的两种配置,那么你可以通过如下方式,简化你的配置。

//.eslintrc.js
{
  "extends": ["plugin:prettier/recommended"]
}

最后贴一下我们项目中的完整配置,是在vue-cli生成的代码基础上修改的,并且使用standard做代码规范:

module.exports = {
  root: true,
  parserOptions: {
    parser: 'babel-eslint'
  },
  env: {
    browser: true,
    es6: true
  },
  extends: [
    // https://github.com/standard/standard/blob/master/docs/RULES-en.md
    'standard',
    // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
    // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
    'plugin:vue/essential',
    "plugin:prettier/recommended",
  ],
  // required to lint *.vue files
  plugins: [
    'vue'
  ],
  // add your custom rules here
  rules: {
    "prettier/prettier": "error",
    // allow async-await
    'generator-star-spacing': 'off',
    // allow debugger during development
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
  }
}

什么?你们项目没有启用ESLint

不要慌,没有ESLint也不要怕,可以通过onchange进行代码的监听,然后自动格式化代码。只要在package.json的scripts下添加如下代码,然后使用npm run format,我们就能监听src目录下所有的js文件并进行格式化:

"scripts": {
  "format": "onchange 'src/**/*.js' -- prettier --write {{changed}}"
}

当你想格式化的文件不止js文件时,也可以添加多个文件列表。

"scripts": {
  "format": "onchange 'test/**/*.js' 'src/**/*.js' 'src/**/*.vue' -- prettier --write {{changed}}"
}

当然,你也能够在编辑器中配置对prettier的支持,具体支持哪些编辑器,请戳这里

如何对Prettier进行配置

一共有三种方式支持对Prettier进行配置:

  1. 根目录创建.prettierrc 文件,能够写入YML、JSON的配置格式,并且支持.yaml/.yml/.json/.js后缀;
  2. 根目录创建.prettier.config.js 文件,并对外export一个对象;
  3. package.json中新建prettier属性。

下面我们使用prettierrc.js的方式对prettier进行配置,同时讲解下各个配置的作用。

module.exports = {
  "printWidth": 80, //一行的字符数,如果超过会进行换行,默认为80
  "tabWidth": 2, //一个tab代表几个空格数,默认为80
  "useTabs": false, //是否使用tab进行缩进,默认为false,表示用空格进行缩减
  "singleQuote": false, //字符串是否使用单引号,默认为false,使用双引号
  "semi": true, //行位是否使用分号,默认为true
  "trailingComma": "none", //是否使用尾逗号,有三个可选值"<none|es5|all>"
  "bracketSpacing": true, //对象大括号直接是否有空格,默认为true,效果:{ foo: bar }
  "parser": "babylon" //代码的解析引擎,默认为babylon,与babel相同。
}

配置大概列出了这些,还有一些其他配置可以在官方文档进行查阅。

注意一点,parser的配置项官网列出了如下可选项:

  • babylon
  • flow
  • typescript Since v1.4.0
  • postcss Since v1.4.0
  • json Since v1.5.0
  • graphql Since v1.5.0
  • markdown Since v1.8.0

但是如果你使用了vue的单文件组件形式,记得将parser配置为vue,目前官方文档没有列出来。当然如果你自己写过AST的解析器,也可以用你自己的写的parser: require("./my-parser")

总结

有了prettier我们再也不用羡慕隔壁写golang的同事,保存后就能自动format,也不用为了项目代码不统一和同事争论得面红耳赤,因为我们统一使用prettier的风格。可能刚开始有些地方你看不惯,不过不要紧,想想这么做都是为了团队和睦,世界和平,我们做出的牺牲都是必要的。而且prettier的样式风格已经在很多大型开源项目中被采用,比如react、webpack、babel。

他们都在用

你看,他们都在用了,你还在等什么,想变成异教徒被烧死吗,还不快行动起来。更多精彩内容请看官方链接

image

查看原文

清晖 收藏了文章 · 2020-07-16

useEffect Hook 是如何工作的

作者:Dave Ceddia
译者:前端小智
来源:daveceddia.

为了保证的可读性,本文采用意译而非直译。

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!

为了回馈读者,《大迁世界》不定期举行(每个月一到三次),现金抽奖活动,保底200,外加用户赞赏,希望你能成为大迁世界的小锦鲤,快来试试吧

想象一下:你有一个非常好用的函数组件,然后有一天,咱们需要向它添加一个生命周期方法。

呃...

刚开始咱们可能会想怎么能解决这个问题,然后最后变成,通常的做法是将它转换成一个类。但有时候咱们就是要用函数方式,怎么破? useEffect hook 出现就是为了解决这种情况。

使用useEffect,可以直接在函数组件内处理生命周期事件。 如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个函数的组合。来看看例子:

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';

function LifecycleDemo() {
  useEffect(() => {
    // 默认情况下,每次渲染后都会调用该函数
    console.log('render!');

    // 如果要实现 componentWillUnmount,
    // 在末尾处返回一个函数
    // React 在该函数组件卸载前调用该方法
    // 其命名为 cleanup 是为了表明此函数的目的,
    // 但其实也可以返回一个箭头函数或者给起一个别的名字。
    return function cleanup () {
        console.log('unmounting...');
    }
  })  
  return "I'm a lifecycle demo";
}

function App() {
  // 建立一个状态,为了方便
  // 触发重新渲染的方法。
  const [random, setRandom] = useState(Math.random());

  // 建立一个状态来切换 LifecycleDemo 的显示和隐藏
  const [mounted, setMounted] = useState(true);

  // 这个函数改变 random,并触发重新渲染
  // 在控制台会看到 render 被打印
  const reRender = () => setRandom(Math.random());

  // 该函数将卸载并重新挂载 LifecycleDemo
  // 在控制台可以看到  unmounting 被打印
  const toggle = () => setMounted(!mounted);

  return (
    <>
      <button onClick={reRender}>Re-render</button>
      <button onClick={toggle}>Show/Hide LifecycleDemo</button>
      {mounted && <LifecycleDemo/>}
    </>
  );
}

ReactDOM.render(<App/>, document.querySelector('#root'));

CodeSandbox中尝试一下。

单击“Show/Hide”按钮,看看控制台,它在消失之前打印“unmounting...”,并在它再次出现时打印 “render!”。

图片描述

现在,点击Re-render按钮。每次点击,它都会打render!,还会打印umounting,这似乎是奇怪的。

图片描述

为啥每次渲染都会打印 'unmounting'。

咱们可以有选择性地从useEffect返回的cleanup函数只在组件卸载时调用。React 会在组件卸载的时候执行清除操作。正如之前学到的,effect 在每次渲染的时候都会执行。这就是为什么 React 会在执行当前 effect 之前对上一个 effect 进行清除。这实际上比componentWillUnmount生命周期更强大,因为如果需要的话,它允许咱们在每次渲染之前和之后执行副作用。

不完全的生命周期

useEffect在每次渲染后运行(默认情况下),并且可以选择在再次运行之前自行清理。

与其将useEffect看作一个函数来完成3个独立生命周期的工作,不如将它简单地看作是在渲染之后执行副作用的一种方式,包括在每次渲染之前和卸载之前咱们希望执行的需要清理的东西。

阻止每次重新渲染都会执行 useEffect

如果希望 effect 较少运行,可以提供第二个参数 - 值数组。 将它们视为该effect的依赖关系。 如果其中一个依赖项自上次更改后,effect将再次运行。

const [value, setValue] = useState('initial');

useEffect(() => {
  // 仅在 value 更改时更新
  console.log(value);
}, [value]) 

上面这个示例中,咱们传入 [value] 作为第二个参数。这个参数是什么作用呢?如果value的值是 5,而且咱们的组件重渲染的时候 value 还是等于 5,React 将对前一次渲染的 [5] 和后一次渲染的 [5] 进行比较。因为数组中的所有元素都是相等的(5 === 5),React 会跳过这个 effect,这就实现了性能的优化。

仅在挂载和卸载的时候执行

如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。这就告诉 React 你的 effect 不依赖于 propsstate 中的任何值,所以它永远都不需要重复执行。这并不属于特殊情况 —— 它依然遵循依赖数组的工作方式。

useEffect(() => {
  console.log('mounted');
  return () => console.log('unmounting...');
}, []) 

这样只会在组件初次渲染的时候打印 mounted,在组件卸载后打印: unmounting

不过,这隐藏了一个问题:传递空数组容易出现bug。如果咱们添加了依赖项,那么很容易忘记向其中添加项,如果错过了一个依赖项,那么该值将在下一次运行useEffect时失效,并且可能会导致一些奇怪的问题。

只在挂载的时候执行

在这个例子中,一起来看下如何使用useEffectuseRef hook 将input控件聚焦在第一次渲染上。

import React, { useEffect, useState, useRef } from "react";
import ReactDOM from "react-dom";

function App() {
  // 存储对 input 的DOM节点的引用
  const inputRef = useRef();

  // 将输入值存储在状态中
  const [value, setValue] = useState("");

  useEffect(
    () => {
      // 这在第一次渲染之后运行
      console.log("render");
      // inputRef.current.focus();
    },
    // effect 依赖  inputRef
    [inputRef]
  );

  return (
    <input
      ref={inputRef}
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}

ReactDOM.render(<App />, document.querySelector("#root"));

在顶部,我们使用useRef创建一个空的ref。 将它传递给inputref prop ,在渲染DOM 时设置它。 而且,重要的是,useRef返回的值在渲染之间是稳定的 - 它不会改变。

因此,即使咱们将[inputRef]作为useEffect的第二个参数传递,它实际上只在初始挂载时运行一次。这基本上是 componentDidMount 效果了。

使用 useEffect 获取数据

再来看看另一个常见的用例:获取数据并显示它。在类组件中,无们通过可以将此代码放在componentDidMount方法中。在 hook 中可以使用 useEffect hook 来实现,当然还需要用useState来存储数据。

下面是一个组件,它从Reddit获取帖子并显示它们

import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";

function Reddit() {
  const [posts, setPosts] = useState([]);

  useEffect(async () => {
    const res = await fetch(
      "https://www.reddit.com/r/reactjs.json"
    );

    const json = await res.json();

    setPosts(json.data.children.map(c => c.data));
  }); // 这里没有传入第二个参数,你猜猜会发生什么?

  // Render as usual
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

ReactDOM.render(
  <Reddit />,
  document.querySelector("#root")
);

注意到咱们没有将第二个参数传递给useEffect,这是不好的,不要这样做。

不传递第二个参数会导致每次渲染都会运行useEffect。然后,当它运行时,它获取数据并更新状态。然后,一旦状态更新,组件将重新呈现,这将再次触发useEffect,这就是问题所在。

为了解决这个问题,我们需要传递一个数组作为第二个参数,数组内容又是啥呢。

useEffect所依赖的唯一变量是setPosts。因此,咱们应该在这里传递数组[setPosts]。因为setPostsuseState返回的setter,所以不会在每次渲染时重新创建它,因此effect只会运行一次。

当数据改变时重新获取

虚接着扩展一下示例,以涵盖另一个常见问题:如何在某些内容发生更改时重新获取数据,例如用户ID,名称等。

首先,咱们更改Reddit组件以接受subreddit作为一个prop,并基于该subreddit获取数据,只有当 prop 更改时才重新运行effect.

// 从props中解构`subreddit`:
function Reddit({ subreddit }) {
  const [posts, setPosts] = useState([]);

  useEffect(async () => {
    const res = await fetch(
      `https://www.reddit.com/r/${subreddit}.json`
    );

    const json = await res.json();
    setPosts(json.data.children.map(c => c.data));

    // 当`subreddit`改变时重新运行useEffect:
  }, [subreddit, setPosts]);

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

ReactDOM.render(
  <Reddit subreddit='reactjs' />,
  document.querySelector("#root")
);

这仍然是硬编码的,但是现在咱们可以通过包装Reddit组件来定制它,该组件允许咱们更改subreddit

function App() {
  const [inputValue, setValue] = useState("reactjs");
  const [subreddit, setSubreddit] = useState(inputValue);

  // Update the subreddit when the user presses enter
  const handleSubmit = e => {
    e.preventDefault();
    setSubreddit(inputValue);
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input
          value={inputValue}
          onChange={e => setValue(e.target.value)}
        />
      </form>
      <Reddit subreddit={subreddit} />
    </>
  );
}

ReactDOM.render(<App />, document.querySelector("#root"));

在 CodeSandbox 试试这个示例。

这个应用程序在这里保留了两个状态:当前的输入值和当前的subreddit。提交表单将提交subreddit,这会导致Reddit重新获取数据。

顺便说一下:输入的时候要小心,因为没有错误处理,所以当你输入的subreddit不存在,应用程序将会爆炸,实现错误处理就作为你们的练习。

各位可以只使用一个状态来存储输入,然后将相同的值发送到Reddit,但是Reddit组件会在每次按键时获取数据。

顶部的useState看起来有点奇怪,尤其是第二行:

const [inputValue, setValue] = useState("reactjs");
const [subreddit, setSubreddit] = useState(inputValue);

我们把reactjs的初值传递给第一个状态,这是有意义的,这个值永远不会改变。

那么第二行呢,如果初始状态改变了呢,如当你输入box时候。

记住useState是有状态的。它只使用初始状态一次,即第一次渲染,之后它就被忽略了。所以传递一个瞬态值是安全的,比如一个可能改变或其他变量的 prop

许许多多的用途

使用useEffect 就像瑞士军刀。它可以用于很多事情,从设置订阅到创建和清理计时器,再到更改ref的值。

componentDidMountcomponentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。

然而,并非所有 effect 都可以被延迟执行。例如,在浏览器执行下一次绘制前,用户可见的 DOM 变更就必须同步执行,这样用户才不会感觉到视觉上的不一致。(概念上类似于被动监听事件和主动监听事件的区别。)React 为此提供了一个额外的 useLayoutEffect Hook 来处理这类 effect。它和 useEffect 的结构相同,区别只是调用时机不同。

虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行。React 将在组件更新前刷新上一轮渲染的 effect

原文:https://daveceddia.com/useeff...

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

查看原文

清晖 收藏了文章 · 2020-06-10

Vue.js源码学习二 —— 生命周期 LifeCycle 学习

春节继续写博客~加油!

这次来学习一下Vue的生命周期,看看生命周期是怎么回事。

callHook

生命周期主要就是在源码某个时间点执行这个 callHook 方法来调用 vm.$options 的生命周期钩子方法(如果定义了生命周期钩子方法的话)。
我们来看看 callHook 代码:

export function callHook (vm: Component, hook: string) {
  const handlers = vm.$options[hook] // 获取Vue选项中的生命周期钩子函数
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm) // 执行生命周期函数
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
}

比如触发 mounted 钩子的方法:

callHook(vm, 'mounted')

生命周期钩子

先上一张图看下Vue的生命周期,我们可以在相应的生命周期中定义一些事件。
Vue生命周期

beforeCreate & created

先看看这两个方法调用的时间。

beforeCreate
在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。
created
在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),属性和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,$el 属性目前不可见。

具体代码如下

  // src/core/instance/init.js
  Vue.prototype._init = function (options?: Object) {
    ……
    initLifecycle(vm) // 初始化生命周期
    initEvents(vm) // 初始化事件
    initRender(vm) // 初始化渲染
    callHook(vm, 'beforeCreate')
    initInjections(vm) // 初始化Inject
    initState(vm) // 初始化数据
    initProvide(vm) // 初始化Provide
    callHook(vm, 'created')
    ……
    if (vm.$options.el) {
      vm.$mount(vm.$options.el) // 如果有el属性,将内容挂载到el中去。
    }
  }

beforeMount & mounted

beforeMount
在挂载开始之前被调用:相关的 render 函数首次被调用。该钩子在服务器端渲染期间不被调用。
mounted
el 被新创建的 vm.$el 替换,并挂载到实例上去之后调用该钩子。如果 root 实例挂载了一个文档内元素,当 mounted 被调用时 vm.$el 也在文档内。

贴出代码逻辑

// src/core/instance/lifecycle.js
// 挂载组件的方法
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  
  vm._watcher = new Watcher(vm, updateComponent, noop)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

那么这个 mountComponent 在哪里用了呢?就是在Vue的 $mount 方法中使用。

// src/platforms/web/runtime/index.js
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

最后会在Vue初始化的时候,判断是否有 el,如果有则执行 $mount 方法。

// src/core/instance/init.js
if (vm.$options.el) {
  vm.$mount(vm.$options.el) // 如果有el属性,将内容挂载到el中去。
}

至此生命周期逻辑应该是 beforeCreate - created - beforeMount -mounted

beforeUpdate & updated

beforeUpdate
数据更新时调用,发生在虚拟 DOM 打补丁之前。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器。
updated
由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。

找代码逻辑~ beforeUpdate 和 updated 在两个地方调用。

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    // 如果是已经挂载的,就触发beforeUpdate方法。
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
    ……
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }

在执行 _update 方法的时候,如果 DOM 已经挂载了,则调用 beforeUpdate 方法。
在 _update 方法的最后作者也注视了调用 updated hook 的位置:updated 钩子由 scheduler 调用来确保子组件在一个父组件的 update 钩子中
我们找到 scheduler,发现有个 callUpdateHooks 方法,该方法遍历了 watcher 数组。

// src/core/observer/scheduler.js
function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted) {
      callHook(vm, 'updated')
    }
  }
}

这个 callUpdatedHooksflushSchedulerQueue 方法中调用。

/**
 * 刷新队列并运行watcher
 */
function flushSchedulerQueue () {
  flushing = true
  let watcher, id
  queue.sort((a, b) => a.id - b.id)

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    watcher.run()
  }

  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // 调用组件的updated和activated生命周期
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)
}

继续找下去

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true // 此参数用于判断watcher的ID是否存在
    ……
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

最终在 watcher.js 找到 update 方法:

  // src/core/observer/watcher.js
  update () {
    // lazy 懒加载
    // sync 组件数据双向改变
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this) // 排队watcher
    }
  }

等于是队列执行完 Watcher 数组的 update 方法后调用了 updated 钩子函数。

beforeDestroy & destroyed

beforeDestroy
实例销毁之前调用。在这一步,实例仍然完全可用。该钩子在服务器端渲染期间不被调用。
destroyed
Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。该钩子在服务器端渲染期间不被调用。

看代码~

  // src/core/instance/lifecycle.js
  // 销毁方法
  Vue.prototype.$destroy = function () {
    const vm: Component = this
    if (vm._isBeingDestroyed) {
      // 已经被销毁
      return
    }
    callHook(vm, 'beforeDestroy')
    vm._isBeingDestroyed = true
    // 销毁过程
    // remove self from parent
    const parent = vm.$parent
    if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
      remove(parent.$children, vm)
    }
    // teardown watchers
    if (vm._watcher) {
      vm._watcher.teardown()
    }
    let i = vm._watchers.length
    while (i--) {
      vm._watchers[i].teardown()
    }
    // remove reference from data ob
    // frozen object may not have observer.
    if (vm._data.__ob__) {
      vm._data.__ob__.vmCount--
    }
    // call the last hook...
    vm._isDestroyed = true
    // invoke destroy hooks on current rendered tree
    vm.__patch__(vm._vnode, null)
    // 触发 destroyed 钩子
    callHook(vm, 'destroyed')
    // turn off all instance listeners.
    vm.$off()
    // remove __vue__ reference
    if (vm.$el) {
      vm.$el.__vue__ = null
    }
  }

这是一个销毁 Vue 实例的过程,将各种配置清空和移除。

activated & deactivated

activated
keep-alive 组件激活时调用。
deactivated
keep-alive 组件停用时调用。

找到实现代码的地方

// src/core/instance/lifecycle.js
export function activateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = false
    if (isInInactiveTree(vm)) {
      return
    }
  } else if (vm._directInactive) {
    return
  }
  if (vm._inactive || vm._inactive === null) {
    vm._inactive = false
    for (let i = 0; i < vm.$children.length; i++) {
      activateChildComponent(vm.$children[i])
    }
    callHook(vm, 'activated')
  }
}

export function deactivateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = true
    if (isInInactiveTree(vm)) {
      return
    }
  }
  if (!vm._inactive) {
    vm._inactive = true
    for (let i = 0; i < vm.$children.length; i++) {
      deactivateChildComponent(vm.$children[i])
    }
    callHook(vm, 'deactivated')
  }
}

以上两个方法关键就是修改了 vm._inactive 的值,并且乡下遍历子组件,最后触发钩子方法。

errorCaptured

当捕获一个来自子孙组件的错误时被调用。此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。此钩子可以返回 false 以阻止该错误继续向上传播。

这是 2.5 以上版本有的一个钩子,用于处理错误。

// src/core/util/error.js
export function handleError (err: Error, vm: any, info: string) {
  if (vm) {
    let cur = vm
    // 向上冒泡遍历
    while ((cur = cur.$parent)) {
      // 获取钩子函数
      const hooks = cur.$options.errorCaptured
      if (hooks) {
        for (let i = 0; i < hooks.length; i++) {
          try {
            // 执行 errorCaptured 钩子函数
            const capture = hooks[i].call(cur, err, vm, info) === false
            if (capture) return
          } catch (e) {
            globalHandleError(e, cur, 'errorCaptured hook')
          }
        }
      }
    }
  }
  globalHandleError(err, vm, info)
}

代码很简单,看代码即可~

生命周期

除了生命周期钩子外,vue还提供了生命周期方法来直接调用。

vm.$mount

如果 Vue 实例在实例化时没有收到 el 选项,则它处于“未挂载”状态,没有关联的 DOM 元素。可以使用 vm.$mount() 手动地挂载一个未挂载的实例。
如果没有提供 elementOrSelector 参数,模板将被渲染为文档之外的的元素,并且你必须使用原生 DOM API 把它插入文档中。
这个方法返回实例自身,因而可以链式调用其它实例方法。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  if (el === document.body || el === document.documentElement) {
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    // 获取template
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    // 编译template
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  // 执行 $mount 方法
  return mount.call(this, el, hydrating)
}

其实很简单,先获取html代码,然后执行 compileToFunctions 方法执行编译过程(具体编译过程在学习Render的时候再说)。

vm.$forceUpdate

迫使 Vue 实例重新渲染。注意它仅仅影响实例本身和插入插槽内容的子组件,而不是所有子组件。
   Vue.prototype.$forceUpdate = function () {
    var vm = this;
    if (vm._watcher) {
      vm._watcher.update();
    }
  };

这是强制更新方法,执行了 vm._watcher.update() 方法。

vm.$nextTick

将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用它,然后等待 DOM 更新。它跟全局方法 Vue.nextTick 一样,不同的是回调的 this 自动绑定到调用它的实例上。

找了找 vm.$nextTick 的代码

  // src/core/instance/render.js
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }

找到这个 nextTick 方法:

// src/core/util/next-tick.js
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

具体功能逻辑等学习完 render 再更新……

vm.$destroy

完全销毁一个实例。清理它与其它实例的连接,解绑它的全部指令及事件监听器。
触发 beforeDestroy 和 destroyed 的钩子。

关于$destroy 我们之前再说 destroyed 钩子的时候提到过了,这里就不再赘述。

  Vue.prototype.$destroy = function () {
    ……
  }

最后

首先说下过年博客计划,过年学习Vue各个模块的源码,并发布相应博客。另外还会发布一些前端知识的整理,便于下个月找工作~
然后,小结下自己看源码的一些小技巧:

  • 重点关注方法的执行、对象的实例化、对象属性的修改。
  • 忽略开发版本提示逻辑、内部变量赋值。
  • 有目标的看代码,根据主线目标进行源码学习。

OK,今天就这么多~ 明天去学习下Vue的事件源码!加油!明天见!

Vue.js学习系列

鉴于前端知识碎片化严重,我希望能够系统化的整理出一套关于Vue的学习系列博客。

Vue.js学习系列项目地址

本文源码已收入到GitHub中,以供参考,当然能留下一个star更好啦^-^。
https://github.com/violetjack/VueStudyDemos

关于作者

VioletJack,高效学习前端工程师,喜欢研究提高效率的方法,也专注于Vue前端相关知识的学习、整理。
欢迎关注、点赞、评论留言~我将持续产出Vue相关优质内容。

新浪微博: http://weibo.com/u/2640909603
掘金:https://gold.xitu.io/user/571...
CSDN: http://blog.csdn.net/violetja...
简书: http://www.jianshu.com/users/...
Github: https://github.com/violetjack

查看原文

清晖 收藏了文章 · 2020-06-10

Vue2.x - 子组件的实例化过程

Vue的其中一个核心思想为组件化,将页面拆分成不同的组件,独立了资源,利于开发和维护。前面讲了整个Vue的实例和挂载,但并没有详细记录子组件是怎么开始一轮生命周期的。

createComponent

回顾一下vnode的创建的过程:

  1. createElement
  2. _createElement
  3. createComponent
// 在 src/core/vdom/create-component.js 中:
export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {

  const baseCtor = context.$options._base
  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)
  
  // install component management hooks onto the placeholder node
  installComponentHooks(data)

  // return a placeholder vnode
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  return vnode
}

核心流程大致分为4步:

  • 创建Ctor(Vue子类构造函数)
  • 提取props
  • 安装组件钩子函数
  • 实例化vnode

Ctor

  const baseCtor = context.$options._base
  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

这里的关键在于弄清baseCtor是什么:

  • initGlobalAPI 中,我们定义了 Vue.options._base = Vue
  • Vue.prototype._init 中,将Vue.optionsoptions进行了合并。

          vm.$options = mergeOptions(
           resolveConstructorOptions(vm.constructor),
           options || {},
           vm
         )

所以这里的baseCtor就是Vue本身,而这里相当于执行了Vue.extend(Ctor)

  • Vue.extend
  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    const Super = this
    const SuperId = Super.cid
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }

    const name = extendOptions.name || Super.options.name
    if (process.env.NODE_ENV !== 'production' && name) {
      validateComponentName(name)
    }

    const Sub = function VueComponent (options) {
      this._init(options)
    }
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    Sub['super'] = Super

    // For props and computed properties, we define the proxy getters on
    // the Vue instances at extension time, on the extended prototype. This
    // avoids Object.defineProperty calls for each instance created.
    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // allow further extension/mixin/plugin usage
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use

    // create asset registers, so extended classes
    // can have their private assets too.
    ASSET_TYPES.forEach(function (type) {
      Sub[type] = Super[type]
    })
    // enable recursive self-lookup
    if (name) {
      Sub.options.components[name] = Sub
    }

    // keep a reference to the super options at extension time.
    // later at instantiation we can check if Super's options have
    // been updated.
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)

    // cache constructor
    cachedCtors[SuperId] = Sub
    return Sub
  }
}

这里用了经典原型继承的方式,构造了一个Vue的子类Sub,当实例化Sub的时候,就会调用_init方法,重新走到组件初始化创建的逻辑。

installComponentHooks

const hooksToMerge = Object.keys(componentVNodeHooks)

function installComponentHooks (data: VNodeData) {
  const hooks = data.hook || (data.hook = {})
  for (let i = 0; i < hooksToMerge.length; i++) {
    const key = hooksToMerge[i]
    const existing = hooks[key]
    const toMerge = componentVNodeHooks[key]
    if (existing !== toMerge && !(existing && existing._merged)) {
      hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge
    }
  }
}

遍历hooksToMerge,不断向data.hook插入componentVNodeHooks对象中对应的钩子函数,包括initprepatchinsertdestory
这一步就是安装组件钩子函数,等待patch过程时去执行。

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  },

  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  },

  insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    if (vnode.data.keepAlive) {
      if (context._isMounted) {
        // vue-router#1212
        // During updates, a kept-alive component's child components may
        // change, so directly walking the tree here may call activated hooks
        // on incorrect children. Instead we push them into a queue which will
        // be processed after the whole patch process ended.
        queueActivatedComponent(componentInstance)
      } else {
        activateChildComponent(componentInstance, true /* direct */)
      }
    }
  },

  destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy()
      } else {
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
}

实例化vnode

const name = Ctor.options.name || tag
const vnode = new VNode(
  `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
  data, undefined, undefined, undefined, context,
  { Ctor, propsData, listeners, tag, children },
  asyncFactory
)
return vnode

最终生成的vnode对象。

组件的patch

createElm的实现中,有下面这个判断:

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }
  // ...
}
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false /* hydrating */)
    }
    // after calling the init hook, if the vnode is a child component
    // it should've created a child instance and mounted it. the child
    // component also has set the placeholder vnode's elm.
    // in that case we can just return the element and be done.
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

let i = vnode.data 如果 i 有定义,则说明vnode是一个组件,最后i经过一系列的赋值指向了data.hook.init,然后执行 i(vnode, false),也就是执行了上面提到过的init钩子函数。

  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  },

这里的逻辑就是通过 createComponentInstanceForVnode 创建子组件实例,然后通过$mount挂载子组件。

export function createComponentInstanceForVnode (
  vnode: any,
  parent: any,
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  // ...
  return new vnode.componentOptions.Ctor(options)
}

首先看vnode.componentOptions,它是在new VNode()实例化vnode时,将Ctor作为参数传入的,上面也提到了,它其实就是Vue的子类构造器Sub,所以这里相当于在new Sub()创建子组件实例。这里用_isComponent标识为一个组件,它的用处是在_init()的时候会采取不同方式处理options

Vue.prototype._init = function (options?: Object) {
  const vm: Component = this
  // merge options
  if (options && options._isComponent) {
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  }
}

在前面关于Vue实例化的文章也提到过,Vue的合并策略是很慢的,当注册一个内部组件的时候不需要做特殊的处理,所以可以直接初始化内部组件提升性能。


可以注意到 child.$mount(hydrating ? vnode.elm : undefined, hydrating) 接管了子组件的挂载,又开始新的一轮renderupdatepatch,不断的递归,只到整颗树挂载完毕为止。由于这种递归的关系,在进行 insert(parentElm, vnode.elm, refElm)的时候,插入的顺序是先子后父。所以其实也可以得知了父子的生命周期是
父beforeCreate -> 父created -> 父beforeMount -> 子beforeCreate -> 子created -> 子beforeMount -> 子mounted -> 父mounted 这样的顺序。

查看原文

清晖 赞了问题 · 2020-05-25

解决CSRF如何发送跨域的Cookie的?

Cookie存在同源策略,不同的域名无法访问。
例如,有A,C两个网站,C网站为恶意网站,C网站是如何获得A网站的Cookie然后向A网站服务器发送请求的?

关注 3 回答 1

清晖 关注了专栏 · 2020-05-24

题叶

ClojureScript 爱好者.

关注 632

清晖 关注了问题 · 2020-05-09

react 如何获得上一次访问的路由路径?

routes = {
    path: '/p',
    indexRoute: {component: A},
    childRoutes: [
        {
            path: '/p/listA',
            component: A
        },
        {
            path: '/p/listB',
            component: B
        },
        {
            path: '/p/detail,
            component: C
        }
    ]
}

比如说从A或者B跳转到了C,
如何实现在C准确获得A或B的路由路径,
C与AB组件之间没有父子关系

关注 8 回答 6

清晖 赞了回答 · 2020-04-16

vue中的一个组件就是一个vue实例吗?

简单的讲,带uid的都是vue的实例。
根也叫根组件,和其他的组件没有本质区别,还有通过函数调用(像各个组件库的弹窗)这些,通过append讲dom添加到body里的也是。

为什么各个单文件组件没有new的过程,因为这个过程在你看不到的地方进行了(应该是在vue-loader里)

关注 11 回答 7

认证与成就

  • 获得 18 次点赞
  • 获得 15 枚徽章 获得 0 枚金徽章, 获得 5 枚银徽章, 获得 10 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-08-28
个人主页被 756 人浏览