我们是如何做视图和视图逻辑分离的

#react#js

本文将分享一下过去一年里,我们项目是如何做视图与视图逻辑抽离的。

什么是视图?什么是逻辑?

正所谓视图就是身为一个用户可见到的图像,对于这个图像来说它正是广为流传的 view = f(data)。这个公式精确的表达了视图就像是一个函数一般,输入即输出,所见即所得,没有任何副作用。

<div>
    <input placeholder="修改名字" onChange={handleChange} />
    <p>姓名:{username}</p>
<div>

以上这样一段DOM代码,它就是我们所见到的视图,它是纯粹的,给了什么就会渲染什么。当用户有了交互的事件,数据有了变化,就会渲染新的视图。

而视图逻辑就是我们前端工程师对视图的显示,对视图的 data 进行的处理。它可能是来自于服务端,可能是来自于本地,亦或者是来自于用户自有的操作行为。一切让数据改变,或者对数据进行操作的行为等等,这些都是我们的业务逻辑。

username = api.getUserName()

handleChange(e) {
    this.username = e.target.value;
}

过去的组件

按照惯例,我们会习惯于把视图和业务逻辑都写在一起,当视图越来越庞大或者逻辑越来越复杂。就会让我们的代码越来越不易维护和测试。比如这样的代码

class Demo extends React.Component<Props> {

  constructor(props: Props) {
    super(props);
    this.state = {
      data: null,
    }
  }

  async componentDidMount() {
    const ret = http.get(`/api/xx/${this.props.id}`);
    this.setState({
      data: ret.data,
    })
  }

  handleClick = () => {
    // do something...
  }

  // other methods...

  render() {

    return (
      <div>
        <p onClick={this.handleClick}>click</p>
        {this.state.data}
      </div>
    )
  }
}

通常我们一个组件的实现大致都长这样,随着业务逻辑复杂,我们Demo组件需要存放的属性和方法也越来越多。我们的dom结构也越来越大。如何抽象封装这样的组件,如何提取我们的业务逻辑,组织出更加可维护易测试的代码,成为大型项目的关键。

改进方案

首先我们可以基于MVP或者MVC的思想,把视图和逻辑抽离分别分为两个文件。(以下的示例代码仅作为思想,不一定能实际运行。)

import { DemoPresenter } from './Demo.presenter';

class Demo extends React.Component<Props> {

  constructor(props: Props) {
    super(props);
    this.presenter = new DemoPresenter(props);
  }

  async componentDidMount() {
    const { fetchData } = this.presenter;
    await fetchData();
  }

  render() {
    const { handleClick, data } = this.presenter;
    return (
      <div>
        <p onClick={handleClick}>click</p>
        {data}
      </div>
    )
  }
}
class DemoPresenter {

  data = {};

  constructor(props: Props) {
    this.props = props;
  }

  fetchData = async () => {
    const { id } = this.props;
    const ret = await http.get(`/api/xx/${id}`);
    this.data = ret.data;
  }

  handleClick = () => {
    // do something...
  }
}

自此我们尝试着把视图和业务逻辑抽离成了两个文件,分别用两个class来维护。降低了视图和逻辑之间的耦合。另外从测试的角度来说,我们可以剥离视图,单独为我们的业务逻辑写UT,这就极大的降低了测试的成本。

看到这里大家有没有发现这特别像某一种代码?其实这不就像是mobx里的inject store吗?把我们手动new Presenter的过程通过inject来完成。

@inject('demoPresenter')
class AppComp extends React.Component<Props> {
  render() {
    const { demoPresenter } = this.props;
    const { handleClick, data } = demoPresenter;

    return (
      <div>
        <p onClick={handleClick}>click</p>
        {data}
      </div>
    )
  }
}

我们是否可以根据这个启发继续摸索呢?

改进一下

mobx是基于providerinject来实现的。而providerinject又是基于react的context。这里就有一个问题,mobx主要为我们做全局状态管理。而我们需要的仅仅是局部的视图和逻辑抽离。又应该如何做呢?

站在mobx的肩膀上,我们来实现一个高阶组件做我们presenter注入。而这两年MVVM这么火,再加上presenter这单词着实麻烦,我们换个名称好了。把我们的视图逻辑层就叫ViewModel吧,当然这里指的是广义上的VM

import React from 'react';

function withViewModel<P = {}>(
  Component: React.ComponentType<any>,
  ViewModel: new (...args: any[]) => any,
) {
  return class withViewModelComp extends React.Component<Omit<P, 'vm'>> {
    vm: any;
    constructor(props: Omit<P, 'vm'>) {
      super(props);
      this.vm = new ViewModel(props);
    }

    render() {
      return <Component {...this.props} vm={this.vm} />;
    }
  };
}

export { withViewModel };

看下我们这个高阶组件的实现非常简单,自动帮我们new一下VM,然后传递给我们需要的组件。我们的组件就可以这样使用

import React from 'react';
import { observer } from 'mobx-react';
import { withViewModel } from '../../hoc';
import { TestVM } from './TestVM';
import { Props } from './types';

@observer
class TestComp extends React.Component<Props> {
  render() {
    const { vm } = this.props;

    return <div onClick={vm.setUserName}>{vm.userName}</div>;
  }
}

// 绑定我们的组件和VM
const Test = withViewModel<Props>(TestComp, TestVM);

export { Test };
// Test.VM.ts
import { observable, action, computed } from 'mobx';

class TestVM {
  @observable userName = '二哲1号';

  @action
  setUserName = () => {
    this.userName = '二哲2号';
  };
}

export { TestVM };

基于mobx,我们现在达到了视图与视图逻辑抽离的目标了。但是有没有发现这样的代码其实还是有问题的?我们虽然在hoc里new VM的时候把props传递进去了,但那是静态的,如果我们写了一段computed是不会生效的。

// Test.VM.ts
import { observable, action, computed } from 'mobx';

class TestVM {
  @observable userName = '二哲1号';
  @observable props: any;

  constructor(props: any) {
    this.props = props;
  }

  @computed
  get someValue() {
    return this.props.value + this.userName;
  }

  @action
  setUserName = () => {
    this.userName = '二哲2号';
  };
}

export { TestVM };

如果我们父组件传递的value props变化了,someValue是拿不到最新的值的。接着我们来修复这个问题。

进阶版

在我初次思考这个问题的时候,我本以为是无解的。因为我们如论如何都需要把props传递给我们VM才行,那一定就是静态的。但如何能与我们的 VM绑定起来就成为了一个关键

这就意味着要处理两件事情。第一个问题是收集props依赖,第二个则是当props变化了,我们传递进VM里的props需要得到相应。

最后我在mobx源码中得到了灵感。https://github.com/mobxjs/mobx-react-lite/blob/master/src/useAsObservableSource.ts#L20-L30

import React from 'react';
import { observable, runInAction, IObservableObject } from 'mobx';

function withViewModel<P = {}>(
  Component: React.ComponentType<any>,
  ViewModel: new (...args: any[]) => any,
) {
  return class withViewModelComp extends React.Component<Omit<P, 'vm'>> {
    vm: any;
    vmProps: IObservableObject;
    constructor(props: Omit<P, 'vm'>) {
      super(props);
      // 转为mobx 观察对象
      this.vmProps = observable(props, {}, { deep: false });
      // 传递引用
      this.vm = new ViewModel(this.vmProps);
    }

    componentDidUpdate() {
      // props变化的时候,重新更新一下我们的观察对象
      runInAction(() => {
        Object.assign(this.vmProps, this.props);
      });
    }

    render() {
      return <Component {...this.props} vm={this.vm} />;
    }
  };
}

export { withViewModel };

新增三行代码,通过mobx的observable和runInAction我们很容易就可以完成我们的目的。

hook

刚刚我们都是基于class实现的,hook这么火爆,当然也少不了我们hook版本。hook实现相对来说就简单许多了。

import { useMemo } from 'react';
import { useAsObservableSource } from 'mobx-react-lite';

function useVM<T>(VM: new (...args: any[]) => T, props: any = {}) {
  const source = useAsObservableSource(props);
  return useMemo(() => new VM(source), []);
}
const HookComponent = (props: Props) => {
  const vm = useVM<HookVM>(HookVM, props);

  return (
    <div onClick={vm.setUserName}>
      hook 组件 组件内部数据 = {vm.userName} 父组件传入数据 = {vm.name111}
    </div>
  );
};

总结

  1. 本文我们通过两个例子对比,可以深刻意识到视图和逻辑分离的重要性
  2. 通过mobx的启发,我们分别实现了基于Class/SFC和hook的视图逻辑分离方案
  3. 视图和逻辑分离可以更好的锻炼我们封装抽象思维,写出可维护性更强的代码
  4. 视图和逻辑分离更加易于测试,可以单独测试视图或者逻辑

代码示例地址在:https://github.com/MeCKodo/view-and-view-logic 个人网站:http://www.meckodo.com

深入理解webpack的require.context

#webpack

前言

require.context 其实是一个非常实用的 api。但是 3-4 年过去了,却依旧还有很多人不知道如何使用。

而这个 api 主要为我们做什么样的事情?它可以帮助我们动态加载我们想要的文件,非常灵活和强大(可递归目录)。可以做 import 做不到的事情。今天就带大家一起来分析一下,webpack 的 require.context是如何实现的。

准备工作

在分析这个 api 之前呢,我们需要先了解一下一个最简单的文件,webpack 会编译成啥样。

-- src
    -- index.ts
// index.ts
console.log(123)

编译之后,我们可以看见 webpack 会编译成如下代码

// 源码 https://github.com/MeCKodo/require-context-sourece/blob/master/simple-dist/bundle-only-index.js
 (function(modules) { // webpackBootstrap
     // The module cache
     var installedModules = {};
     // The require function
     function __webpack_require__(moduleId) {
         // Check if module is in cache
         if(installedModules[moduleId]) {
             return installedModules[moduleId].exports;
         }
         // Create a new module (and put it into the cache)
         var module = installedModules[moduleId] = {
             i: moduleId,
             l: false,
             exports: {}
         };
         // Execute the module function
         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
         // Flag the module as loaded
         module.l = true;
         // Return the exports of the module
         return module.exports;
     }
     // expose the modules object (__webpack_modules__)
     __webpack_require__.m = modules;
     // expose the module cache
     __webpack_require__.c = installedModules;
     // define getter function for harmony exports
     __webpack_require__.d = function(exports, name, getter) {
         if(!__webpack_require__.o(exports, name)) {
             Object.defineProperty(exports, name, {
                 configurable: false,
                 enumerable: true,
                 get: getter
             });
         }
     };
     // define __esModule on exports
     __webpack_require__.r = function(exports) {
         Object.defineProperty(exports, '__esModule', { value: true });
     };
     // getDefaultExport function for compatibility with non-harmony modules
     __webpack_require__.n = function(module) {
         var getter = module && module.__esModule ?
             function getDefault() { return module['default']; } :
             function getModuleExports() { return module; };
         __webpack_require__.d(getter, 'a', getter);
         return getter;
     };
     // Object.prototype.hasOwnProperty.call
     __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
     // __webpack_public_path__
     __webpack_require__.p = "";
     // Load entry module and return exports
     return __webpack_require__(__webpack_require__.s = "./src/index.ts");
 })
 ({
 "./src/index.ts": (function(module, exports) {
      console.log('123');
    })
 });

初次一看是很乱的,所以为了梳理结构,我帮大家去除一些跟本文无关紧要的。其实主要结构就是这样而已,代码不多为了之后的理解,一定要仔细看下每一行

// 源码地址 https://github.com/MeCKodo/require-context-sourece/blob/master/simple-dist/webpack-main.js

(function(modules) {
  // 缓存所有被加载过的模块(文件)
  var installedModules = {};
  // 模块(文件)加载器 moduleId 一般就是文件路径
  function __webpack_require__(moduleId) {
    // 走 cache
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache) 解释比我清楚
    var module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    });
    // 执行我们的模块(文件) 目前就是 ./src/index.ts 并且传入 3 个参数
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );
    // Flag the module as loaded 解释比我清楚
    module.l = true;
    // Return the exports of the module 解释比我清楚
    return module.exports;
  }
  // 开始加载入口文件
  return __webpack_require__((__webpack_require__.s = './src/index.ts'));
})({
  './src/index.ts': function(module, exports, __webpack_require__) {
    console.log('123');
  }
});

__webpack_require__ 就是一个模块加载器,而我们所有的模块都会以对象的形式被读取加载

modules = {
    './src/index.ts': function(module, exports, __webpack_require__) {
       console.log('123');
    }
}

我们把这样的结构先暂时称之为 模块结构对象

正片

了解了主体结构之后我们就可以写一段require.context来看看效果。我们先新增 2 个 ts 文件并且修改一下我们的 index.ts,以便于测试我们的动态加载。

--- src
    --- demos
        --- demo1.ts
        --- demo2.ts
    index.ts
// index.ts
// 稍后我们通过源码分析为什么这样写
function importAll(contextLoader: __WebpackModuleApi.RequireContext) {
  contextLoader.keys().forEach(id => console.log(contextLoader(id)));
}

const contextLoader = require.context('./demos', true, /\.ts/);
importAll(contextLoader);

查看我们编译后的源码,发现多了这样一块的 模块结构对象

// 编译后代码地址 https://github.com/MeCKodo/require-context-sourece/blob/master/simple-dist/contex-sync.js#L82-L113
{
'./src/demos sync recursive \\.ts': function(module, exports, __webpack_require__) {
  var map = {
    './demo1.ts': './src/demos/demo1.ts',
    './demo2.ts': './src/demos/demo2.ts'
  };

  // context 加载器,通过之前的模块加载器 加载模块(文件) 
  function webpackContext(req) {
    var id = webpackContextResolve(req);
    var module = __webpack_require__(id);
    return module;
  }

  // 通过 moduleId 查找模块(文件)真实路径
  // 个人在这不喜欢 webpack 内部的一些变量命名,moduleId 它都会编译为 request
  function webpackContextResolve(req) {
    // id 就是真实文件路径
    var id = map[req];
    // 说实话这波操作没看懂,目前猜测是 webpack 会编译成 0.js 1.js 这样的文件 如果找不到误加载就出个 error
    if (!(id + 1)) {
      // check for number or string
      var e = new Error('Cannot find module "' + req + '".');
      e.code = 'MODULE_NOT_FOUND';
      throw e;
    }
    return id;
  }

  // 遍历得到所有 moduleId
  webpackContext.keys = function webpackContextKeys() {
    return Object.keys(map);
  };
  // 获取文件真实路径方法
  webpackContext.resolve = webpackContextResolve;
  // 该模块就是返回一个 context 加载器
  module.exports = webpackContext;
  // 该模块的 moduleId 用于 __webpack_require__ 模块加载器
  webpackContext.id = './src/demos sync recursive \\.ts';
}

我在源码中写了很详细的注释。看完这段代码就不难理解文档中所说的require.context 会返回一个带有 3 个API的函数(webpackContext)了。

require.context api

接着我们看看编译后 index.ts 的源码

'./src/index.ts': function(module, exports, __webpack_require__) {
  function importAll(contextLoader) {
    contextLoader.keys().forEach(function(id) {
      // 拿到所有 moduleId,在通过 context 加载器去加载每一个模块
      return console.log(contextLoader(id));
    });
  }
  var contextLoader = __webpack_require__(
    './src/demos sync recursive \\.ts'
  );
  importAll(contextLoader);
}

很简单,可以发现 require.context 编译为了 __webpack_require__加载器并且加载了 id 为./src/demos sync recursive \\.ts 的模块,sync表明我们是同步加载这些模块(之后我们在介绍这个参数),recursive 表示需要递归目录查找。自此,我们就完全能明白 webpack 是如何构建所有模块并且动态加载的了。

进阶深入探究 webpack 源码

我们知道 webpack 在 2.6 版本后,在加载模块时,可以指定 webpackMode 模块加载模式,我们能使用几种方式来控制我们要加载的模块。常用的 mode一般为sync lazy lazy-once eager

所以在 require.context 是一样适用的,我们如果查看一下@types/webpack-env就不难发现它还有第四个参数。

简要来说

  • sync 直接打包到当前文件,同步加载并执行
  • lazy 延迟加载会分离出单独的 chunk 文件
  • lazy-once 延迟加载会分离出单独的 chunk 文件,加载过下次再加载直接读取内存里的代码。
  • eager 不会分离出单独的 chunk 文件,但是会返回 promise,只有调用了 promise 才会执行代码,可以理解为先加载了代码,但是我们可以控制延迟执行这部分代码。

文档在这里 https://webpack.docschina.org/api/module-methods/#magic-comments。

这部分文档很隐晦,也可能是文档组没有跟上,所以如果我们去看 webpack 的源码的话,可以发现真正其实是有 6 种 mode。

mode类型定义 https://github.com/webpack/webpack/blob/master/lib/ContextModule.js#L13

那 webpack 到底是如何做到可递归获取我们的文件呢?在刚刚上面的源码地址里我们能发现这样一行代码。

这一看就是去寻找我们所需要的模块。所以我们跟着这行查找具体的源码。

这就是 require.context 是如何加载到我们文件的具体逻辑了。其实就是fs.readdir而已。最后获取到文件之后在通过 context 加载器来生成我们的模块结构对象。比如这样的代码就是负责生成我们sync类型的context加载器。大家可以具体在看别的5种类型。

6种类型加载逻辑并且生成 context 加载器的模块结构对象 https://github.com/webpack/webpack/blob/master/lib/ContextModule.js

总结

1.学习了解 webpack 是如何组织加载一个模块的,webpack 的加载器如何运作,最后如何生成编译后的代码。

2.本来仅仅只是想了解 require.context 如何实现的,却发现了它第三个参数有 6 种mode,这部分却也是 webpack 文档上没有的。

3.从一个实用的 API 出发,探索了该 api 的实现原理,并且一起阅读了部分 webpack 源码。

4.探索本质远比你成为 API 的搬运工更重要。只有你不断地探寻本质,才可以发现世界的奥秘。

最后抛砖引玉,可以按照这样的思路再去学习另外 6 种 mode 编译后的代码。

文章里编译后的代码,都在这里 >>> https://github.com/MeCKodo/require-context-source

React中的9个设计的瑕疵

#react

前言

本文出自于上海FD 2019 讲师Lucas,视屏地址为 https://m.lizhiweike.com/lecture2/12498341. 里面例子也取自于此。我选取了部分容易分享的以及加入了一些别的元素。也删除了部分不容易解释的内容,作为自己的一个记录便于分享。

1. 学习成本

时至今日,如果你在 NPM 上搜索 React 你会发现已经有91008个包了。生态繁荣是好事,但也意味着同一个问题可能会有很多种解决方案。这在无意间就提升了我们的学习成本。

另外,React每年都保持着一个高频率的更新,比如今年的 hook 对于前端来说又是一次理念上的革新。React 是改善 UI 体验的领头者,也是在前端多种概念的创造者。

2. this 问题

绑定事件的 this 问题,在 react 存在很久了。早在前端对于 this 的理解参差不齐的时候,很容易就在这里采坑出错。

现在我们常用的是在构造函数中 bind 绑定,也有直接箭头函数绑定,还出现了很多 autobind 这样的绑定this工具库。但为什么 React 不能帮我们自动绑定 this 呢? 而官方不帮我们自动绑定 this,主要归咎于 JS 自身的语法问题。

3. setState 异步 or 同步?

我们都知道 React 为了性能考虑 setState 是异步更新的。但是如果脱离了 react 的掌控范围之内则是同步的。

class App extends React.Component {
  state = {
    count: 0
  };

  componentDidMount() {
    const btn = document.getElementById('test');
    btn.addEventListener('click', this.handleClick);
  }

  handleClick = () => {
    this.setState({
      count: this.state.count + 1
    });
  };

  render() {
    return (
      <button id="test" onClick={this.handleClick}>
        click
      </button>
    );
  }
}

如果在项目中分散了各种这样混合的代码,很可能让你获取到不被期望的state,难以维护。

4. setState callback hell

想象一下,时常我们需要获取最新的状态而做某些事情,那我们就只能在 setState 的 callback里来做。但是为什么 react 不直接提供 promise 的版本呢?

class CallbackHell extends React.component {
    handleClick = () => {
        this.setState({}, () => {
            doSomething()
            this.setState({}, () => {
              doOtherSomething()
            })
        })
    }
}

搜一下 PR 不难发现,其实早在2017年就有人提过了类似的问题。https://github.com/facebook/react/pull/9989/commits/b5da0b3aff4ecbbdff4ba264f2f6ee33afeb4899

PS:笔者的猜测可能是因为 react 官方不希望把这些 callback 放入 microtask 里去执行。

5. 合成事件

在 React 中,我们获取的事件并不是原生事件而是合成事件,合成事件初衷是为了提升性能。但也会带来一些问题。

class SyntheticEvent extends React.component {
    handleClick = (e) => {
        console.log(e);
        setTimeout(() => {
            console.log(e); // can't get event
        })
    }
}

那我们要如何才能访问到呢?React 给我们提供了一个接口。Event Pooling

class SyntheticEvent extends React.component {
    handleClick = (e) => {
        console.log(e);
        e.persist(); // call persist()
        setTimeout(() => {
            console.log(e); // success
        })
    }
}

除了异步中不能访问 event 以外,还有事件冒泡的问题。

class SyntheticEvent extends React.Component {
  componentDidMount() {
    const btn = document.getElementById('test')!;
    btn.addEventListener('click', () => {
      console.log('document bind');
    });
  }

  handleClick = (e) => {
    console.log('click');
    e.stopPropagation();
  };

  render() {
    return (
      <button id="test" onClick={this.handleClick}>
        click
      </button>
    );
  }
}

stopPropagation 是没法阻止我们冒泡到 document 的。这是因为 React 对事件的处理都是冒泡在 document 在执行。但如果我们真的需要阻止这样的我们应该如何做呢?

  handleClick = (e) => {
    console.log('click');
    e.nativeEvent.stopImmediatePropagation();
  };

我们可以通过 nativeEvent 拿到原生事件,然后调用原生的stopImmediatePropagation来阻止document 上的事件。

关于合成事件的几个问题

React 的合成了所有的事件是否是最优化呢?

在类 React 框架中有一个性能最优化的框架叫 Inferno. 它对事件的处理则是处理部分的事件作为合成事件,其余依旧为原生事件。源码在这里

合成事件我们知道为了性能优化,那react会存储多少个合成事件呢?

源码 可以从这里发现,React设置了一个 EVENT_POOL_SIZE 的常量。所以这就是存储的个数。

那还一个问题,我们有各种各样的事件,react是把所有的事件都放在一起复用吗?

react事件

显然不是。React封装了这么多种类型的事件,每一个都继承于SyntheticEvent,所以每一种类型的事件都会存有EVENT_POOL_SIZE个以便于事件的复用。

6. 事件绑定传参问题

当我们想在一个事件传递多个参数的时候,这在 React 里也是非常的恶心。通常我们是用箭头函数或者直接用bind(this, params)。

而在这一点在 inferno 里就做的很棒,提供了一个linkEvent的接口,不仅解决了 params的问题,还能解决 this 的问题。

import { linkEvent, Component } from 'inferno';

function handleClick(instance, event) {
  instance.setState({ data: event.target.value });
}

class MyComponent extends Component {
  render () {
    return <div><input type="text" onClick={ linkEvent(this, handleClick) } /><div>;
  }
}

https://github.com/infernojs/inferno/blob/master/README.md#linkevent-package-inferno

7. render()

我敢打赌,99%的组件我们至少都需要用到props。那为什么React官方团队不能自动的每次把 props 和 state 都自动的注入到 render 方法里呢?像这样

render(props, state) {
    return <div></div>
}

8. 富交互应用

试想一下假设你需要实现一个类似微信读书的应用。上面有划词标记,划词备忘录,划词锚点,自定义选区编辑等大量需要跟DOM打交道的需求。这时候我们单纯用VM的方式其实非常难实现。无论如何我们都会多生命周期进行大量的DOM事件操作。这对于React来说,或者别的MVVM框架来说都并不是那么的擅长。

也就是说,任何需要我们必须大量操作DOM的需求,我们虽然都能实现,但是依旧是以一种很“不舒服”的方式进行实现。

PS: 笔者之前在实现富文本选区加锚点可以评论的需求。就有过类似的问题。也是没有办法在生命周期中去获取selection和range对象来做。

9. 组件复用/逻辑复用

组件复用和逻辑复用,一直是React长久以来的探索。从最早的mixin -> HOC && Render props。一直都是React的软肋。

mixin就不说了,在中大型应用中使用mixin就是噩梦。天知道你会被谁注入了什么。

HOC是我们长久以来逻辑复用的最佳实践,然而大量使用HOC,导致我们项目无意间多出了许许多多无用层级的DOM,无意义的内存浪费。对于强迫症来说审查元素进行调试也是一件非常恶心的事情。

而render props也只是另一种围魏救赵的方式罢了,虽然也完成了我们组件复用和逻辑复用的使命,但一不小心我们就可能陷入了render callback hell。

而这些所有的问题,我们都可以在hook里被更完美更优雅的解决。

PS: 下一篇继续从hook开始介绍,基于mobx的项目我们可以如何快速切换到hook。

React已经非常优秀了,但它还再变得更好。

咸鱼哲的2018

#奇心怪谈

点击链接快速查看! 咸鱼哲的2018

React应该如何优雅的绑定事件?

#react

前言

由于JS的灵活性,我们在React中其实有很多种绑定事件的方式,然而,其实有许多我们常见的事件绑定,其实并不是高效的。所以本文想给大家介绍一下React绑定事件的正确姿势。

常见两种种错误绑定事件

class ErrorExample1 extends Component {

    balabala(e) {console.log(e)}

    render() {
        const { text } = this.state;

        return (
          <Wrapper>
            {text}
            <Balabala onClick={(e) => this.balabala(e)}></Balabala>
          </Wrapper>
        )
    }
}
class ErrorExample2 extends Component {
    balabala(e) {console.log(e)}

    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={this.balabala.bind(this)}></Balabala>
          </Wrapper>
        )
    }
}

这是两种最常见的React绑定事件代码,但它为什么是错误的?

每当你的text发生变化,就会rerender,只要组件重新render,那就会重新创建新的事件函数,进而绑定事件,这个过程的开销就是极大极大的浪费。相当于,每次rerender都会创建一次事件函数。

这据说是 Best Practice

class Balabala extends Component {
    constructor(p) {
        suprt(p);
        this.balabala = this.balabala.bind(this);
    }
    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={this.balabala}></Balabala>
          </Wrapper>
        )
    }
}

然而我更喜欢的姿势

class Balabala extends Component {
    constructor(p) {
        suprt(p);
    }
    醒来记得想我 = (e) => {
        alert('想你了');
    }
    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={this.醒来记得想我}></Balabala>
          </Wrapper>
        )
    }
}

利用箭头函数,帮我们bind this。避免了在构造函数里生命一堆的变量。减少键盘的敲击,延长生命。

当然,还有人会问,这样的写法如何传参呢?以前别人会这样写

class 渣男 extends Component {
    constructor(p) {
        suprt(p);
    }
    醒来记得想我 = (e, text) => {
        alert(text); // alert 滚吧,渣男
    }
    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={this.醒来记得想我.bind(e, '滚吧,渣男')}></Balabala>
          </Wrapper>
        )
    }
}   

但是其实,我们可以这样传参,更加简洁明了

class 渣男 extends Component {
    constructor(p) {
        suprt(p);
    }
    醒来记得想我 = (text) => (event) => {
        alert(text); // 你渣我也喜欢,因为是你
    }
    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={this.醒来记得想我( '你渣我也喜欢,因为是你')}></Balabala>
          </Wrapper>
        )
    }
}

动态绑定

基于这个我们还可以针对同一事件修改多个input值,进行事件优化

class ChangeMyName extends Component {
  修改渣男名称 = name => {
    if (!this.handlers[name]) {
      this.handlers[name] = event => {
        this.setState({ [name]: event.target.value });
      };
    }
    return this.handlers[name];  
  }

  render() {
    return (
        <>
          <input onChange={this.修改渣男名称('男神1号')}/>
          <input onChange={this.修改渣男名称('渣男2号')}/>
        </>
    )
  }
}

旁门左道,邪教!(个人不喜欢而已)

import autoBind from 'react-autobind';

class Balabala extends Component {
    constructor() {
       autoBind(this);
    }
    醒来记得想我 (e) {
        alert('想你了');
    }
    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={this.醒来记得想我}></Balabala>
          </Wrapper>
        )
    }
}
import { BindAll } from 'lodash-decorators';

@BindAll()
class Bbb extends Component {}
// 这种写法等同于 bind
class Bbb extends Component {
    balabala(){}
    render() {
        const { text } = this.state;
        return (
          <Wrapper>
            {text}
            <Balabala onClick={::this.balabala}></Balabala>
          </Wrapper>
        )
    }
}

基本都大同小异吧,就不过多介绍了。看到这里,你也知道到底应该如何绑定事件了。

五分钟 Styled-components 高级实用技巧

#react

写在前面的废话

回到2013年,React凭空出世。但是在那时,我们会想,oh shit! 我们好不容易分离了HTML/CSS/JS, 为什么出现了JSX,我们又需要把HTML和JS耦合在一起?React 创造了 HTML in JS. 在React中,我们知道,一切即组件。那既然HTML能在js里写,为什么我们不把CSS也一起写呢?这样不才是一个真正的组件吗?

Styled-components就是为React而生的,它是CSS in JS的下一代解决方案。以往我们想要做到css scope都需要在webpack中各种配置,或者使用js的解决方案。而styled-components你只需要import styled from 'styled-components';即可。

甚至React完美的结合,不仅是从TagName上,还有Props上。使我们的代码有更好的语义化,可维护性更强,效率更高。当然我们无需考虑它的学习成本,只要你用过CSS或者SASS都可以立刻上手,因为它本身就是一种超集的存在。

接下来,我会逐步的介绍一些这段时间以来,我非常喜欢的独有的特性。

开胃菜

const Button = styled.button`
  background: #abcdef;
  border-radius: 3px;
  border: none;
  color: white;
`;
console.log(Button); //styled component
console.log(new Button()); // react component 

export default CustomButton extends React.component {
    render() {
        return <Button {...props} />
    }
}

styled-components 用了tagged template语法,直接为我们编写样式创建组件。

继承

styled-components继承样式有两种写法如下

const Button = styled.button`
  background: #abcdef;
  border-radius: 3px;
  border: none;
  color: white;
`;

const OtherButton1 = styled(button)``;
const OtherButton2 = button.extend``; // 老的写法,不推荐,未来会被废弃

写法一的继承,仅仅只会创建不一样的css rule,而第二种写法会复制一遍base component的css rule,然后在添加不一样的进行css 权重覆盖。不推荐

当然,还有一种有趣的“继承” withComponent,我们可以利用withComponent改变渲染的标签

const Li = styled.li`
    color:#abcdef;
`;
const A = Li.withComponent('a'); // 将会渲染a标签

编译后他们会使用不同的className,这对我们想用同个样式,但是不同标签非常有用。

样式覆盖

这里所说的样式覆盖,主要是一些交互上的行为(hover, active)覆盖。其实组件继承也算是覆盖的一种。

以往我们的覆盖写法如下:

const ListItem = styled.li`
  padding: 0;
  height: 48px;

  &.left-item-focus {
    .left-link {
       background: ${props => props.color};
    }
  }
  &:hover {
     .left-icon {
        color: #9e9e9e; // 500
     }
  }
`;

而在styled中,我们可以使用styled-components 组件方式对我们的DOM进行引用,从而覆盖样式,如下

const Icon = styled.span`
    color: red;
`;

const ListItem = styled.li`

    &:hover ${Icon} {
        color: green;
    }
`;

这依旧是我们过去的思路来覆盖样式,只是我们把选择器直接使用styled组件引用罢了。拥有这样的接口,就更加让我们无需去思考需要给组件取什么className或者id,从而达到覆盖样式的做法。然而还有我最喜欢的另外一种写法。

TIPS:组件的引用必须是styled-components包装后的组件,直接是react的会报错

const ListItem = styled.li``;

const Icon = styled.span`
    color: red;

    ${ListItem}:hover & { // & 代表icon组件
        color: green;
    }
`;

这段代码实现的是一样的功能,只是我们思路转换了一下。可以发现这样的代码更加没有侵入性。更加符合开放封闭原则,当我们不需要这个Icon组件时,直接把这个Icon删除即可,我们不用去父组件里寻找与该组件有关的样式,不容易造成样式污染。突然觉得眼前一亮,有木有!

当然这种“子组件引用父级”的功能,还有更加广泛的引用。你可以选择该DOM任何parent,再对自己进行样式的覆盖。如下:

const Icon = styled.span`
    color: red;

    html.ie-8 & {
        // fuck ie8
        color: blue;
    }
    body.xxx & {
        color: green;
    }
`;

当任何父级带有class都会覆盖Icon的样式。这种“子组件引用父级”的功能也是我最喜欢的功能没有之一。

在上面可以看见我们大量使用了&作为选择器,而&还有另外的技巧。

const Example = styled.li`
    color: red; 
    & {
        color:blue;
    }

    && {
        color: green;
    }
`;

大家可以猜猜,这最终会渲染成什么?

<li class='sc-gzVnrw fmpfVE'></li>

最终会编译成如下class,但是我们的一个&就代表一个class权重也就是说我们最后会渲染原谅色,原因是li被作用于了.fmpfVE.fmpfVE样式表。这个功能非常有用,比如在你使用第三方组件想要覆盖它的样式的时候,我们就可以加多个&来提高样式权重,从而覆盖第三方组件的样式

Theme

关于Theme只想说一点,那就是结合第三方组件应该如何传入Theme呢?我们有一个简单的技巧。比如使用了Material-UI,如果我们需要基于它拓展我们自己的组件,并且需要样式。

const ThemeProvider: React.SFC<ThemeProviderProps> = ({ themeName, children }) => {
  const theme = themes[themeName];
  return (
    <StyledThemeProvider theme={theme}>
      <MuiThemeProvider theme={theme}>
        {React.Children.only(children)}
      </MuiThemeProvider>
    </StyledThemeProvider>
  );
};

之后只需要把我们需要调用的组件使用styled-components提供的withTheme包装一下我们的组件来获取我们的theme。

这样既可以在我们的styled-components里取到theme,material里也可以了。

以上就是我们所有的技巧了, 看了这么多有意思的黑科技,难道你还不爱上styled-components吗?

没用到React为啥我写jsx还需要import它啊喂?

#react

提问

当我写一个纯函数组件的时候,为什么还特么需要import React from 'react' 代码如下

import React from 'react';

export default () => <div>你是傻X,爱我吗?</div>

从JSX说起

const Fool = (
    <div className="fool"><b>I'm fool, But I love you</b></div>
)

我们还知道想要编译这样的语法有一个Babel插件,babel-plugin-transform-react-jsx。如上JSX会编译成以下数据结构

既然能知道它会被Babel编译成啥样,那我们只需要解析这个对象即可组装成真正的DOM。

所以React有一个createElement方法,用来解析这编译后的对象

React.createElement(
  "div",
  { className: "fool" },
  "I'm fool, But I love you"
);

假装实现一个假的React?那我就叫她Tcaer

在这我们把createElement方法改写成与Vue一样(在Vue里创建Vdom用得是h方法。虽然我不知道为什么叫h。这么带有颜色的字母) 同时我们需要配置以下我们的.babelrc,否则不支持这样的语法编译。

{
  "presets": ["env"],
  "plugins": [
    ["transform-react-jsx", {
      "pragma": "Tcaer.h" // 这里改成你想要的,比如Love.you
    }]
  ]
}
// tcaer.js
const Tcaer = {
  h,
};

/**
React.createElement(
  "div",
  { className: "fool" },
  "I'm fool, But I love you"
);
**/

// 通过上面的样本代码,我们可以发现也就3个参数罢了。
function h( tag, attrs, ...children ) {
  return {
    tag,
    attrs,
    children
  }
}

export default Tcaer;

接着我们知道React是通过ReactDOM.render(<Component>, root),挂载我们组装后的DOM。那我们也需要实现一个TcaerDOM.render方法。

再看一遍这张图,我们来实现render方法。

<div onClick={test} a="aaa" 
     className={'sdf'} 
     style={{width: 10, color: 'red'}} 
     id='id1'
>
    I'm fool, But I love you
</div>

这里其实要做两件事,第一件事是要把vnode转换为DOM,第二件事则是处理attrs设置到DOM上。

关于处理vnode,我们只需要获取tag,然后递归渲染children即可。如下

  // 处理Node为text的情况
  if (typeof vnode === 'string') {
    const textNode = document.createTextNode(vnode);
    return container.appendChild(textNode);
  }

  const parentDOM = document.createElement(vnode.tag);

  if (vnode.attrs) {
    Object.keys(vnode.attrs).forEach((item) => {
      // TODO 设置样式,之后实现
      setAttributes()
    });
  }

  vnode.children.length && vnode.children.forEach((child) => render(child, parentDOM));

DOM处理好后,我们来实现以下setAttributes。其实要做的处理很简单,className转换为class,解析onXXX事件类型,处理style对象。剩下的就只是单纯的DOM属性设置了,我们统统setAttribute即可搞定。

于是我们的逻辑代码就类似如下。

function setAttributes(dom, key, value) {
    // 1.转换className
    if (name === 'className') name = 'class';

    // 2.处理事件类型
    if (/on\w+/.test(name)) {
      name = name.toLowerCase();
      dom[name] = value;
      return;
    }

    // 3.处理style对象
    if (name === 'style') {
        Object.keys(value).forEach((item) => {
          const styleValue = value[item];
          dom.style[item] = typeof styleValue === 'number' ? `${styleValue}px` : styleValue;
        });
      return;
    }

    // 4.直接粗暴处理通常的DOM属性
    dom.setAttribute(name, value);
}

把这两个过程实现了之后,我们就可以实现TcaerDom.render了。以下是完整代码

function setAttributes(dom, name, value) {
  if (name === 'className') name = 'class';

  // event
  if (/on\w+/.test(name)) {
    name = name.toLowerCase();
    dom[name] = value;
    return;
  }

  // style
  if (name === 'style') {
    Object.keys(value).forEach((item) => {
      const styleValue = value[item];
      dom.style[item] = typeof styleValue === 'number' ? `${styleValue}px` : styleValue;
    });
    return;
  }

  dom.setAttribute(name, value);
}

function render(vnode) {
  if (typeof vnode === 'string') {
    return document.createTextNode( vnode );
  }

  const parentDOM = document.createElement(vnode.tag);

  if (vnode.attrs) {
    Object.keys(vnode.attrs).forEach((item) => {
      setAttributes(parentDOM, item, vnode.attrs[item]);
    });
  }

  vnode.children.length && vnode.children.forEach((child) => render(child, parentDOM));

  return parentDOM;
}

const TcaerDom = {
  // 没想到把,render还有个callback方法,很多人会遗忘它。这里我们暂时没有实现得与react一样。只会回调一次。
  render(vnode, container, callback) {
    container.innerHTML = ''; // 每次渲染需要清空一次root DOM

    container.appendChild(render(vnode,container));

    callback && callback();
  },
};

export default tcaerDom;

最后我们把它拿去测试一下就好,可以发现效果与React官网上的类似。

function tick() {
   const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
   );
   TcaerDOM.render(element, document.getElementById('root'));
}

setInterval(tick, 1000);

整理后的代码都在这里 >> Tcaer <<

「CI集成」基于Jest Mock API对业务逻辑集成测试

#jest

有时候我们需要不发送请求就能完成前端的业务逻辑测试,而许多的业务逻辑都会需要调用到后端的API接口。那如何能mock我们所需要的data就是一个问题。当我们能有一个良好的测试环境之后,只要保证后端的接口没有问题,那我们就可以保证业务逻辑也没有问题。

所以我们对API的集成测试有以下几个要求

1.不发送请求,返回本地假数据

2.发布前通过CI跑unit test,通过则发布上线

如何实现?

首先一般我们在network部分都会进行封装,假设在project中封装了如下的请求工具

// http tool
export default function http() {
    // some implement
}

既然我们不能发送真实请求,那我们就需要类似能拦截的东西,拦截也可以通过mock代替。于是我们可以通过jest.mock方法来做。

jest.mock

// api/http.js
// real fn
export default function http() {
    console.log('real');
    //...
}
// api/__mocks__/http.js
// fake fn
export default function http() {
    console.log('fake');
}
// some.test.js
jest.mock('../http')
import http from '../http'
http() // 这里log的是fake,而不是real

这个就是jest.mock的作用。

正事

明白了这个后就好办了。项目目录如下:

-- api
    |-- __mockData__
        |-- user.data.js
    |-- __mocks__
        |-- http.js
    |-- __tests__
        |-- user.test.js
    |-- http.js
    |-- profile // profile 业务模块
        |-- user.js // 获取用户信息

而我们的fake文件其实主要做的事情就是根据请求url,method,status等,去读取对应的本地假数据。大致如下。

// ./api/__mocks__/http.js
// 直接读取本地假数据
let statusCode;
export function setStatus(code) {
  statusCode = code;
}

export default function http({ url = "", data = {}, method = "get" }) {
  return new Promise((resolve, reject) => {
    const lastSlash = url.lastIndexOf("/");
    const module = url.substring(lastSlash + 1);
    const mockData = require(`../__mockData__/${module}.data`).default;
    const result = mockData[`${method.toUpperCase()} ${statusCode}`];

    process.nextTick(
      () => (statusCode === 200 ? resolve(result) : reject(result))
    );
  });
}

mockData文件夹则就是放我们的假数据,在这我们可以假设定义如下数据结构,来模拟我们的response

// ./api/__mockData__/user.js
export default {
  'GET 200': {
    code: 0,
    msg: 'ok',
    data: {
      username: '二哲',
      age: 18,
    },
  },
  'POST 200': {
    code: 0,
    msg: 'xxx',
  },
  'GET 400': {
    msg: 'invald params',
    code: -1,
  },
  'GET 401': {},
};

最后看下我们的 unit test 如何写

// ./api/__test__/user.test.js
jest.mock('../http') // jest 会自动搜索目录下的 __mocks__里的文件
import http from '../http';

describe('user api test', () => {
    it("user GET should be 200", async () => {
    setStatus(200);

    const result = await http({
      url,
      method: "get"
    });
    expect(result.data.username).toBe("Kodo");
  });

})

实现了这个有什么用?

假设/user接口返回得数据可能是这样

{
    "username": "二哲",
    "age": 18,
}

而我们前端service层为UI层提供了一个initUserData的方法,initUserData方法里的操作是当age为18,那就要返回19。

所以我们在Jest则可以直接这样测试

// ./api/__test__/user.test.js
jest.mock('../http') // jest 会自动搜索目录下的 __mocks__里的文件
import { setStatus } from './http';
import { initUserData } from '../user'

describe('user api test', () => {
  it("if user age is 18, age should be 19", async () => {
    expect.assertions(1);
    setStatus(200);
    const result = await initUserData();
    // console.log(result);
    expect(result.data.age).toBe(19);
  });

  // test catch
  it("initUserData 400", async () => {
    expect.assertions(1);
    setStatus(400);
    const result = await initUserData();
    expect(result.msg).toBe("invald params");
  });

})

这样我们使用Jest就可以完成对业务逻辑的测试,Unit test在大型项目中非常需要,每当提交一个feature时,可以跑完所有测试,会让你非常有安全感,极大提升了项目的稳定性。

TIP

真正的方法(http),与mock的方法http,文件必须同名,然后放在mocks文件夹下即可。如果不同名使用jest.mock()则会失败。

以上例子都在这 jest-api-test

使用Jest对原生TypeScript项目进行UI测试

#ts#jest

前戏

最近写了一个 wechat-colorpicker 小项目。 主要是为了练习下TS。既然写了一个小库,我就想着顺便学下如何写测试吧,这是一件蛮有意思的事情。

从选型到搭建环境,前前后后用了近2个小时。不得不说一个合格的前端必然是一个合格的配置工程师。再次列举下,这个项目中所需要搭建配置的工具。

  • webpack.config 自动编译ts+css
  • tsconfig.config ts的配置文件
  • tslint.json tslint的配置文件
  • jest.config 配置jest
  • .babelrc jest解析js时还会需要用到的插件
  • circle.yml CircleCI 配置文件

如果大家有什么不懂的,自行百度

Jest + TS 入门

第一个问题,我项目都是TS写的,自然会有 import 这样的语法怎么办?

通过官网的Getting started 我们可以在最下方找到 ts-jest 不难理解,我们需要配的其实就是jest加载到什么样类型的文件,使用什么预处理来处理文件。

transform: {
    '.*\\.(ts)$': '<rootDir>/node_modules/ts-jest/preprocessor.js',
},

transform 就是专门用来匹配各种文件后缀,然后进行对应的预处理,你可以理解为webpack里的loader

我在TS中引入了.css文件咋办?同上

既然有transform,那我们任何文件都可以通过transform进行预处理了。

transform: {
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    '.*\\.(ts)$': '<rootDir>/node_modules/ts-jest/preprocessor.js',
    '^.+\\.(css)$':"<rootDir>/node_modules/jest-css-modules"
},

如果是js文件我通过babel-jest处理,css则使用jest-css-modules。假如没有这些配置,那import了你的库,库里有引入了高特性的js文件,或者css文件就会编译报错。

关于rootDir

在进行技术选型的过程中,我看了最新版本的vue-cli里推荐用哪些框架进行测试,一个是jest,还一个是krama+mocha。 我选择了jest,jest本身是fb出的,对于react非常友好。本身也做了许多环境上的封装切换jsdom环境或者node环境非常方便。我最后选择了这个。

刚刚开始看vue-cli里的jest配置我是拒绝的,第一个最显眼的关键字就是<rootDir>这种像XML得东西。但是你慢慢静下心来去理解就很容易了,其实就是一个basePath的感觉。我们可以看下文档怎么说 rootDir

我的目录如下

--- __test__
    jest.config.js
--- dist
--- node_modules
--- src
//jest.config.js
module.exports = {
      rootDir: path.resolve(__dirname, '../'),
}

<rootDir>其实就代表根目录了

setupFiles 选项

setupFiles是一个AOP的配置,我觉得这个非常好用!因为jest是通过jsdom是模拟了一个document的执行环境,那必然还有很多可能是没有的,比如localStorage,那我们可以通过该配置来设置我们启动前,需要加载什么,比如vue-cli中就是设置了Vue.config.productionTip = false,而我们可以让环境支持localStorage。

setupFiles: ["jest-localstorage-mock"]

不难发现,其实jest的生态还是很丰富的,我本次遇到的问题谷歌几个关键字很快都能解决

UI Test 该怎么写?

test应该是像纯函数一样保证输入输出都是一样的,UI test一方面与Dom耦合,另一方面又用户交互耦合,那具体应该怎么写呢?

思路是:模拟用户操作,再通过Dom进行判断是否渲染正确。

比如这个实例化的测试,我们可以测试是否初始化是否正常,通过jquery来辅助判断

// 实例化测试
import WeChatColorPicker from '../src';
import $ from 'jquery';

export function newInstance() {
  document.body.innerHTML = `<div id="container"></div>`;
  return new WeChatColorPicker(pickerOptions);
}

const baseColorArr = [ ... ];

test('test new instance', () => {
  const instance = newInstance();
  expect(instance.baseComponent.baseColorArr).toEqual(baseColorArr);
  expect($('.wechat-colorpicker')).not.toBeNull();
});

比如这个是点击【基本色】【更多颜色】我们会切换class,那就可以像这样

// tab class切换的交互测试
import $ from 'jquery';
import { newInstance } from './utils';

describe('change tab test', () => {
  newInstance();
  test('use base color', () => {
    $('.wechat-picker-box p i').eq(0).click();
    expect($('.wechat-colorpicker').hasClass('base-color')).toBe(true);
  });

  test('use picker', () => {
    $('.wechat-picker-box p i').eq(1).click();
    expect($('.wechat-colorpicker').hasClass('more-color')).toBe(true);
  });
});

是不是突然就觉得非常简单了?并且是唯一性的,测试用例可靠性也有保障。 之后我们就只需要配合一个CI,每次提交前跑一边我们的测试代码,所有用例测试成功即可pr,否则直接被拒绝。

写完了测试,给我们的jest.config 多加一行配置,来生成我们的测试报告(Jest内置了istanbul)

module.exports = {
  // ...
  collectCoverage: true,
  // ...
}

接着执行下 npm t 查看测试结果如下 wechat-colorpicker-06

  • % Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了?
  • % Branch 分支覆盖率(branch coverage):是否每个if代码块都执行了?
  • % Funcs 函数覆盖率(function coverage):是否每个函数都调用了?
  • % Lines 行覆盖率(line coverage):是否每一行都执行了?

更多测试用例前往 >>> repo-wechat-colorpicker <<< 查看

CricleCI(番外篇)

我们可以通过CI的工具来完善我们的wordflow,在这我选用了CricleCi。进入官网我们直接github登入后,setup 我们的项目。

wechat-colorpicker-02

然后根据它的推荐走,在我们项目根目录添加一个cricle.yml,复制黏贴它的推荐配置即可。

然后我们push测试一下,在这里我写错了我的文件路径,所以构建报错了。

wechat-colorpicker-03

重新修复了问题后,就可以正常运行工作了。

wechat-colorpicker-04

由于本文不是重点介绍CI,这里就不过多展开了,有兴趣的朋友可以自己摸索下

后面真的没有了

至此,你应该对前端UI测试应该大致有一个宏观的了解。

本文没有过多得介绍Jest的用法或者语法,希望可以给不知道如何做测试的朋友们一点方向,自己去尝试找到适合自己项目的才是最好的。

刚刚开始可能很难,无从下手,成本很大。实际上做起来,其实都是慢慢的套路,写熟练了后应该会上瘾,毕竟最后跑完测试的那感觉会让你十分高潮。

对于一个毕竟稳定的项目来说,写测试的确非常有好处,我还有一个v-tap插件,那个小项目就是没有写测试每次有什么问题,修复了bug,我都得人工测试一遍,惨痛的教训。不过现在!我想,我可以释放出我的双手了。

学会通过规范解决问题

一位朋友问了我一个问题,promise.then的第二个参数和.catch有什么区别?怎么说也用了2年的Promise,也应该能“精通”使用Promise API了啊!但是却一时半会没法回答出有什么区别,于是我又重新深入的认识了下Promise。第一手资料则是Promise/A+的规范,大多数实现Promise的库也是按照A+来实现的比如Q.js PS:不想看规范的直接略过看最下面结论。

1.术语

1.1 "Promise"是一个带有then方法的对象或方法,它的行为必须符合本规范

1.2 "thenable"是一个有then方法的对象或方法

1.3 "value"是合法Javascript的值(包括undefined,thenable,Promise)

1.4 "exception" 是一个被throw抛出的值

1.5 "reason" 是一个值表明了promise为什么拒绝

2.要求

2.1 Promise 状态

Promise必须是这三种状态的其中之一:pending,fulfilled,rejected

2.1.1 当pending时

2.1.1.1 状态可能会变换成 fulfilled或rejected

2.1.2 当fulfilled时

2.1.2.1 不能切换为别的状态

2.1.2.2 必须有一个值且该值不允许改变

2.1.3 当rejected时

2.1.3.1 不能切换为别的状态

2.1.3.2 必须有拒绝的原因且不允许改变

在这里,“不允许改变” 意味着它是不可变唯一的(即 ===),但并不是说它是深度不可变

2.2 then方法

Promise 必须提供then方法,当前(最后)通过它可以得到value(reason)

// Promise的then方法接受两个参数
promise.then(onFulfilled, onRejected)

2.2.1 onFulfilled 和 onRejected 都是可选的参数

2.2.1.1 如果 onFulfilled 不是函数,它会被忽略

2.2.1.2 如果 onRejected 不是函数,它会被忽略

2.2.2 如果 onFulfilled 是一个函数

2.2.2.1 当promise为fulfilled后会被调用,切promise的value会作为它的第一个参数

2.2.2.2 不能在promise为fulfilled之前调用

2.2.2.3 只允许被调用一次

2.2.3 如果 onRejected 是一个函数

2.2.3.1 当promise为rejected后会被调用,且promise的reason会作为它的第一个参数

2.2.3.2 不能在promise为rejected之前调用

2.2.3.3 只允许被调用一次

2.2.4 onFulfilled 或 onRejected 直到执行上下文调用栈仅有平台代码的时候才能被调用

2.2.5 onFulfilled 和 onRejected 必须作为函数调用

2.2.6 then在同一个promise中可以被多次调用

2.2.6.1 当 promise 是 fulfilled, 所有的 onFulfilled 回调 会按照顺序执行即then调用的顺序

2.2.6.2 当 promise 是 rejected, 所有的 onRejected 回调 会按照顺序执行即then调用的顺序

2.2.7 then必须返回promise

promise2 = promise1.then(onFulfilled, onRejected);

2.2.7.1 任何一个 onFulfilled 或 onRejected 返回了一个value x, 执行Promise决议过程 [[Resolve]](promise2, x)

2.2.7.2 任何一个 onFulfilled 或 onRejected 抛出异常ee会作为reason被 promise2 拒绝

2.2.7.3 如果 onFulfilled 不是函数且 promise1 已 fulfilled, promise2也会fulfilled且value和promise1的value一样

2.2.7.4 如果 onRejected 不是函数且 promise1 已 rejected, promise2也会 rejected且reason和promise1的reason一样

2.3 Promise决议过程

Promise决议过程是讲一个promise和一个value作为输入的抽象操作,我们可以用[[Resolve]](promise, x)来表示。如果x是可以then的或x的行为看起来像一个promise,它会尝试用promise去接收x的状态,否则会用x的作为value来fulfilled promise。

只要暴露了兼容Promise/A+-的then,不同的promise是可以相互处理操作的。它允许Promise/A+去"同化"不一样的实现,只要有合适的then方法

[[Resolve]](promise, x), 会执行如下几个步骤:

2.3.1 如果 promise 和 x 引用了同一个对象, 用TypeError作为reason去reject promise

2.3.2 如果x是一个promise,采取它的状态

2.3.2.1 如果x是pending,promise会保持pending直到x是fulfilled或rejected

2.3.2.2 如果x是fulfilled,fulfill promise用同一个value

2.3.2.3 如果x是rejected,reject promise用同一个reason

2.3.3 如果x是一个对象或者方法

2.3.3.1 使then为x.then 

2.3.3.2 尝试去获取x.then的结果,如果抛出了e,则作为reason reject promise

2.3.3.3 如果then是函数,将x作为this调用,第一个参数是resolvePromise,第二个参数是rejectPromise

    2.3.3.3.1 如果 resolvePromise 调用后返回value y,则run `[[Resolve]](promise, y)`

    2.3.3.3.2 如果 rejectPromise 返回了reason r,则r作为reason reject promise 

    2.3.3.3.3 如果 resolvePromise 和 rejectPromise同时被调用,或者被同一个参数调用了多次,则取第一个,其他的将会被忽略

    2.3.3.3.4 如果then抛出错误e

        2.3.3.3.4.1 如果 resolvePromise 或 rejectPromise 已经被调用过了,则忽略

        2.3.3.3.4.2 否则e作为reason reject promise

2.3.3.4 如果then不是函数,则x作为value fulfill promise

2.3.4 如果x不是对象也不是函数,则x作为value fulfill promise

结论

其实即使看完了A+,我们也没法回答刚刚的问题,但是我们对如何实现一个Promise,Promise三种状态是如何进行决策是有了更清晰的认识。那我们继续寻找问题的答案。从控制台log会发现,浏览器的确实现了更多的方法,race,all,原型上有catch。那具体应该如何实现呢? 此时我们就可以从MDN里找到ES2015的Promise规范。

promise.prototype.catch在这里我们就可以明确的得知,原来catch只是then(void 0, onRejected)的封装罢了。让我们再看看A+中的点2.2.7,then必须返回一个Promise。

所以他们最大的区别就是 then第二个参数是直接reject,而catch是返回了一个新的promise再reject

最后

学会如何查找规范,如何看规范是前端的必经之路啊!

1/5