魏其上

魏其上 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

魏其上 赞了文章 · 2020-05-13

浅析 JS 中的 EventLoop 事件循环(新手向)

Event Loop 这个概念相信大家或多或少都了解过,但是有一次被一个小伙伴问到它具体的原理的时候,感觉自己只知道个大概印象,于是计划着写一篇文章,用输出倒逼输入,让自己重新学习这个概念,同时也能帮助更多的人理解它~

概念

JavaScript 是一门 单线程 语言,即同一时间只能执行一个任务,即代码执行是同步并且阻塞的。

eg. 这就像只有一个窗口的银行,客户需要一个一个排队办理业务。

只能同步执行肯定是有问题的,所以 JS 有了一个用来实现异步的函数:setTimeout

下面要讲的 Event Loop 就是为了确保 异步代码 可以在 同步代码 执行后继续执行的。

由于涉及到的相关概念较多,我们先从最简单的来。

队列(Queue)

队列 是一种 FIFO(First In, First Out) 的数据结构,它的特点就是 先进先出

eg. 生活中最常见的例子就是排队啦,排在队伍最前面的人最先被提供服务。

栈(Stack)

是一种 LIFO(Last In, First Out)的数据结构,特点即 后进先出

eg. 大家都吃过桶装薯片吧~薯片在包装的时候只能从顶部放入,而吃的时候也只能从顶部拿出,这就叫后进先出哈

调用栈(Call Stack)

栈我们已经知道了,那么什么是 调用栈 呢 ?

它本质上当然还是个栈啦 废话,关键在于它里面装的东西,是一个个待执行的函数。

Event Loop 会一直检查 Call Stack 中是否有函数需要执行,如果有,就从栈顶依次执行。同时,如果执行的过程中发现其他函数,继续入栈然后执行。

先拿两个函数来说:

  • 栈空
  • 现在执行到一个 函数A,函数A 入栈
  • 函数A 又调用了 函数B,函数B 入栈
  • 函数B 执行完后 出栈
  • 然后继续执行 函数A,执行完后A也 出栈
  • 栈空

更复杂一点的话,来看一段代码:

call-stack-code.png

这段代码在 调用栈中的运行顺序如下图:

call-stack-process.png

这个调用栈其实大家经常会见到,就是在控制台报错的时候,错误信息显示的就是当前时刻调用栈的状态。

But, 上面我们讨论的其实都是同步代码,代码在运行的时候只用 调用栈 解释就可以了。

那么,假如我们发起了一个网络请求(request),或者设置了一个定时器延时(setTimeout),一段时间后的代码(回调函数)肯定不是直接被加到调用栈吧?

这时就要引出 事件表格(Event Table)事件队列 (Event Queue)

Event Table

Event Table 可以理解成一张 事件->回调函数 对应表

它就是用来存储 JavaScript 中的异步事件 (request, setTimeout, IO等) 及其对应的回调函数的列表

Event Queue

Event Queue 简单理解就是 回调函数 队列,所以它也叫 Callback Queue

当 Event Table 中的事件被触发,事件对应的 回调函数 就会被 push 进这个 Event Queue,然后等待被执行

Event Loop

先来看一个流程图:

event-loop-process.png

  • 开始,任务先进入 Call Stack
  • 同步任务直接在栈中等待被执行,异步任务从 Call Stack 移入到 Event Table 注册
  • 当对应的事件触发(或延迟到指定时间),Event Table 会将事件回调函数移入 Event Queue 等待
  • 当 Call Stack 中没有任务,就从 Event Queue 中拿出一个任务放入 Call Stack

Event Loop 指的就是这一整个圈圈:

它不停检查 Call Stack 中是否有任务(也叫栈帧)需要执行,如果没有,就检查 Event Queue,从中弹出一个任务,放入 Call Stack 中,如此往复循环。

好啦,不知道有没有看明白呢?放一张更经典的图:

event-loop.png

其中与 Event Queue 对应的还有一个叫 Job Queue,它主要是用来执行 Promise 的,这两种 Queue 有什么区别呢?

这就涉及到 宏任务 (macro task) 和 微任务 (micro task) 了,我们放在下篇再讲~

参考文章

原文链接
MDN EventLoop
javascript-event-loop
understanding-js-the-event-loop
这一次,彻底弄懂JavaScript执行机制
understanding-event-loop-call-stack-event-job-queue-in-javascript

欢迎关注我的公众号:码力全开

图片描述

查看原文

赞 40 收藏 29 评论 3

魏其上 收藏了文章 · 2020-04-20

快速进阶Vue3.0

在2019.10.5日发布了Vue3.0预览版源码,但是预计最早需要等到 2020 年第一季度才有可能发布 3.0 正式版。

可以直接看 github源码。

新版Vue 3.0计划并已实现的主要架构改进和新功能:

  • 编译器(Compiler)

    • 使用模块化架构
    • 优化 "Block tree"
    • 更激进的 static tree hoisting 功能 (检测静态语法,进行提升)
    • 支持 Source map
    • 内置标识符前缀(又名"stripWith")
    • 内置整齐打印(pretty-printing)功能
    • 移除 Source map 和标识符前缀功能后,使用 Brotli 压缩的浏览器版本精简了大约10KB
  • 运行时(Runtime)

    • 速度显著提升
    • 同时支持 Composition API 和 Options API,以及 typings
    • 基于 Proxy 实现的数据变更检测
    • 支持 Fragments (允许组件有从多个根结点)
    • 支持 Portals (允许在DOM的其它位置进行渲染)
    • 支持 Suspense w/ async setup()
目前不支持IE11

1.剖析Vue Composition API

可以去看官方地址

  • Vue 3 使用ts实现了类型推断,新版api全部采用普通函数,在编写代码时可以享受完整的类型推断(避免使用装饰器)
  • 解决了多组件间逻辑重用问题 (解决:高阶组件、mixin、作用域插槽)
  • Composition API 使用简单

先尝鲜Vue3.0看看效果

<script data-original="vue.global.js"></script>
<div id="container"></div>
<script>
    function usePosition(){ // 实时获取鼠标位置
        let state = Vue.reactive({x:0,y:0});
        function update(e) {
            state.x= e.pageX
            state.y = e.pageY
        }
        Vue.onMounted(() => {
            window.addEventListener('mousemove', update)
        })
        Vue.onUnmounted(() => {
            window.removeEventListener('mousemove', update)
        })
        return Vue.toRefs(state);
    }
    const App = {
        setup(){ // Composition API 使用的入口
            const state  = Vue.reactive({name:'youxuan'}); // 定义响应数据
            const {x,y} = usePosition(); // 使用公共逻辑
            Vue.onMounted(()=>{
                console.log('当组挂载完成')
            });
            Vue.onUpdated(()=>{
                console.log('数据发生更新')
            });
            Vue.onUnmounted(()=>{
                console.log('组件将要卸载')
            })
            function changeName(){
                state.name = 'webyouxuan';
            }
            return { // 返回上下文,可以在模板中使用
                state,
                changeName,
                x,
                y
            }
        },
        template:`<button @click="changeName">{{state.name}} 鼠标x: {{x}} 鼠标: {{y}}</button>`
    }
    Vue.createApp().mount(App,container);
</script>
到这里你会发现响应式才是Vue的灵魂

2.源码目录剖析

packages目录中包含着Vue3.0所有功能

├── packages
│   ├── compiler-core # 所有平台的编译器
│   ├── compiler-dom # 针对浏览器而写的编译器
│   ├── reactivity # 数据响应式系统
│   ├── runtime-core # 虚拟 DOM 渲染器 ,Vue 组件和 Vue 的各种API
│   ├── runtime-dom # 针对浏览器的 runtime。其功能包括处理原生 DOM API、DOM 事件和 DOM 属性等。
│   ├── runtime-test # 专门为测试写的runtime
│   ├── server-renderer # 用于SSR
│   ├── shared # 帮助方法
│   ├── template-explorer
│   └── vue # 构建vue runtime + compiler

compiler
compiler-core主要功能是暴露编译相关的API以及baseCompile方法
compiler-dom基于compiler-core封装针对浏览器的compiler (对浏览器标签进行处理)

runtime
runtime-core 虚拟 DOM 渲染器、Vue 组件和 Vue 的各种API
runtime-testDOM结构格式化成对象,方便测试
runtime-dom 基于runtime-core编写的浏览器的runtime (增加了节点的增删改查,样式处理等),返回rendercreateApp方法

reactivity
单独的数据响应式系统,核心方法reactiveeffectrefcomputed

vue
整合 compiler + runtime

到此我们解析了Vue3.0结构目录,整体来看整个项目还是非常清晰的

再来尝尝鲜:
我们可以根据官方的测试用例来看下如何使用Vue3.0

const app = {
    template:`<div>{{count}}</div>`,
    data(){
        return {count:100}
    },
}
let proxy = Vue.createApp().mount(app,container);
setTimeout(()=>{
    proxy.count = 200;
},2000)
接下来我们来对比 Vue 2 和 Vue 3 中的响应式原理区别

3.Vue2.0响应式原理机制 - defineProperty

这个原理老生常谈了,就是拦截对象,给对象的属性增加setget方法,因为核心是defineProperty所以还需要对数组的方法进行拦截

3.1 对对象进行拦截

function observer(target){
    // 如果不是对象数据类型直接返回即可
    if(typeof target !== 'object'){
        return target
    }
    // 重新定义key
    for(let key in target){
        defineReactive(target,key,target[key])
    }
}
function update(){
    console.log('update view')
}
function defineReactive(obj,key,value){
    observer(value); // 有可能对象类型是多层,递归劫持
    Object.defineProperty(obj,key,{
        get(){
            // 在get 方法中收集依赖
            return value
        },
        set(newVal){
            if(newVal !== value){
                observer(value);
                update(); // 在set方法中触发更新
            }
        }
    })
}
let obj = {name:'youxuan'}
observer(obj);
obj.name = 'webyouxuan';

3.2 数组方法劫持

let oldProtoMehtods = Array.prototype;
let proto = Object.create(oldProtoMehtods);
['push','pop','shift','unshift'].forEach(method=>{
    Object.defineProperty(proto,method,{
        get(){
            update();
            oldProtoMehtods[method].call(this,...arguments)
        }
    })
})
function observer(target){
    if(typeof target !== 'object'){
        return target
    }
    // 如果不是对象数据类型直接返回即可
    if(Array.isArray(target)){
        Object.setPrototypeOf(target,proto);
        // 给数组中的每一项进行observr
        for(let i = 0 ; i < target.length;i++){
            observer(target[i])
        }
        return
    };
    // 重新定义key
    for(let key in target){
        defineReactive(target,key,target[key])
    }
}

测试

let obj = {hobby:[{name:'youxuan'},'喝']}
observer(obj)
obj.hobby[0].name = 'webyouxuan'; // 更改数组中的对象也会触发试图更新
console.log(obj)
这里依赖收集的过程就不详细描述了,我们把焦点放在Vue3.0
  • Object.defineProperty缺点

    • 无法监听数组的变化
    • 需要深度遍历,浪费内存

4.Vue3.0数据响应机制 - Proxy

在学习Vue3.0之前,你必须要先熟练掌握ES6中的 ProxyReflect 及 ES6中为我们提供的 MapSet两种数据结构

先应用再说原理:

let p = Vue.reactive({name:'youxuan'});
Vue.effect(()=>{ // effect方法会立即被触发
    console.log(p.name);
})
p.name = 'webyouxuan';; // 修改属性后会再次触发effect方法
源码是采用ts编写,为了便于大家理解原理,这里我们采用js来从0编写,之后再看源码就非常的轻松啦!

4.1 reactive方法实现

通过proxy 自定义获取、增加、删除等行为

function reactive(target){
    // 创建响应式对象
    return createReactiveObject(target);
}
function isObject(target){
    return typeof target === 'object' && target!== null;
}
function createReactiveObject(target){
    // 判断target是不是对象,不是对象不必继续
    if(!isObject(target)){
        return target;
    }
    const handlers = {
        get(target,key,receiver){ // 取值
            console.log('获取')
            let res = Reflect.get(target,key,receiver);
            return res;
        },
        set(target,key,value,receiver){ // 更改 、 新增属性
            console.log('设置')
            let result = Reflect.set(target,key,value,receiver);
            return result;
        },
        deleteProperty(target,key){ // 删除属性
            console.log('删除')
            const result = Reflect.deleteProperty(target,key);
            return result;
        }
    }
    // 开始代理
    observed = new Proxy(target,handlers);
    return observed;
}
let p = reactive({name:'youxuan'});
console.log(p.name); // 获取
p.name = 'webyouxuan'; // 设置
delete p.name; // 删除

我们继续考虑多层对象如何实现代理

let p = reactive({ name: "youxuan", age: { num: 10 } });
p.age.num = 11
由于我们只代理了第一层对象,所以对age对象进行更改是不会触发set方法的,但是却触发了get方法,这是由于 p.age会造成 get操作
get(target, key, receiver) {
      // 取值
    console.log("获取");
    let res = Reflect.get(target, key, receiver);
    return isObject(res) // 懒代理,只有当取值时再次做代理,vue2.0中一上来就会全部递归增加getter,setter
    ? reactive(res) : res;
}
这里我们将p.age取到的对象再次进行代理,这样在去更改值即可触发set方法

我们继续考虑数组问题
我们可以发现Proxy默认可以支持数组,包括数组的长度变化以及索引值的变化

let p = reactive([1,2,3,4]);
p.push(5);
但是这样会触发两次set方法,第一次更新的是数组中的第4项,第二次更新的是数组的length

我们来屏蔽掉多次触发,更新操作

set(target, key, value, receiver) {
    // 更改、新增属性
    let oldValue = target[key]; // 获取上次的值
    let hadKey = hasOwn(target,key); // 看这个属性是否存在
    let result = Reflect.set(target, key, value, receiver);
    if(!hadKey){ // 新增属性
        console.log('更新 添加')
    }else if(oldValue !== value){ // 修改存在的属性
        console.log('更新 修改')
    }
    // 当调用push 方法第一次修改时数组长度已经发生变化
    // 如果这次的值和上次的值一样则不触发更新
    return result;
}

解决重复使用reactive情况

// 情况1.多次代理同一个对象
let arr = [1,2,3,4];
let p = reactive(arr);
reactive(arr);

// 情况2.将代理后的结果继续代理
let p = reactive([1,2,3,4]);
reactive(p);
通过hash表的方式来解决重复代理的情况
const toProxy = new WeakMap(); // 存放被代理过的对象
const toRaw = new WeakMap(); // 存放已经代理过的对象
function reactive(target) {
  // 创建响应式对象
  return createReactiveObject(target);
}
function isObject(target) {
  return typeof target === "object" && target !== null;
}
function hasOwn(target,key){
  return target.hasOwnProperty(key);
}
function createReactiveObject(target) {
  if (!isObject(target)) {
    return target;
  }
  let observed = toProxy.get(target);
  if(observed){ // 判断是否被代理过
    return observed;
  }
  if(toRaw.has(target)){ // 判断是否要重复代理
    return target;
  }
  const handlers = {
    get(target, key, receiver) {
      // 取值
      console.log("获取");
      let res = Reflect.get(target, key, receiver);
      return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let hadKey = hasOwn(target,key);
      let result = Reflect.set(target, key, value, receiver);
      if(!hadKey){
        console.log('更新 添加')
      }else if(oldValue !== value){
        console.log('更新 修改')
      }
      return result;
    },
    deleteProperty(target, key) {
      console.log("删除");
      const result = Reflect.deleteProperty(target, key);
      return result;
    }
  };
  // 开始代理
  observed = new Proxy(target, handlers);
  toProxy.set(target,observed);
  toRaw.set(observed,target); // 做映射表
  return observed;
}
到这里reactive方法基本实现完毕,接下来就是与Vue2中的逻辑一样实现依赖收集和触发更新

tupian

get(target, key, receiver) {
    let res = Reflect.get(target, key, receiver);
+   track(target,'get',key); // 依赖收集
    return isObject(res) 
    ?reactive(res):res;
},
set(target, key, value, receiver) {
    let oldValue = target[key];
    let hadKey = hasOwn(target,key);
    let result = Reflect.set(target, key, value, receiver);
    if(!hadKey){
+     trigger(target,'add',key); // 触发添加
    }else if(oldValue !== value){
+     trigger(target,'set',key); // 触发修改
    }
    return result;
}
track的作用是依赖收集,收集的主要是effect,我们先来实现effect原理,之后再完善 tracktrigger方法

4.2 effect实现

effect意思是副作用,此方法默认会先执行一次。如果数据变化后会再次触发此回调函数。

let school = {name:'youxuan'}
let p = reactive(school);
effect(()=>{
    console.log(p.name);  // youxuan
})

我们来实现effect方法,我们需要将effect方法包装成响应式effect

function effect(fn) {
  const effect = createReactiveEffect(fn); // 创建响应式的effect
  effect(); // 先执行一次
  return effect;
}
const activeReactiveEffectStack = []; // 存放响应式effect
function createReactiveEffect(fn) {
  const effect = function() {
    // 响应式的effect
    return run(effect, fn);
  };
  return effect;
}
function run(effect, fn) {
    try {
      activeReactiveEffectStack.push(effect);
      return fn(); // 先让fn执行,执行时会触发get方法,可以将effect存入对应的key属性
    } finally {
      activeReactiveEffectStack.pop(effect);
    }
}

当调用fn()时可能会触发get方法,此时会触发track

const targetMap = new WeakMap();
function track(target,type,key){
    // 查看是否有effect
    const effect = activeReactiveEffectStack[activeReactiveEffectStack.length-1];
    if(effect){
        let depsMap = targetMap.get(target);
        if(!depsMap){ // 不存在map
            targetMap.set(target,depsMap = new Map());
        }
        let dep = depsMap.get(target);
        if(!dep){ // 不存在set
            depsMap.set(key,(dep = new Set()));
        }
        if(!dep.has(effect)){
            dep.add(effect); // 将effect添加到依赖中
        }
    }
}

当更新属性时会触发trigger执行,找到对应的存储集合拿出effect依次执行

function trigger(target,type,key){
    const depsMap = targetMap.get(target);
    if(!depsMap){
        return
    }
    let effects = depsMap.get(key);
    if(effects){
        effects.forEach(effect=>{
            effect();
        })
    }
}

我们发现如下问题

let school = [1,2,3];
let p = reactive(school);
effect(()=>{
    console.log(p.length);
})
p.push(100);
新增了值,effect方法并未重新执行,因为push中修改length已经被我们屏蔽掉了触发trigger方法,所以当新增项时应该手动触发length属性所对应的依赖。
function trigger(target, type, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let effects = depsMap.get(key);
  if (effects) {
    effects.forEach(effect => {
      effect();
    });
  }
  // 处理如果当前类型是增加属性,如果用到数组的length的effect应该也会被执行
  if (type === "add") {
    let effects = depsMap.get("length");
    if (effects) {
      effects.forEach(effect => {
        effect();
      });
    }
  }
}

4.3 ref实现

ref可以将原始数据类型也转换成响应式数据,需要通过.value属性进行获取值

function convert(val) {
  return isObject(val) ? reactive(val) : val;
}
function ref(raw) {
  raw = convert(raw);
  const v = {
    _isRef:true, // 标识是ref类型
    get value() {
      track(v, "get", "");
      return raw;
    },
    set value(newVal) {
      raw = newVal;
      trigger(v,'set','');
    }
  };
  return v;
}

问题又来了我们再编写个案例

let r = ref(1);
let c = reactive({
    a:r
});
console.log(c.a.value);
这样做的话岂不是每次都要多来一个.value,这样太难用了

get方法中判断如果获取的是ref的值,就将此值的value直接返回即可

let res = Reflect.get(target, key, receiver);
if(res._isRef){
  return res.value
}

4.4 computed实现

computed 实现也是基于 effect 来实现的,特点是computed中的函数不会立即执行,多次取值是有缓存机制的

先来看用法:

let a = reactive({name:'youxuan'});
let c = computed(()=>{
  console.log('执行次数')
  return a.name +'webyouxuan';
})
// 不取不执行,取n次只执行一次
console.log(c.value);
console.log(c.value);
function computed(getter){
  let dirty = true;
  const runner = effect(getter,{ // 标识这个effect是懒执行
    lazy:true, // 懒执行
    scheduler:()=>{ // 当依赖的属性变化了,调用此方法,而不是重新执行effect
      dirty = true;
    }
  });
  let value;
  return {
    _isRef:true,
    get value(){
      if(dirty){
        value = runner(); // 执行runner会继续收集依赖
        dirty = false;
      }
      return value;
    }
  }
}

修改effect方法

function effect(fn,options) {
  let effect = createReactiveEffect(fn,options);
  if(!options.lazy){ // 如果是lazy 则不立即执行
    effect();
  }
  return effect;
}
function createReactiveEffect(fn,options) {
  const effect = function() {
    return run(effect, fn);
  };
  effect.scheduler = options.scheduler;
  return effect;
}

trigger时判断

deps.forEach(effect => {
  if(effect.scheduler){ // 如果有scheduler 说明不需要执行effect
    effect.scheduler(); // 将dirty设置为true,下次获取值时重新执行runner方法
  }else{
    effect(); // 否则就是effect 正常执行即可
  }
});
let a = reactive({name:'youxuan'});
let c = computed(()=>{
  console.log('执行次数')
  return a.name +'webyouxuan';
})
// 不取不执行,取n次只执行一次
console.log(c.value);
a.name = 'zf10'; // 更改值 不会触发重新计算,但是会将dirty变成true

console.log(c.value); // 重新调用计算方法
到此我们将Vue3.0核心的 Composition Api 就讲解完毕了! 不管是面试还是后期的应用也再也不需要担心啦!~

默认标题_横版二维码_2019.10.16.png

查看原文

魏其上 收藏了文章 · 2020-04-17

动手写一个Promise

现在Promise用的比较频繁了,如果哪天突然不用了,可能逻辑就不好厘清了,回调没的说是一大把

废话不多说,进入正题

Promise这个东西很神奇,用起来舒服,若自己写一下,恐怕还真不简单,关键就一个字“绕”,绕过了也就好了

首先定义结构

class MyPromise {
    constructor(excutor) {}
    then(onfulfilled, onrejected) {}
    catch(onrejected) {}
}

以上也就是大体的结构了

结构清楚了,那么说如何设计呢,先看看Promise的示例

var p = new Promise((resolve, reject) => {
    resolve(1)
})
p.then(console.log)
p.then(console.log)

以上的输出结果为

1
1

所以说内部应该有必要存这个结果值,同时还有必要存一系列的回调函数,所以先来看看构造函数的实现

构造函数

constructor(excutor) {
    // 状态
    this._state = 'pending'
    // 成功回调
    this._callbacks_resolved = []
    // 失败回调
    this._callbacks_rejected = []
    // 执行
    excutor((data) => {
        // 避免重复执行
        if (this._state !== 'pending') {
            return
        }
        // 保存结果
        this._data = data
        // 状态变更
        this._state = "resolved"
        // 处理
        this._handle()
    }, (err) => {
        if (this._state !== 'pending') {
            return
        }
        // 错误信息
        this._err = err
        // 状态变更
        this._state = "rejected"
        this._handle()
    })
}

看看处理函数

也就是根据对应的状态执行对应的回调,当然不要忘了处理完之后要清空

_handle() {
    if (this._state === 'pending') {
        return;
    }
    // 为什么要延时,then的东西是不能马上执行的,可以看看micro-task和macro-task,这里只能模拟
    setTimeout(() => {
        if (this._state === 'resolved') {
            this._callbacks_resolved.forEach(cb => cb(this._data))
        } else {
            this._callbacks_rejected.forEach(cb => cb(this._err))
        }
        this._callbacks_resolved = []
        this._callbacks_rejected = []
    })
}

then

到了关键的一步,也就是核心的then
这个then的放回结果本身就是一个Promise,所以是可以无限的套的,也就像这样.then().then()
还支持值传递,上一个没有处理的,直接流到下一级,上一级把错误处理后,下一级状态变更为resolved

最开始想直接返回this,发觉这样问题大了去了,所以得另外new一个MyPromise实例,代码如下

then(onfulfilled, onrejected) {
    return new MyPromise((resolve, reject) => {
        this._callbacks_resolved.push(data => {
            // 没有处理,直接往后传
            if (!onfulfilled) {
                return resolve(data)
            }
            try {
                let r = onfulfilled(data)
                // 有then函数,就认为是Promise
                if (r && typeof r.then === "function") {
                    return r.then(resolve, reject)
                }
                resolve(r)
            } catch(e) {
                // 有错误直接向后传
                reject(e)
            }
        })
        this._callbacks_rejected.push(err => {
            if (!onrejected) {
                return reject(err)
            }
            try {
                let r = onrejected(err)
                if (r && typeof r.then === "function") {
                    return r.then(resolve, reject)
                }
                resolve(r)
            } catch(e) {
                reject(e)
            }
        })
        this._handle()
    })
}

以上基本功能已经实现完成,最后还有一个catch函数,很简单,包装一下就好了

catch(onrejected) {
    return this.then(undefined, onrejected)
}

随便写了个测试用例

var p = new MyPromise((resolve, reject) => {
  resolve(1);
})
  .then((n) => {
    console.log(n);
    return new MyPromise((resolve, reject) => {
      resolve(2);
    });
  })
  .then(console.log)
  .then(() => {
    throw new Error("error");
  });

p.catch((e) => {
  console.error(e);
});

p.catch((e) => {
  console.error(e);
});

输出

1
2
error
error

完全符合预期

好了,完结

附上全部代码

class MyPromise {
  constructor(excutor) {
    // 状态
    this._state = "pending";
    // 成功回调
    this._callbacks_resolved = [];
    // 失败回调
    this._callbacks_rejected = [];
    // 执行
    excutor(
      (data) => {
        // 避免重复执行
        if (this._state !== "pending") {
          return;
        }
        // 保存结果
        this._data = data;
        // 状态变更
        this._state = "resolved";
        // 处理
        this._handle();
      },
      (err) => {
        if (this._state !== "pending") {
          return;
        }
        // 错误信息
        this._err = err;
        // 状态变更
        this._state = "rejected";
        this._handle();
      }
    );
  }
  then(onfulfilled, onrejected) {
    return new MyPromise((resolve, reject) => {
      this._callbacks_resolved.push((data) => {
        // 没有处理,直接往后传
        if (!onfulfilled) {
          return resolve(data);
        }
        try {
          let r = onfulfilled(data);
          // 有then函数
          if (r && typeof r.then === "function") {
            return r.then(resolve, reject);
          }
          resolve(r);
        } catch (e) {
          // 有错误直接向后传
          reject(e);
        }
      });
      this._callbacks_rejected.push((err) => {
        if (!onrejected) {
          return reject(err);
        }
        try {
          let r = onrejected(err);
          if (r && typeof r.then === "function") {
            return r.then(resolve, reject);
          }
          resolve(r);
        } catch (e) {
          reject(e);
        }
      });
      this._handle()
    });
  }

  catch(onrejected) {
    return this.then(undefined, onrejected);
  }

  _handle() {
    if (this._state === "pending") {
      return;
    }
    // 为什么要延时,then的东西是不能马上执行的,可以看看micro-task和macro-task,这里只能模拟
    setTimeout(() => {
      if (this._state === "resolved") {
        this._callbacks_resolved.forEach((cb) => cb(this._data));
      } else {
        this._callbacks_rejected.forEach((cb) => cb(this._err));
      }
      this._callbacks_resolved = [];
      this._callbacks_rejected = [];
    });
  }
}
查看原文

魏其上 提出了问题 · 2019-12-12

vue数据调用问题

image.png
这是我一个页面,点击数字加1
image.png
然后写了一个函数返回原来的值,并且输出123
为什么我点击conut+1的时候test也会调用一次呢

关注 2 回答 2

魏其上 收藏了文章 · 2019-11-21

win10下安装flutter开发环境过程

本文为flutter开发环境在win10下安装全过程

1、下载flutter

这里下载windows包,下载完成后解压
https://flutter.dev/docs/get-...

2、配置环境变量

新增一个配置如下,这里的bin目录就是上一步加压后里面的文件夹

clipboard.png

3、新建系统变量

这里是为了把flutter相关的下载包括第三方插件等指向国内镜像,加快速度,主要两个,如下

clipboard.png

clipboard.png

4、测试配置

在终端里面运行 flutter doctor 命令看看是否能正常运行,效果如下,第一个打勾表示环境变量已经修改好了

clipboard.png

5、安装android studio

https://developer.android.goo...

6、添加安卓虚拟机

打开android studio,按下面图片顺序添加虚拟机

clipboard.png

clipboard.png

选一个设备,next
clipboard.png

下载推荐的即可,完了一路next,直到完成
clipboard.png

完了点这个三角形打开模拟器
clipboard.png

如果可以启动那么就下一步,如果启动不了报错:

Emulator: emulator: ERROR: x86 emulation currently requires hardware
acceleration! Emulator: Process finished with exit code 1

那么就是电脑BIOS里面没有开启虚拟化支持,需要重启电脑,进入BIOS->security 里面,把虚拟化支持从disabled改为enabled,保存即可。这里没有截图,有空把截图补上

clipboard.png

clipboard.png

clipboard.png

6、使用vscode开发flutter

关掉android studio,因为我使用VSCODE,所以需要安装一下flutter插件

clipboard.png

安装完后ctrl+P,输入flutter,看到如下命令
clipboard.png

以上完成整个win10下安装flutter开发环境了。

7、创建一个项目来测试一下

clipboard.png

创建完成后按 F5,选一个设备

clipboard.png

完成!
clipboard.png

查看原文

魏其上 提出了问题 · 2019-11-14

关于前端JS字典序多重嵌套问题

目前是一个对象需要需要字典序后转换为JSON发送给后台
我使用了Object.keys(data).sort()排序后转换为JSON
但是这个只能转换一层,无法与后台对应
多层的话如何字典序呢

data={
a:1,
b:2
c:[{d:3},{e:4}]
}这种的C里面的也要字典序才能与后台对应
这种如何字典序呢,自己写个方法递归排序吗?还是有别的方法

关注 2 回答 1

魏其上 提出了问题 · 2019-11-14

关于前端JS字典序多重嵌套问题

目前是一个对象需要需要字典序后转换为JSON发送给后台
我使用了Object.keys(data).sort()排序后转换为JSON
但是这个只能转换一层,无法与后台对应
多层的话如何字典序呢

data={
a:1,
b:2
c:[{d:3},{e:4}]
}这种的C里面的也要字典序才能与后台对应
这种如何字典序呢,自己写个方法递归排序吗?还是有别的方法

关注 2 回答 1

魏其上 收藏了文章 · 2019-09-28

vue权限路由详解

前言


近期,用vue框架做了个后台管理项目,涉及权限,参考网上资料,得此项目https://github.com/zhangxuexi...
不知大家是否还是各种mock工具来模拟数据,此项目,包含一个基于express,搭建的mock模拟服务,个人觉得,高大上了一点有木有!

clipboard.png

启动


mock项目:


cd mock
npm install
npm start

基于node.js的mock服务就启动起来了。

项目目录

clipboard.png

请求接口路由都在这个routes/index.js里定义,接口返回的都是写死的json数据。如需连接数据库等操作请自行百度。这个项目不作重点介绍

myProject项目:


cd myProject
npm install
npm run dev

两个项目分别启动起来后,可用下面账号登录:
角色1:用户名:123 密码:a123456
角色2:用户名:456 密码:a123456

router.js 权限路由
重点来了,直接上干货!

clipboard.png
这部分是webpack的代码分割,模块可按需加载,也就是懒加载,具体知识可点击链接进去查看。

clipboard.png

不受权限控制的通用路由可定义在这里,直接在vue实例中注入即可。

export const asyncRoutes = [
  {
    path: 'home',
    component: home,
    redirect: '/home/index',
    meta: {role: ['0', '1'], nav: false},
    children:
      [
        {
          path: '/home/index',
          name: '概览',
          component: companyIndex,
          meta: { role: ['1'], nav: true }
        },
        {
          path: '/home/index',
          name: '概览',
          component: userIndex,
          meta: { role: ['0'], nav: true }
        },
        {
          path: '/policy',
          name: '列表管理',
          component: policy,
          redirect: '/policy/list',
          meta: { role: ['0'], nav: true },
          children: [
            {
              path: 'list',
              name: '列表',
              component: userList,
              meta: { role: ['0'], nav: true }
            }
          ]},
        {
          path: '/policy',
          name: '列表管理',
          component: policy,
          redirect: '/policy/company',
          meta: { role: ['1'], nav: true },
          children: [
            {
              path: '/policy/company',
              name: '列表',
              redirect: '/policy/company/list',
              component: policy,
              meta: { role: ['1'], nav: true },
              children: [
                {
                  path: 'list',
                  name: '列表',
                  component: companyList,
                  meta: { role: ['1'], nav: false }
                },
                {
                  path: 'detail/:policyNum',
                  name: '列表详情',
                  component: detailCompany,
                  meta: { role: ['1'], nav: false }
                },
                {
                  path: 'add',
                  name: '添加',
                  component: addCompany,
                  meta: { role: ['1'], nav: false }
                }
              ]}
          ]
        }
      ]
  }
]

这部分是权限路由,以每个路由记录的元信息字段,定义权限。

meta:{role:['0'], nav:true}

role:代表这条路由记录的所属角色,nav:true代表要显示在导航栏,例如一些列表路由,false:代表不显示在导航栏,例如添加,查看路由。

router.beforeEach((to, from, next) => {
  // 第一步 先去vuex拉取用户信息
  let info = store.state.userInfo
  let infoLength = Object.keys(info).length === 0
  // infoLength为true 说明是第一次访问系统或者刷新页面,在这里判断路由'/login',防止进入死循环。未登录,status为1,跳到登录页面,已登录,status为0,继续访问页面
  if (infoLength && to.path !== '/login' && to.path !== '/forget') {
    store.dispatch('getUserInfo').then((response) => {
      if (response.status === '0') {
        store.dispatch('userLogin', response.data) // 将用户基本信息存入vuex
        store.dispatch('permission', response.data.userType).then(() => { // 根据用户角色,动态添加权限路由
          router.addRoutes(store.state.navList)
          next({ ...to, replace: true })
        }
        )
      } else if (response.status === '1') {
        next('/login')
      } else if (response.status === '2') { // 刷新时登录失效
        next() // 刷新后 加载出原页面再提示登录失效,防止出现空白页面
        Message({
          message: '登录失效,请重新登录',
          type: 'error',
          duration: 2000,
          showClose: true,
          customClass: 'my-el-message',
          onClose: function () {
            next('/login')
          }
        })
      }
    })
  } else { // infoLength为false 说明是静态点击,不需要任何操作
    next()
  }
})

代码注释写的很详细了,这里用了vuex,做状态管理,还是很方便的。
这段代码流程:刷新或第一次访问项目的时候,会请求这个接口,
clipboard.png

如果接口返回正确数据,说明是已登录,继续跳转路由
如果未返回正确数据,则跳回登录路由。

接口返回正确数据后,根据权限,去筛选路由

clipboard.png

vuex:permission详情

import { asyncRoutes } from '@/router/index'
import { getUserInfo } from '@/common/getUser'
const actions = {
  userLogin: ({commit}, userData) => commit('userLogin', userData), /* 用户信息存储 */
  permission: ({commit}, role) => { /* 根据权限信息加载权限路由 */
    let routeArr = deepCopy(asyncRoutes) // 含有复杂数组对象,循环进行深拷贝
    const b = filterAsyncRouter(routeArr, role)
    commit('navList', b)
  },
  getUserInfo: ({commit}) => { // 检验是否登录请求
    return getUserInfo()
  }
}
// 根据权限 筛选路由
function filterAsyncRouter (asyncRouterMap, roles) {
  const accessedRouters = asyncRouterMap.filter(route => {
    if (route.meta.role.indexOf(roles) >= 0) {
      if (route.children && route.children.length) {
        route.children = filterAsyncRouter(route.children, roles)
      }
      return true
    }
    return false
  })
  return accessedRouters
}
function deepCopy (routeArr) {
  return routeArr.map((arr) => {
    arr = Object.assign({}, arr)
    return arr
  })
}

export default actions

针对刷新时,vuex中数据丢失,这里,没有使用localstorge或者sessionstorge做缓存,主要考虑这两种缓存方式的缺点均不符合公司业务要求,localstorge:手动清除才能消失,sessionstorge:只在本标签页有效。

不如直接来个接口,让后端判断登录时效来的更安全。

好了,关于路由的就分析到这里,大家理解了吗?欢迎大家star哈
https://github.com/zhangxuexi...

查看原文

魏其上 收藏了文章 · 2019-09-24

我的weex开发之路

认识比较浅薄,单纯从使用方面入手,整理了两个半小时,有错误的地方还请指出。

1. 构建项目

创建一个项目之前,首先需要选取合适的工具,目前使用比较广的两个weex脚手架有weexpack和weex-toolkit。

weex-toolkit(创建的weex项目没有ios和android包)

  • weex init weex 创建项目

  • 修改weex.html文件,将./node_modules/weex-vue-render/index.js修改为./node_modules/weex-vue-render/dist/index.js

  • cnpm install 加载依赖包

  • package.json中的scripts配置"app": "npm run build & npm run dev & npm run server"

  • npm run app 启动项目

目录结构如下图:
clipboard.png

weexpack (创建的weex项目有ios和android包)

  • weexpack create weex 创建项目

  • weexpack platform add android 添加android

  • weexpack platform add ios 添加ios

  • weexpack run ios 模拟器运行

目录结构如下图:
clipboard.png

因为我们不打包android和ios,只需要将写好的页面打包成.weex.js文件供ios和android开发人员调用,所以采用了weex init的构建方式。

2. 工具

Weex Devtools

Weex Devtools是Weex开发调试必备的神器,安装好后,终端进入到项目目录,运行weex debug 会自动打开页面

clipboard.png

扫二维码后

clipboard.png

点击Inspector可以看页面信息,我们打开Debugger,然后扫描打包好的js文件二维码就可以开始调试了。

clipboard.png

注: 箭头所指处选debugger,我因为手贱选了个别的,导致好几天console里没有内容提示,还以为版本问题,后来研究了下,发现这里选错了。

3. 遇到的问题

官方demo跑不通

解决:

高一点版本的weex-vue-render里index.js路径改变,导致。修改weex.html文件,将./node_modules/weex-vue-render/index.js修改为./node_modules/weex-vue-render/dist/index.js

使用vue-resources获取接口数据, weex web上好的,但是weex-playground中跑不通,一片空白,错误信息:

[undefined:344:31] ReferenceError: Can't find variable: document
addStyle
addStylesToDom
exports

__webpack_require__

__webpack_require__

__webpack_require__

__webpack_require__

anonymous
a@main.js:4:16690
main.js:7:8740
解决:

weex中不支持document和window,换成其它方式。weex不支持vue-resources,改成weex支持的fetch

<scroll>里loading一直没效果

解决:

<scroll>中使用refresh就没法用loading,去掉refresh模块

webpack报错,错误信息 ERROR in Entry module not found: Error: Cannot resolve 'file' or 'directory' /Users/xx/xx/code/weex/app.js in /Users/xx/xx/code/weex

解决:

开始一直以为是webpack入口没配置对,检查很多遍,各种测试后,发现这里真的没问题

// entry: entries
entry: {
  app: path.resolve('./app.js')
}

后来找到问题出自

resolve: {
  extensions: ['.js', '.vue', '.json']
},

原因是修改了默认的这个配置后,第一个空项不能省略,应该配置为

  extensions: ['', '.js', '.vue', '.json']
},

错误信息 Cannot resolve module 'sass-loader'

解决:

缺少node-sass 或 sass-loader
npm install node-sass sass-loader --save-dev
把sass-loader安装成了"scss-loader": "0.0.1",也是服了我自己。

接口地址只能获取本地数据,配置test环境失败

解决:

server.js中加一层代理

require('http-proxy-middleware')

// api代理
var proxyTable = config.test.proxyTable
Object.keys(proxyTable).forEach(function (context) {
  var options = proxyTable[context]
  if (typeof options === 'string') {
    options = { target: options }
  }
  server.use(proxyMiddleware(context, options))
})

// proxyTable数据
proxyTable: {
  '/api': {
    // 测试服务器
    target: 'http://ip地址:端口号/xx',
    changeOrigin: true,
    pathRewrite: {
      '^/api': ''
    }
  },
  ...
}

weex接口调用,fetch的headers某些字段始终设置不上

解决:

fetch的headers只能设置下面这些字段
参考: https://developer.mozilla.org...


● 人为设置了对CORS安全的首部字段集合之外的其他首部字段。该集合为:

○ Accept
○ Accept-Language
○ Content-Language
○ Content-Type (but note the additional requirements below)
○ DPR
○ Downlink
○ Save-Data
○ Viewport-Width
○ Width

● Content-Type 的值不属于下列之一:

○ application/x-www-form-urlencoded
○ multipart/form-data
○ text/plain

stream的fetch使用get方式请求接口,url都会自动加上&undefined,官网的例子也不例外。原本普通接口多加一个undefined也没太大影响,但是我们项目是需要根据url参数计算签名的,所以一直签名不通过。

clipboard.png

解决:

找到源码出处
clipboard.png
clipboard.png
clipboard.png

weex-vue-render第2753行对get进行了特别处理,第2764行的url拼接了body和hash,因为body没有传值,所以是undefined,注释掉url+=这行就没有undefined了,但是修改node_modules中的包内容显然不是一个合理的解决方案。
于是把get方式传值改为body传过来,web端好了,签名没有问题,但是真机上还是报错,排查后发现问题出在get中使用了body传值,找到开发文档,
http://weex.apache.org/cn/ref...
clipboard.png
然后我凌乱了,为什么明明不能传body你的源码里又要有那么一行代码url += (config.url.indexOf('?') <= -1 ? '?' : '&') + body + hash;。没办法,最后使用了一个超级笨的办法解决了。在签名计算的时候人为的给url加上“&undefined",计算好签名后,web中fetch参数中的url也要加上“&undefined",但是真机上是不会有&undefined的,所以真机上的url需要去掉undefined,好了问题解决了。

storage中的getItem(key, callback)封装后,页面没拿到数据。

解决:

storage异步造成的,使用promise解决

const p1 = new Promise(function (resolve) {
    storage.getItem(key, event => {
      let ls = event.data || ''
      let d = secret.decrypt(ls) // 对密文字符串进行解密。
      d = typeof d === 'object' ? JSON.parse(d) : d
      resolve(d)
    })
  })
  Promise.all([p1]).then(function (result) {
    callback(result)
  }).catch(function(err){
    console.log('error', err)
  })

页面跳转外部非js链接,在网页上是好的,但真机上一片空白

navigator.push({
  url: 'https://segmentfault.com/write?freshman=1',
  animated: "true"
})
解决:

新建一个vue文件,使用weex的web标签包一层,然后打包成weex.js格式,普通调用就好了。
<web class="content" :data-original="url"></web>

跳转weex.js页面传参

解决:

直接在url后面拼接参数,新页面使用this.$getConfig().bundleUrl获取url解析一下就好了。

post提交数据的是后报错415

解决:

头部信息一定要和后端协议好,不允许不一致。

查看原文

魏其上 赞了文章 · 2019-09-23

探索 TypeScript + Jest 开源项目的自动化测试(上)

前言

最近在做一个采用 TypeScript 语言编写的项目,测试库选择了 Jest。我跟着 Jest 文档完成了入门教程后依然不知道从何开始,主要是有以下几个问题:

  1. 测试的时机是什么时候,即什么时候运行 jest
  2. 测试文件放在哪个目录下比较好,业界是不是有比较成熟的规范;
  3. 测试配置文件中常用的有哪些配置以及 TypeScript 项目需要有哪些特殊的配置。

带着这些问题,我前往 Github 上寻找了 5 个用 TypeScript 编写并且使用 Jest 作为测试框架的热门项目进行研究,这 5 个项目分别是:

  1. ant-design
  2. mobx
  3. oni
  4. prettier
  5. vuetify

其中,我认为 Vuetify 最有参考价值,所以本文会以 Vuetify 为例详细地分析它的测试入口和配置,其他的几个库如果有和 Vuetify 不同的地方也会指出来,从而有一个更具全局观的认识。

也希望大家在阅读本文之后,能够组织让自己满意的测试代码结构,也能够看懂 Jest 大多数配置的含义。

另外,本文还涉及以下库的一些内容,也希望能唤起大家的一些思考:

测试入口

首先需要找到自动化测试是从哪里开始的,这里的技巧一般是看项目根目录下的 package.json 中有没有相关的脚本。很幸运,在 Vuetify 项目根目录下的 package.json 中,发现了这段代码:

{
    // ...
    "husky": {
        "hooks": {
            "pre-commit": "node scripts/warn-npm-install.js && yarn run lint && yarn lerna run test -- -- -o"
        }
    }
    // ...
}

注意 yarn lerna run test -- -- -o 这段命令,很显然这就是我要找的测试入口的线索。这段命令的作用是运行项目的 packages 目录下所有包的 test 脚本,并带上 -o 参数。

如果你不了解 YarnLerna 这两个工具,你也许会看不懂上述的命令,这里做个简单的解释。

如果你仔细地看过 Yarn 的文档,你会发现 Yarn 是支持 yarn <command> [--args] 的写法的,这个写法就是调用项目本地的命令行工具(即 node_modules/.bin/ 下的脚本)执行一段命令(command)。上述命令 lerna run test 就是 command,所以上述命令实际上就是调用项目本地的 Lerna 工具来执行一行命令。

细心的你也许会注意到上述命令行中存在两个 --,这又是什么意思呢?我运行了上述命令后,会发现一个这样的提示:

warning From Yarn 1.0 onwards, scripts don't require "--" for options to be forwarded. In a future version, any explicit "--" will be forwarded as-is to the scripts.

这行提示是说命令行中存在一个过时的 -- 参数语法,说明有一个 --Yarn 的老语法,新版本不用传了,但后面我们会讲到在这种场景下不得不传。

如此一来,剩下的 ---o 便是 Lerna 命令的参数了,所以上述命令实际上是调用项目本地的 Lerna 执行 lerna run test -- -o

Learn 的文档可知,lerna run test -- -o 的作用是执行项目的 packages 目录下所有包中含有 test 脚本的命令(不含有的包会自动跳过),而 -- 符号可以将后面的参数传递给 test 脚本,和 Yarn-- 语法一致。

这里可以回答之前的一个问题了,即为什么必须写 Yarn-- 参数?我们可以想一想,如果只留一个 --,那么这个 -- 还是会被 Yarn 识别,最终导致实际运行的命令行是 lerna run test -o,这将会报错,所以两个 -- 都得保留。

还有一个思考题,就是不知道大家有没有想过这里为什么不直接写 lerna run test -- -o 而要写成 yarn lerna run test -- -- -o。我一开始也进入了这个直觉陷阱,认为一般这样写不也是调用本地的 Lerna 工具吗?后来才反应过来,那只是 npm scripts 的特性,只在 scripts 里面那么写,而这里是 husky 的配置,不能直接支持调用本地的命令行工具,需要借助 Yarn 的这个特性。于是我又去翻了翻 husky 的文档,发现里面提到如果命令要支持调用本地命令行工具执行,还需要配置 ~/.huskyrc 文件,这还不如使用 Yarn 的特性来得方便。

接下来便是寻找哪些包里面有 test 脚本,幸运地是,只有 packages/vuetify 这个包包含 test 脚本,脚本对应的命令如下:

"test": "node build/run-tests.js"

test 脚本实际执行了 build/run-tests.js 文件,于是我看了下 build 目录下的 run-tests.js,发现它针对不同系统运行了不同的测试脚本,Windows 下运行 yarn test:win32 -o,其他系统运行 yarn test:unix -o。(提示:这里的 -o 就是之前入口处带的 -o 参数

通过 package.json 文件可以知道 yarn test:win32 -o 实际上运行的是 jest -i -oyarn test:unix -o 实际上运行的是 jest -o提示:当运行 yarn <script> [...args] 的时候,运行脚本对应的命令时也会加上 args 里的那些参数)。

那么,带有 -i 和 没带有 -i 参数有什么区别呢?通过 Jest 文档可知,-i--runInBand 的短命名方式,-i 代表所有测试会串行地在当前进程中执行,这就能够逐步调试了,而 Jest 默认是将测试通过创建子进程的方式运行,无法调试。想必 Windows 不通过串行方式在当前进程中执行,会遇到一些问题吧,具体是什么问题还需要大家亲身测试下告诉我哈。

-o 参数则代表仅对更改的文件进行测试,配合 Git 使用就很节约时间了。

至此,忽略无关紧要的 -i 参数,我们知道了 Vuetify 项目的测试入口实际就是在 packages/vuetify 下运行 jest -o

Vuetify 不同的是,其他项目很少将测试时机放在 Git 钩子上触发,而是手动执行一个 npm script 触发,所以不需要依赖 Yarn 的运行本地命令的功能。另外,其他项目也都不和 Vuetify 一样是个多个 Packages 在一起的项目,所以也不需要用到 Lerna

测试配置

Jest 的运行离不开它的配置,Jest 默认的配置文件是 jest.config.js,或者在 package.json 中直接写配置也行,大多数项目都是采用前一种方式,mobx 则是采用后面这种方式,这个看个人喜好吧,我更喜欢前面的方式,不至于让 package.json 变得很长。另外,ant-design 是通过 --config 参数来指定自己的配置文件,这是因为 ant-design 需要有不同的测试环境来进行测试,如果你的项目也有这个需求,就可以自定义配置文件了。

以下是 Vuetify 项目中 packages/vuetify 的配置(提示:已经将配置中依赖的其他配置展开),基本上搞清楚 Vuetify 的配置,你就明白大多数配置有哪些,并且都是什么意思了:

{
    // 多于一个测试文件运行时不展示每个测试用例测试通过情况
    verbose: false,
    // 测试用例运行在一个类似于浏览器的环境里,可以调用浏览器的 API
    testEnvironment: 'jest-environment-jsdom-fourteen',
    // 以 <rootDir>/src 这个目录做为根目录来搜索测试文件(模块)
    roots: [
        '<rootDir>/src',
    ],
    // 在测试环境准备好之后且每个测试文件执行之前运行下述文件
    setupFilesAfterEnv: [
        '<rootDir>/test/index.ts',
    ],

    // 测试文件模块之间的引用应该是自己实现了一套类似于 Node 的引用机制
    // 不过自己可以配置,下面 module 开头的都是配置这个的,都用例子来说明

    // 例如,require('./a') 语句会先找 `a.ts`,找不到找 `a.js`
    moduleFileExtensions: [
        'ts',
        'js',
    ],
    // 例如,require('a') 语句会递归往上层的 node_modules 中寻找 a 模块
    moduleDirectories: [
        'node_modules',
    ],
    // 例如,require('@/a.js') 会解析成 require('<rootDir>/src/a.js')
    moduleNameMapper: {
        '^@/test$': '<rootDir>/test/index.js',
        '^@/test/(.*)$': '<rootDir>/test/$1',
        '^@/(.*)$': '<rootDir>/src/$1',
        '\\.(css|sass|scss)$': 'identity-obj-proxy',
    },
    // 转译下列模块为 Jest 能识别的代码
    transform: {
        '\\.(styl)$': 'jest-css-modules',
        '\\.(sass|scss)$': 'jest-css-modules',
        '.*\\.(j|t)s$': 'ts-jest',
    },
    // 收集这些文件的测试覆盖率
    collectCoverageFrom: [
        'src/**/*.{js,ts,tsx}',
        '!**/*.d.ts',
    ],
    // 排除 node_modules/vue-router 包以外的都被忽略
    // 说明 vue-router 这个还是要被 `ts-jest` 转译
    transformIgnorePatterns: [
        'node_modules/(?!vue-router)',
    ],
    snapshotSerializers: [
        'jest-serializer-html',
    ],
    // 从下列文件中寻找测试文件
    testMatch: [
        // Default
        '**/test/**/*.js',
        '**/__tests__/**/*.spec.js',
        '**/__tests__/**/*.spec.ts',
    ],
    // 将 `ts-jest` 的配置注入到运行时的全局变量中
    globals: {
        'ts-jest': {
            // 是否使用 babel 配置来转译
            babelConfig: true,
            // 编译 Typescript 所依赖的配置
            tsConfig: '<rootDir>/tsconfig.test.json',
            // 是否启用报告诊断,这里是不启用
            diagnostics: false,
        },
    },
    // Jest 文档中无此配置,应该已经过时了
    name: 'Vuetify',
    // 测试文件名旁边显示的标识
    displayName: 'Vuetify',
    // 在测试环境准备好之前且每个测试文件执行之前运行下述模块
    setupFiles: [
        'jest-canvas-mock'
    ]
}

在进入每一项配置的解读之前,我们对项目中的文件分成两类,一类是测试文件,一类是非测试文件。所谓测试文件就是指该文件内写的是测试代码,非测试文件与之相反,他们之间的关系是非测试文件中的功能可能会被测试文件引用并测试。

下面进入每一项配置属性的详细解读,让大家对 Jest 运行时遵循的一些规则有所了解,顺序按笔者认为的重要程度排列:

rootDir

字符串类型,这个就是设置 Jest 配置中 <rootDir> 模版字符串的值。默认就是 Jest 配置所在的目录,如果你的配置写在 package.json 文件中,那就是 package.json 文件所在的目录,如果都没有,则是你运行 jest 命令所在的目录。由于配置中经常用到,所以明白其含义是非常重要的。

roots

这个配置就是定义 Jest 从哪些目录里面去搜索测试文件。默认值就是 <rootDir>

testMatch

很重要的属性,不知道为什么官网不最先说,我连 Jest 怎么搜索到测试文件来跑的都不知道,那我怎么知道该在哪写测试文件呢?

如果说 roots 属性规定了 Jest 搜索测试文件的范围,那么 testMatch 属性就能让 Jest 在这个范围内精准地规定哪些文件是测试文件。如果 testMatch 定义的搜索范围超出了 roots 定义的范围,这些超出范围之外的测试文件是不会执行的。

默认情况下,Jest 搜索测试文件的原则如下:

  • jest.config.js 配置文件或 package.json 所在位置(一般也是 Jest 命令运行的目录)为根目录;
  • 搜索根目录下所有 __test__ 文件夹下的 js.jsxts.tsx 的文件,作为测试文件放到测试环境中运行;
  • 搜索根目录下所有后缀为 .test.[js|ts|jsx|tsx].spec.[js|ts|jsx|tsx] 的文件;
  • 搜索根目录下所有名为 test.[js|ts|jsx|tsx]spec.[js|ts|jsx|tsx] 的文件。

自定义后,搜索的文件就从根目录按自定义的路径和文件类型来搜寻测试文件。

了解这个属性后,就知道测试文件写在哪里了,比如有些开源项目不配置这个属性,直接在需要测试的源代码文件所在的目录中建立 __test__ 文件夹,在其中写 jsts 文件,后面的系列中我们会看到。

同时,与此类似的属性是 testRegex,除 Vuetify 之外的其他四个项目基本都是使用 testRegex,可以自行前往 Jest 中文文档研究。

testEnvironment

字符串类型,表示测试用例运行的环境,内置环境可以有两个选择:

  • jsdom。默认值,一个类似于浏览器的环境。
  • node。一个类似于 Node 的环境。

另外,还可以通过自己安装环境包来设置环境:

  • <package-name>。使用 NPM 包中的某个环境。例如,第一个 jsdom 选项其实是 Jest 默认带的 jsdom@11,如果想用更高版本的 jsdom,就得自己安装了。Vuetify 使用的就是自己安装的高版本 jsdom 环境,即 jest-environment-jsdom-fourteen

文档上还提到,如果要让某个文件下的测试用例走单独的环境,可以在文件顶部加 @jest-environment <env> 注释来实现。

另外,Jest 还支持自定义测试环境,这部分可以自行查看官方文档进行了解,后续有机会我也会新开一篇文章进行讲解。

至于 Vuetify 为什么要用高版本 jsdom,我的想法是越高的版本实现的浏览器的 API 越多,正好 Vuetify 要用到或者将来要用到更高级的 API,所以就用了高版本的 jsdom 了。

transform

这个属性是设置哪些文件中的代码是需要被相应的转译器转换成 Jest 能识别的代码,Jest 默认是能识别 JS 代码的,其他语言,例如 TypescriptCSS 等都需要被转译。例如 Vuetify 中就用到了 ts-jest 来转译 Typescript,用到了 jest-css-modules 来转译样式模块。这有点像 Webpackloader

transformIgnorePatterns

这个属性是设置哪些文件不需要转译的。默认是 node_modules 中的模块不需要转译,当然如果 node_modules 中有些模块仍需要被转译,你可以像 Vuetify 一样设置该属性。

moduleFileExtensions

接下来的三个属性 moduleFileExtensionsmoduleDirectoriesmoduleNameMapper 这些以 module 开头的属性都是设置模块引用代码的解析规则。这些规则既作用于测试文件也作用于非测试文件,默认情况下这些规则和 Node 的模块解析规则有点像,但是请记住测试时代码并不是运行在 Node 中,而是 testEnvironment 属性定义的测试环境中,因此你可以更灵活的定义模块解析的规则。

moduleFileExtensions 属性就规定了模块检索的文件类型,例如你写了 require('./a),然后 moduleFileExtensions 配的是 ['ts', 'js'],那就先找 ./a.ts,找不到就找 ./a.js。所以你的项目用什么语言写的,就应该把该语言的后缀名放在最前面,这样检索效率也会更高。值得注意的是,这个属性配置中必须包含 js 配置

moduleDirectories

这个属性对直接引用模块名而不是路径的方式定义了模块搜索路径,例如你在代码里面写了 require(<module-name>) 这样一句代码,默认情况下就会和 Node 一样递归搜索 node_modules 目录中是否含有这个名字的模块。如果你配置了其他目录名,就会递归搜索那个文件夹下面的模块了。

moduleNameMapper

这个属性是对模块路径映射,键是正则表达式,值是模块地址,可以用 $1 之类的符号来表示路径中匹配的字符串。值得注意的是,值必须是完整的模块路径,和 Webpack 中的 alias 有所不同,比如以 Webpack 的思维,我想用 @ 代替项目根目录的 src,那么直接写 '@': 'src' 就行了,而这里就不能写成类似的 @: '<rootDir>/src' 了,得写成 '^@/(.*)$': '<rootDir>/src/$1'$1 解析出来后就是个完整的模块路径。总的来说,Webpack 做的更像是对路径上的部分字符串做个替换,而 Jest 则是匹配路径后,将整个路径用对应的值给完全替换掉,这个思维的转变很重要

但是,对于 Typescript 项目来说,可能光配置 moduleNameMapper 属性还是无法解析别名路径的,如果项目和 Vuetify 一样,用了 ts-jest 来解析 Typescript,那么还得配置以下内容才能生效:

globals: {
    'ts-jest': {
        diagnostics: false
    }
}

这里的原因是,如果开启了 diagnostics,就会在编译前执行诊断,诊断时会报模块无法找到的错误,从而终止编译。

globals

这个属性是设置测试环境中的全局变量的,如果你在 globals 中新增了一个 name: 'vabaly' 的配置,那么你将在代码(包含测试文件和非测试文件)中无需声明而直接使用 name 或者 global.name 来获得 vabaly 这个值。

setupFiles

这个属性是定义在每个测试文件运行之前,且在测试环境准备好前就会立即执行的文件或模块。

setupFilesAfterEnv

这个属性是定义在每个测试文件运行之前,且在测试环境准备好后就会立即执行的文件或模块。

无论是 setupFiles 还是 setupFilesAfterEnv,都会在测试文件运行第一行代码前执行。

collectCoverageFrom

这个属性是收集符合 Glob 模式匹配的文件的测试覆盖率,这个和 collectCoverage 属性是相关联的,只有 collectCoverage 设为 true,或者命令行中带有 --coverage 参数,这个配置才会生效。

值得注意的是,当只设置 collectCoveragetrue 时,那么就只会收集测试过程中用到的非测试文件的测试覆盖率。如果又设置了 collectCoverageFrom,则只会收集 collectCoverageFrom 指定的非测试文件的测试覆盖率,而且不管该文件有没有在测试中被用到,都会收集,只不过该文件的测试覆盖率是零罢了。如果你想知道你的代码有哪些还没被覆盖到,设置这个属性是十分有必要的。

verbose

布尔值,默认值是 false。设置是否在运行期间展示每个单独测试用例的测试情况(PASS or Fail)。不过无论设置什么,都会在尾部显示测试错误信息以及总体通过情况,可看下面两张图体会:

  1. verbose 设为 false 的时候:
    verbose-false
  2. verbose 设为 true 的时候:
    verbose

值得注意的是,当 verbose 设为 false,且只有一个测试文件运行时,还是会显示出每个单独测试用例的测试情况,多个测试文件运行时,就不会显示出来了。只有 verbosetrue 时才会显示出来。

displayName

字符串类型,定义在每个测试文件名旁边高亮显示的名字,方便区分不同包的测试,比如设置 displayName: 'TEST' 之后,终端运行测试时就会显示成下图这样:

显示文字

总结

至此为止,开头的几个问题都有了答案:

  1. 测试脚本的运行时机基本都是手动用 npm script 执行,也可以放到 Git 钩子上,保证每次提交都测试通过,不过要注意这两者细微的使用差别;
  2. 测试目录和测试文件名字基本都是自己通过配置文件中的属性配的,但基本都是名字中带有 .spec,并放在 test 目录下;
  3. 测试常用配置就是上面那些,基本都搞清楚了意思。

在弄清楚 Vuetify 的测试命令和测试配置之后,接下来就到了激动人心的测试代码欣赏时间,不过由于本章篇幅过长以及作者精力问题,我们下回再讲!

宣传

欢迎大家 Star 笔者的 Github,另外,也欢迎大家关注笔者的公众号,获取最新文章的推送:

微信公众号二维码

查看原文

赞 9 收藏 4 评论 4

认证与成就

  • 获得 5 次点赞
  • 获得 24 枚徽章 获得 0 枚金徽章, 获得 3 枚银徽章, 获得 21 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-03-15
个人主页被 509 人浏览