React 学习笔记

曹至梧 2018-03-15 20:50 更新
  1. React 长什么样
  2. ReactElement 的定义
  3. createElement 的 DSL 方案 JSX
  4. 使用 state 处理数据单向绑定
  5. 事件处理
  6. 列表与 key
  7. 组件的各状态 Hook (生命周期)
  8. 外围函数
  9. DOM 元素自己的那些东西
  10. 测试工具
  11. 测试 API
  12. Redux

1. React 长什么样

React 是 facebook 开源出来的一套前端方案,官网在 https://reactjs.org

先看一个简单的样子:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
</head>
<body>
  <h1>Hello</h1>
  <div id="place"></div>
  <script type="text/javascript">
    var attribute = {
      className: 'text',
      style: {color: 'red'},
      id: 'me',
      href: 'http://' + 's.zys.me'
    };
    var inner = React.createElement('a', attribute, 'React');
    var instance = React.createElement('h1', null, '字符串在中间', inner);
    ReactDOM.render(instance, $('#place')[0]);
  </script>
</body>
</html>

从上面可以看出,它的使用其实跟其它很多框架的方式差不多,即,找到一个 DOM 节点,然后在这个节点上做事,类似 jQuery 的 $(dom).xxx({})

React.createElement 中,第一个参数是标签名,第二个是 config ,其中包括了节点属性(class 因为关键词问题改用 className) ,第三个及以后,是子级节点,或者说子级元素。

上面两个 createElement 得到的最终结果,是:

<h1>
    字符串在中间
    <a class="text" style="color: red" id="me" href="http://s.zys.me">React</a>
</h1>

当然,这里的重点,不是 React.createElement ,而是它返回的那个 instance ,事实上,从源码 https://unpkg.com/react@16.2.0/umd/react.development.js 中看的话,可以看出它返回的是一个 ReactElement ,这个 ReactElement 就是整个 React 体系于众不同的地方,否则它直接返回一个 DOM Element 就好了。

最后一行的 ReactDOM.render 就是处理 ReactElement 的, ReactDOM 的源码在 https://unpkg.com/react-dom@16.2.0/umd/react-dom.development.js , 这个东西自己实现了一套挂节点的树结构(所谓的 Virtual DOM),中间隔了一层再去处理真实的 DOM 渲染。这么做的好处是,因为自己有一套完整的树结构的,所以,可以在上面实现很多在原始 DOM 结构上不能做,或者不方便做的事。比如,异构渲染之类的。当然,局限的地方也很明显,本质上还是“节点”,其实没有一个往上的抽象,暴露的细节还是很多的,这种情况下,东西可以做得比较细,但是付出的成本也比较大,我是这样预测的。

2. ReactElement 的定义

最开始,已经简单演示了针对像 a h1 这类原生 DOM 元素的 ReactElement 定义, 自然,“能接受一个确定值的地方也应该可以接受一个函数”是 js 的一个惯例吧:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
</head>
<body>
  <div></div>
  <script type="text/javascript">
    function Component(props) {
      if(props.tag === 'h1') {
        return React.createElement('h1', null, '大标题');
      } else {
        return React.createElement('span', null, '普通文本');
      }
    }
    var instance = React.createElement(Component, {tag: 'h1'});
    ReactDOM.render(instance, $('div')[0]);
  </script>
</body>
</html>

上面代码的过程,大概是:

反正,我从源码中找了一长串,还是没搞明白 createElement() 第一个参数的 type 可以是,应该是一个什么东西。

从官方的文档来看 https://reactjs.org/docs/react-api.html#createelement ,这个 type 可以是三类东西:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
</head>
<body>
  <div></div>
  <script type="text/javascript">
    function Component(props) {
      return ['1', React.createElement('h1', null, props.text)];
    }
    var instance = React.createElement(Component, {text: '啊啊啊'});
    ReactDOM.render(instance, $('div')[0]);
  </script>
</body>
</html>

React 组件有两种类型,一种是简单的“函数式组件”,另一种是复杂点的“类组件”。

2.1. 函数式组件

函数式组件就是接受 props ,然后返回一个 React element

function Component(props) {
  return React.createElement('h1', null, props.text);
}

2.2. 类组件

类组件,是继承 React.Component ,然后重写一些方法:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16.2.0/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16.2.0/umd/react-dom.development.js"></script>
</head>
<body>
  <div></div>
  <script type="text/javascript">
    class MyComponent extends React.Component {
      render(){
        return React.createElement('h1', null, this.props.text);
      }
    }
    var instance = React.createElement(MyComponent, {text: '哈哈'});
    ReactDOM.render(instance, $('div')[0]);
  </script>
</body>
</html>

3. createElement 的 DSL 方案 JSX

前面提过,如果你想通过 createElement 构造一个类似:

<h1>
    字符串在中间
    <a class="text" style="color: red" id="me" href="http://s.zys.me">React</a>
</h1>

那么你需要的 js 大概是:

var attribute = {
  className: 'text',
  style: {color: 'red'},
  id: 'me',
  href: 'http://' + 's.zys.me'
};
var inner = React.createElement('a', attribute, 'React');
var instance = React.createElement('h1', null, '字符串在中间', inner);
ReactDOM.render(instance, $('#place')[0]);

看起来是比较麻烦的,于 React 中引入了一个新的东西,JSX ,即 JavaScript XML ,简单来说,就是 javascript 和 XML 的混写,上面的代码可以写成:

var inner = <a className="text" style={ {color: 'red'} } id="me" href={'http://' + 's.zys.me'}>React</a>;
var instance = <h1>字符串在中间 {inner}</h1>;
ReactDOM.render(instance, $('#place')[0]);

甚至是:

var inner = <a className="text" style={ {color: 'red'} } id="me" href={'http://' + 's.zys.me'}>React</a>;
ReactDOM.render(<h1>字符串在中间 {inner}</h1>, $('#place')[0]);

可以看出,比原来直接写 createElement 的方式要简洁得多, JSX 除了支持直接写 XML 风格的标签,还可以使用 {} 处理表达式。不过, JSX 并不是浏览器的标准,要让它跑在浏览器,需要在发布前专门编译到纯 js ,平时测试,也可以直接用 babel 来跑:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/babel-standalone@6.15.0/babel.js"></script>
</head>
<body>
  <h1>Hello</h1>
  <div id="place"></div>
  <script type="text/babel">
    var inner = <a className="text" style={ {color: 'red'} } id="me" href={'http://' + 's.zys.me'}>React</a>;
    ReactDOM.render(<h1>字符串在中间 {inner}</h1>, $('#place')[0]);
  </script>
</body>
</html>

注意,上在的 scripttype 写的是 text/babel 哦。

babel 也有命令行的工具,先安装:

npm install -g babel
npm install -g babel-cli
npm install -g babel-preset-react

然后对于一个 demo.jsx

var inner = <a className="text" style={ {color: 'red'} } id="me" href={'http://' + 's.zys.me'}>React</a>;
ReactDOM.render(<h1>字符串在中间 {inner}</h1>, $('#place')[0]);

可以使用 babel 编译它:

babel --presets=react demo.jsx

就可以看到标准的 js 输出了:

var inner = React.createElement(
  "a",
  { className: "text", style: { color: 'red' }, id: "me", href: 'http://' + 's.zys.me' },
  "React"
);
ReactDOM.render(React.createElement(
  "h1",
  null,
  "\u5B57\u7B26\u4E32\u5728\u4E2D\u95F4 ",
  inner
), $('#place')[0]);

babel 默认输出的到标准输出,可以通过 -o 输出到指定文件。

平时的前端项目,一般把 babelwebpack 结合使用,在构建过程处理 JSX 格式。

3.1. JSX 中的名字

JSX 中的名字,如果是自定义组件,名字开头需要大写,小写的是 DOM 标签。

名字中,可以带名字空间:

<MyComponent.A.B />

也可以是变量值:

let My = [MyComponent][0];
return <My />

但是,不能带表达式(下面是错误的):

<[MyComponent][0] />

提示一下, JSX 不是模板,它的行为,更像是“宏”。本质,还是 js 代码。 宏是没有运行时能力的,自然无法求值表达式。

4. 使用 state 处理数据单向绑定

前面已经提到过 props 了, props 一般处理初始化后就不改变的数据,对于一个组件中要变化的数据,需要放在 state 中处理,因为 React 专门设计了组件的 setState() 来维护 state (不要直接更改 state ,而是使用 setState()) ,同时 setState() 还处理节点渲染相关的事,通过函数调用,来实现“数据到展现”的单向绑定。

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/babel-standalone@6.15.0/babel.js"></script>
</head>
<body>
  <div id="place"></div>
  <script type="text/babel">

    class MyComponent extends React.Component {
      constructor(props) {
          super(props);
          this.state = {date: (new Date()).valueOf()};
          setInterval(() => this.setState({date: (new Date()).valueOf()}), 1000);
      }

      render(){
        return <h1>Hello {this.props.name} {this.state.date}</h1>;
      }

    }

    ReactDOM.render(<MyComponent name="哈哈" />, $('#place')[0]);
  </script>
</body>
</html>

4.1. setState

setState() 也可以接受一个函数,这个函数的参数第一个是当前的 state ,另一个是 props

class MyComponent extends React.Component {
  constructor(props) {
      super(props);
      this.state = {date: (new Date()).valueOf()};
      setInterval(() => this.setState((prevState, props) => ({date: prevState.date + 1000})), 1000);
  }

  render(){
    return <h1>Hello {this.props.name} {this.state.date}</h1>;
  }

}

ReactDOM.render(<MyComponent name="哈哈" />, $('#place')[0]);

5. 事件处理

React 自己维护了一个节点状态的树结构,并且,同时也自己实现了一套事件机制,只是这套事件,跟浏览器自身的区别不是很大:

class MyComponent extends React.Component {
  constructor(props) {
      super(props);
      this.state = {date: (new Date()).valueOf()};
      setInterval(() => this.setState((prevState, props) => ({date: prevState.date + 1000})), 1000);
  }

  handleClick = () => {
      console.log(this);
  }

  render(){
      return <h1 onClick={this.handleClick}>Hello {this.props.name} {this.state.date}</h1>;
  }

}

ReactDOM.render(<MyComponent name="哈哈" />, $('#place')[0]);

注意那个 handleClick 的写法:

handleClick = () => {
    console.log(this);
}

5.1. this 的问题

如果不用“箭头函数”把 this 固定住了,那么在使用时就需要显式绑定:

handlerClick(){
    console.log(this);
}

render(){
    return <h1 onClick={this.handleClick.bind(this)}>Hello {this.props.name} {this.state.date}</h1>;
}

React 自己的事件,是“驼峰式”的名字,比如 onClick ,而 onclick 则是浏览器自己的事件了。

其它支持的完整的事件列表,见: https://reactjs.org/docs/events.html

6. 列表与 key

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/babel-standalone@6.15.0/babel.js"></script>
</head>
<body>
  <div id="place"></div>
  <script type="text/babel">

    class MyComponent extends React.Component {
      constructor(props) {
          super(props);
          this.state = {itemList: [{name: 'abc', id: 'a'}, {name: 'oiii', id: 'b'}] };
      }

      handleClick(o){
        return (e) => {
          console.log(o);
        }
      }

      render(){
        return <div>
          {this.state.itemList.map( (o) => (<div key={o.id} onClick={this.handleClick(o)}>{o.name}</div>) ) }
          </div>
      }

    }

    ReactDOM.render(<MyComponent name="哈哈" />, $('#place')[0]);
  </script>
</body>
</html>

对于列表的渲染, React 要求节点上需要添加 key 属性,否则,会报 warn (是的,不会报错)。如果没有明确的 id 之类的标识,可以使用序列的索引值 index 。这个 key 属性的作用, React 会用于标识节点,以便在数据变更时,对应地可以追踪到节点的变更情况。

7. 组件的各状态 Hook (生命周期)

对于一个 React 组件,准备说,“类组件”,它在渲染及销毁过程中,每个状态转换时, React 都预留了相关的 Hook 函数,这些函数,一共 10 个,可以分成 4 类:

这几个方法的一些细节:

componentWillReceiveProps

componentWillUpdate

componentDidUpdate

除了上面讲的过程 Hook 函数,及前面用过的 setState() ,组件还有一个 forceUpdate(callback) 方法,它的调用可以触发 render() ,并且跳过 shouldComponentUpdate()

另外,在“类”层面,有两个属性, defaultProps 用于定义默认的 propsdisplayName ,用于定义调试时显示的名字。

试试各个过程的表现吧:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
</head>
<body>
  <div></div>
  <script type="text/babel">
    class MyComponent extends React.Component {
      constructor(props){
        super(props);
        this.state = {name: '初始化'};
        let that = this;
        setTimeout(function(){
          console.log('2s go ...');
          that.setState({name: '修改了的'});
        }, 2000);
        console.log('constructor');
      }

      componentWillMount(){
        console.log('componentWillMount');
      }

      componentDidMount(){
        console.log('componentDidMount');
      }

      componentWillReceiveProps(nextProps){
        console.log('componentWillReceiveProps');
      }

      shouldComponentUpdate(nextProps, nextState){
        console.log('shouldComponentUpdate');
        return true;
      }

      componentWillUpdate(nextProps, nextSate){
        console.log('componentWillUpdate');
      }

      componentDidUpdate(prevProps, prevState){
        console.log('componentDidUpdate');
      }

      componentWillUnmount(){
        console.log('componentWillUnmount');
      }

      componentDidCatch(error, info){
        console.log('componentDidCatch');
      }

      render(){
        console.log('render');
        if(this.props.error){throw Error()};
        return <h1>{this.state.name}</h1>;
      }

    }
    ReactDOM.render(<MyComponent />, $('div')[0]);

    setTimeout(function(){
      console.log('5s go ...');
      ReactDOM.render(<MyComponent />, $('div')[0]);
    }, 5000);

    setTimeout(function(){
      console.log('10s go ...');
      ReactDOM.render(null, $('div')[0]);
    }, 10000);

  </script>
</body>
</html>

上面的代码会输出:

constructor
componentWillMount
render
componentDidMount
2s go ...
shouldComponentUpdate
componentWillUpdate
render
componentDidUpdate
5s go ...
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate
render
componentDidUpdate
10s go ...
componentWillUnmount

上面过程看起来都很好理解的。

少的那个 componentDidCatch 是用来抓子组件错误的:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
</head>
<body>
  <div></div>
  <script type="text/babel">
    class ErrorWrapper extends React.Component {
      constructor(props){
        super(props);
        this.state = {error: null};
      }

      componentDidCatch(error, info){
        console.log('componentDidCatch');
        console.log(error, info);
        this.setState({error: {error: error, info: info}});
      }

      render(){
        if(!!this.state.error) {
          return <h1>Error Here</h1>
        } else {
          return this.props.children || '';
        }
      }
    }

    class MyComponent extends React.Component {
      render(){
        throw Error();
      }
    }

    ReactDOM.render(<ErrorWrapper><MyComponent /></ErrorWrapper>, $('div')[0]);

  </script>
</body>
</html>

上面代码占, MyComponent 的错误,会触发 ErrorWrappercomponentDidCatch ,参数中的 error 是一串调用栈字符串, info 是一个结构,里面有一个 componentStack 字符串记录了组件序列。

8. 外围函数

React 对于 Component 的处理,前面介绍得差不多了,这里说的外围函数,是指还没有脱离 DOM 的那几个工具,比如前面用到的 ReactDOM.render(child, container) 就是一个典型。

前 2 个方法是比较好理解的:

class MyComponent extends React.Component {
  render(){
    return <h1 onClick={this.clickHandler}>哈哈哈</h1>;
  }

  clickHandler = () => {
    var dom = ReactDOM.findDOMNode(this);
    console.log(dom);
    ReactDOM.unmountComponentAtNode($('div')[0]);
  }
}

ReactDOM.render(<MyComponent />, $('div')[0]);

8.1. ReactDOM.createPortal

ReactDOM.createPortal(child, container) 一般用在需要自己创建并维护真实 DOM 节点的地方:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
</head>
<body>
  <div></div>
  <script type="text/babel">

    class MyComponent extends React.Component {
      constructor(props){
        super(props);
        this.dom = document.createElement('div');
      }

      componentDidMount(){
        $('body').append($(this.dom));
      }

      componentWillUnmount(){
        $(this.dom).remove();
      }

      clickHandler = () => {
        var dom = ReactDOM.findDOMNode(this);
        console.log(dom);
        ReactDOM.unmountComponentAtNode($('div')[0]);
      }

      render(){
        return ReactDOM.createPortal(<div onClick={this.clickHandler}>{this.props.children}</div>, this.dom);
      }

    }

    ReactDOM.render(<MyComponent><h1>弹出来的东西</h1></MyComponent>, $('div')[0]);

  </script>
</body>
</html>

上面的代码演示了 ReactDOM.createPortal 的使用,需要注意的是,即使一个组件的 render() 实现,不是 ReactElement 而是 ReactDOM.createPortal ,组件本身,还是需要依附于父组件,或者 ReactDOM.render() 的,即坑位在占住,这种时候,就有点“它在那里,它又不在那里”的感觉。

9. DOM 元素自己的那些东西

React 的实现的基础是自己实现的一套“树状节点结构”,然后再把这个结构放到浏览器中用真实的 DOM 展现出来。那么这中间,自己搞的一套,与浏览器环境下的一套,之间肯定会有少许的差异的,虽然 React 连事件这种都自己实现了一遍。

首先,对于节点的属性来说, React 的属性都是“驼峰式”的名字,与 DOM 自己的一些全小写属性名不同,比如, onclick 变成了 onClicktabindex 变成了 tabIndex , SVG 中的属性 React 也支持。

属性名中的 class ,因为关键词因素,在 React 中变成了 className

style 在 React 中作了扩展实现的,给它一个对象值的话,可以得到正确的样式。

onChange 事件在 React 中的实现也作了增强,可以实时触发相应的回调。

label 标签的 for 属性,在 React 中可以使用 htmlFor 代替。

9.1. dangerouslySetInnerHTML

dangerouslySetInnerHTML 是一个特殊的属性,可以用于直接添加原始 HTML 内容:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
</head>
<body>
  <div></div>
  <script type="text/babel">

    class MyComponent extends React.Component {
      getHtml = () => {
        return {__html: '<h1 style="color: red">哈哈哈</h1>'};
      }

      render(){
        return <div dangerouslySetInnerHTML={this.getHtml()} />
      }

    }

    ReactDOM.render(<MyComponent />, $('div')[0]);

  </script>
</body>
</html>

9.2. ref

ref 也是 React 中的一个特殊属性,用于回调真实 DOM 节点的引用:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
</head>
<body>
  <div></div>
  <script type="text/babel">

    class MyComponent extends React.Component {
      clickHandler = () => {
        console.log(this.h1);
      }

      render(){
        return <h1 ref={(node) => {this.h1 = node}} onClick={this.clickHandler}>哈哈</h1>
      }

    }

    ReactDOM.render(<MyComponent />, $('div')[0]);

  </script>
</body>
</html>

ref 可以用于控制焦点,或者在节点上使用浏览器的其它一些 API ,比如音频,视频等。

10. 测试工具

React 因为有自己的一套树状态,所以,理论上它在测试方面有着得天独厚的优势,因为即使是在浏览器环境,不涉及 DOM 相关 API 的行为,只需要在那个树状态中的去操作,并检查操作后的状态就可以达到测试目的。当然,我们也不用关心 React 的一些实现细节,现在说测试,会有两方面的内容。一方面是 React 提供一些测试相关的工具方法,只是工具方法,如果你要建立完整的测试体系,还需要自己解决断言库,测试用例组件与调度等问题。另一方面,是看看非浏览器的渲染,如果可以完全摆脱浏览器环境,完成大部分的测试工作,那将是很美好的一件事。

11. 测试 API

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom-test-utils.development.js"></script>
</head>
<body>
  <div></div>
  <script type="text/babel">

    class MyComponent extends React.Component {
      constructor(props){
        super(props);
        this.state = {n: 0};
      }

      clickHandler = () => {
        this.setState((s) => ({n: s.n + 1}));
      }

      render(){
        return <h1 onClick={this.clickHandler}>{ this.state.n }</h1>
      }

    }

    let component = ReactTestUtils.renderIntoDocument(<MyComponent />);
    console.log(component);
    let node = ReactDOM.findDOMNode(component)
    console.log(component.state);
    ReactTestUtils.Simulate.click(node);
    console.log(component.state);

    console.log(ReactTestUtils.isElement(<MyComponent />));
    console.log(ReactTestUtils.isElementOfType(<MyComponent />, MyComponent));
    console.log(ReactTestUtils.isDOMComponent(ReactTestUtils.renderIntoDocument(<h1>xx</h1>)));
    console.log(ReactTestUtils.isCompositeComponent(component));
    console.log(ReactTestUtils.isCompositeComponentWithType(component, MyComponent));
    console.log(ReactTestUtils.findRenderedDOMComponentWithTag(component, 'h1'));
    console.log(ReactTestUtils.findRenderedComponentWithType(component, MyComponent));

  </script>
</body>
</html>

引入额外的资源文件,相关的方法在浏览器是完全可以用的。

不过,完成的测试体系的话,还需要搭配其它工具来完成, React 的相关 API 只是提供了获取信息的方法。

11.1. 非浏览器渲染

在 nodejs 环境,可以使用 react-test-renderer 来完成对相关组件的处理,并按树结构遍历结果:

const React = require('react');
const TestRenderer = require('react-test-renderer');

class MyComponent extends React.Component {
  constructor(props){
    super(props);
    this.state = {number: 0};
  }
  render(){
    return <h1 onClick={ () => {this.setState((s) => ({number: s.number + 1}))} }>{this.state.number}</h1>;
  }
}


const testRenderer = TestRenderer.create(<MyComponent />);
testRenderer.root.findByType('h1').props.onClick();
console.log(testRenderer.root.findByType('h1').children);
console.log(testRenderer.root.instance.state);

这部分完整的 API 在 https://reactjs.org/docs/test-renderer.html

在非浏览器环境下,“模拟点击”这种操作是没有意义的,但是 onClick 本身并没有说一定是“点击”,它只是指明了一个回调函数而已,浏览环境下是点击触发,那么纯编程环境下,直接调用即可。

12. Redux

12.1. 基本概念

Redux 并不是 React 必不可少的一部分,它只是随着 React 出来的一套模式,并且带了一个这个模式的实现: https://redux.js.org/ 。 简单来说,这个模式是着眼于“组件状态及组件间通信”的,换句话,它定义了一种“相对独立的组件句柄”。

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>Redux</title>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
</head>
<body>

  <script type="text/javascript">
    var store = Redux.createStore(function reducer(state, action){
      console.log('here', state);
      if(action.type === 'HAHA'){ return 'OK' }
      return 'ERROR';
    });
    console.log(store.getState());
    store.subscribe(function render(){
      console.log('subscribe');
      console.log(store.getState());
    })
    store.dispatch({type: 'HAHA'});
  </script>

</body>
</html>

Redux 的流程本身非常简单,如上所示,通过一个 reducer 初始化一个 store , 这个 storesubscribe 一些动作,然后使用时在需要的时候 dispatch 出一个 action 就好了。 dispatch 的对应逻辑是 reducer 中的事,而 reducer 执行完就轮到 subscribe 的回调了,就是这样的一个执行循环。

12.2. React-Redux

React-ReduxReduxReact 上的一个实现,直观点说,就是结合了 React 的 Component 机制的一套 Redux 流程。它的核心点,我个人的理解是下面几点:

具体代码:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/redux.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-redux.min.js"></script>
</head>
<body>
  <div id="app"></div>
  <script type="text/babel">

    const Title = props => (
      <h1 onClick={props.onClick}>{props.name}</h1>
    )

    const mapStateToProps = state => {
      return {...state};
    }

    const mapDispatchToProps = dispatch => {
      return {
        onClick: id => {
          dispatch({type: "CLICK"});
        }
      }
    }

    const VisibleTitle = ReactRedux.connect( mapStateToProps, mapDispatchToProps )(Title);

    let store = Redux.createStore( (state, action) => {
      if(action.type === 'CLICK'){
        return {name: '点击之后的'};
      }
      return {name: '初始化的'};
    });

    const App = () => (
      <ReactRedux.Provider store={store}>
        <VisibleTitle />
      </ReactRedux.Provider>
    )

    ReactDOM.render(<App />, document.getElementById('app'));
  </script>
</body>
</html>

上面的组件实现的效果很简单,就是点击之后,改变文本。但是经过 React-Redux 分拆之后,感觉复杂了好多。不过,直观地也可以看出,分拆之后, Title 这个组件变“纯”了,它的行为一目了解,没有什么“点击之后,改变文本”这类逻辑,只有单纯的接受输入,然后渲染组件。操作上的逻辑,则由外层的 VisibleTitle 来完成。

VisibleTitle 通过它的 state 状态性数据,来控制下面的 Title 的渲染。 state 则是通过 Redux 的 store 来承接的。进一步, stateTitleprops ,可能只是 Title 需要输入的一部分(数据部分),另一部分涉及“反馈”到 state 变化的 props ,则由 Redux 的 dispatch - action 来承接。

说的这两部分,也就对应着 mapStateToPropsmapDispatchToProps ,一个处理数据,一个调度 dispatch 的执行。然后使用 connect 这个现成的方法生成包裹在 Title 之上的 VisibleTitle

当然,事情到这里还没有结束,再上层,还有一个 storeProvider 是 React-Redux 提供的东西,目的是给里面的组件提供 store 。而得到 store ,又需要 Reducerdispatch 出的 action ,及 state 的变化实际上是在这里处理。

12.3. Redux 中间件

Redux 的设计中,所有的状态变化是通过 dispatch 这一个点完成的(并且这个 API 又非常简单),自然,围绕这一个点,就可以很容易完成“套圈”。

let next = store.dispatch;
store.dispatch = function(action){
    console.log(action);
    next(action);
}

这都完全不需要特别设计,你自己就可以随便搞。

不过从 Monkeypatching 作为切入,官方的 https://redux.js.org/docs/advanced/Middleware.html 这份文档写得非常好,循序渐进,推荐一读。

最后,官方 API 在这上面的支持,或者说推荐作法,是通过高阶函数定义 Middleware ,然后使用 applyMiddleware 挂到 store 里面去。

const logger = store => next => action => {
  console.log('dispatching', action, store.getState());
  let result = next(action);
  console.log('next state', store.getState());
  return result
}

这是定义,使用时:

import { createStore, combineReducers, applyMiddleware } from 'redux'
let store = createStore( reducer, applyMiddleware(logger) )

用前面的简单例子可以试试:

function logger(store) {
  return function(next){
    return function(action){
      console.log('dispatching', action, store.getState());
      var result = next(action);
      console.log('next state', store.getState());
      return result
    }
  }
}

var store = Redux.createStore(function reducer(state, action){
  if(action.type === 'HAHA'){ return 'OK' }
  return 'ERROR';
}, Redux.applyMiddleware(logger));

store.subscribe(function render(){
  console.log('sub', store.getState());
})
store.dispatch({type: 'HAHA'});

12.4. 异步流程与 redux-promise

了解了中间件,再来看异步流程,就没什么新问题了。

Redux 中,state 的维护是在 reducer 中的,从前面的例子可以看出, reducer 本身的设计,是没有考虑异步情况的,它的结构: (state, action) => newState 是同步的。

如果我们在某个 dispatch 之后,需要异步获取数据(事实上这很常见),并把这个东西更新到 store 中去,我们就需要额外做点事,比较很容易想到的一个办法:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>Redux</title>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
</head>
<body>

  <script type="text/javascript">
    var store = Redux.createStore(function reducer(state, action){
      if(action.type === 'HAHA'){
        setTimeout(function(){
          store.dispatch({type: 'FETCH', payload: '123'});
        }, 3000);
        return 'LOADING';
      }
      if(action.type === 'FETCH'){
        return action.payload;
      }
      return 'ERROR';
    });
    store.subscribe(function render(){
      console.log('sub', store.getState());
    })
    store.dispatch({type: 'HAHA'});
  </script>

</body>
</html>

简单来说,在 dispatch 之后,我们异步发出请求,并在当前至少有对 dispatch 的引用,然后返回一个中间状态的 state ,甚至是没有任何更改的 state 。在异步响应之后,再次 dispatch ,同时把异步响应的数据放到 action 中传递出去。

这种方式,虽然绕了一点,但是本身没毛病,不过要多定义出来一个 dispatch 类型,是很烦人就是了。

考虑前面讲过的“中间件”机制,我们很容易对 dispatch 的行为作一些扩展,加入对异步的支持。

先看“中间件”的样子:

const logger = store => next => action => {
  console.log('dispatching', action, store.getState());
  let result = next(action);
  console.log('next state', store.getState());
  return result
}

很显然,对于 dispatch 传入的 action 这个东西,我们可以先判断它的特征,或者说状态,根据既定规则决定在什么时候作 next(action)

事实上, redux-promise 的代码就几行:

import { isFSA } from 'flux-standard-action';

function isPromise(val) {
  return val && typeof val.then === 'function';
}

export default function promiseMiddleware({ dispatch }) {
  return next => action => {
    if (!isFSA(action)) {
      return isPromise(action)
        ? action.then(dispatch)
        : next(action);
    }

    return isPromise(action.payload)
      ? action.payload.then(
          result => dispatch({ ...action, payload: result }),
          error => {
            dispatch({ ...action, payload: error, error: true });
            return Promise.reject(error);
          }
        )
      : next(action);
  };
}

做的事,就是当 dispatch 出去的东西,里面有一个 then 成员,并且这个成员是一个函数的话,那么 reducer 会返回这个函数的调用结果,并且调用这个函数时传入的是当前的 dispatch

简单演示一下大概是:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>Redux</title>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
</head>
<body>

  <script type="text/javascript">
    var store = Redux.createStore(function reducer(state, action){
      if(!!action.then){
        return action.then(store.dispatch.bind(store));
      }
      return 'GOT: ' + action.payload;
    });
    store.subscribe(function render(){
      console.log('sub', store.getState());
    })

    store.dispatch({type: '123', payload: 'null'});
    setTimeout(function(){
      store.dispatch({type: 'async', then: function(dispatch){
        setTimeout(function(){
          dispatch({type: 'xxx', payload: 'async data'});
        }, 3000);
      }});
    }, 2000);

  </script>

</body>
</html>

12.5. Redux 流程中的重复计算问题

前面一个例中,有一个 mapStateToProps ,把例子改一点点看看:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/redux.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-redux.min.js"></script>
</head>
<body>
  <div id="app"></div>
  <script type="text/babel">

    const getName = (name, count) => {
      console.log('getName');
      return name + count;
    }

    const Title = props => (
      <div>
        <h1 onClick={props.onClick}>{props.name}</h1>
        <h1 onClick={props.onOtherClick}>另外的 {props.name}</h1>
      </div>
    )

    const mapStateToProps = (state, props) => {
      console.log('mapStateToProps');
      return {
        name: getName(state.name, state.count)
      }
    }

    const mapDispatchToProps = dispatch => {
      return {
        onClick: () => {
          dispatch({type: "CLICK"});
        },
        onOtherClick: () => {
          dispatch({type: "OTHER_CLICK"});
        }
      }
    }

    const VisibleTitle = ReactRedux.connect( mapStateToProps, mapDispatchToProps )(Title);

    let store = Redux.createStore( (state = {name: '初始值', count: 0}, action) => {
      if(action.type === 'CLICK'){
        return { ...state, name: '点击之后的', count: state.count + 1};
      }
      if(action.type === 'OTHER_CLICK'){
        return { ...state, other: '什么东西'};
      }
      return { ...state };
    });

    const App = () => (
      <ReactRedux.Provider store={store}>
        <VisibleTitle />
      </ReactRedux.Provider>
    )

    ReactDOM.render(<App />, document.getElementById('app'));
  </script>
</body>
</html>

上面的例子,简单来说,“纯组件”需要一个 name 来显示,而这个 name 的来自于外套组件的 store 中的 namecount 连在一起的。

执行这个例子,可以看出一个重复执行的问题,当你点击第二行“另外的”那块文本时,是做的 onOtherClick 回调,这个过程并不会更改 count 值,也就是说,“纯组件”需要的 name 并不会因为这个操作而有所改变。但是, getName() 这个函数却还是一遍又一遍地执行了,这就是所谓的重复执行的问题。(如果这样的函数很多,会影响到页面性能的,特别是这些函数的逻辑有一点复杂的时候)

其实看到这里,我个觉得 React 中的这个问题还是非常尴尬的,它自己内部的虚拟节点,靠自己的 diff 算法实现高效的更新策略,但是外面的 state 本身的维护又成了可能的问题。

回到重复执行的问题,它并没有什么本质的解决办法,只可能进一步拆分 getName() ,分离变量的情况下,作一些执行层面的优化,简单说,先作变量是否变化的判断,然后再决定是否继续执行。这样用额外判断的成本,换取可能的省略后继执行的效益。大概是:

const getName = ( () => {
  let lastName = null;
  let lastCount = null;
  let lastResult = null;
  return (name, count) => {
    console.log('getName');
    if(name === lastName && count === lastCount){ return lastResult }
    lastName = name;
    lastCount = count;
    lastResult = name + count;
    return lastResult;
  }
})();

这个样子,像是中间加了一个缓存层。

把这个形式封装一下,就是说先把“变量”的获取提取出来,然后加一个中间缓存层。官方推荐的实现是 reselect ,做的事就是这样的: https://github.com/reactjs/reselect

reselect 把前面的代码调整一下,就是:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>React</title>
<script crossorigin src="https://s.zys.me/js/jq/jquery.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-dom.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/babel.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/redux.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/react-redux.min.js"></script>
<script crossorigin src="https://s.zys.me/js/react/reselect.min.js"></script>
</head>
<body>
  <div id="app"></div>
  <script type="text/babel">

    const getName = Reselect.createSelector(
      [
        (state, props) => {console.log('reselect'); return state.name},
        (state, props) => state.count
      ],
      (name, count) => {
        console.log('getName');
        return name + count
      }
    );

    const Title = props => (
      <div>
        <h1 onClick={props.onClick}>{props.name}</h1>
        <h1 onClick={props.onOtherClick}>另外的 {props.name}</h1>
      </div>
    )

    const mapStateToProps = (state, props) => {
      console.log('mapStateToProps');
      return {
        name: getName(state, props)
      }
    }

    const mapDispatchToProps = dispatch => {
      return {
        onClick: () => {
          dispatch({type: "CLICK"});
        },
        onOtherClick: () => {
          dispatch({type: "OTHER_CLICK"});
        }
      }
    }

    const VisibleTitle = ReactRedux.connect( mapStateToProps, mapDispatchToProps )(Title);

    let store = Redux.createStore( (state = {name: '初始值', count: 0}, action) => {
      if(action.type === 'CLICK'){
        return { ...state, name: '点击之后的', count: state.count + 1};
      }
      if(action.type === 'OTHER_CLICK'){
        return { ...state, other: '什么东西'};
      }
      return { ...state };
    });

    const App = () => (
      <ReactRedux.Provider store={store}>
        <VisibleTitle />
      </ReactRedux.Provider>
    )

    ReactDOM.render(<App />, document.getElementById('app'));
  </script>
</body>
</html>

Reselect.createSelector 是一个高阶函数,它接受的第一个参数,是一个列表,里面就是分拆出来的,获取“变量”的方法,第二个参数就是接受变量的主要逻辑。因为变量已经明确拆分出来的,所以自然地,针对变量作判断并应用缓存机制就好办了。

这里注意一下,虽然表面上 getName() 的调用少了,但是却额外多了 reselect 的调用,本质上来说,只是改善的重复调用的损耗,并没有避免它。

评论
©2010-2018 zouyesheng.com All rights reserved. Powered by GitHub , txt2tags , MathJax