2020年10月

好家伙,他闭着眼睛写Vue3

一个风和日丽的下午,我用了3个小时写了一个Vue3的小应用,这个应用小到不足为奇,但是Vue3的开发体验最值得我吹一波,这个小应用在登录我用了Vuex4,路由管理用到了最新的VueRouter4,而UI框架选择的是Vant3,有赞团队的Vant3最新版也是目前为止Vue3支持度最高的移动UI库,放一个项目预览GIF图

整个的开发体验,我总结了一下:有近似ReactHook的开发体验,又保留了Vue2的原汁原味,同时因为Vue3的本身设计原因,也提升了在react中没有的hook开发体验,所以如同标题,如果开发者拥有reactHook开发经验和vue2开发经验,可谓是“闭着眼睛”都能撸Vue3。

Github地址:https://github.com/1018715564/PlanListForVue3


main.js

新版的vue一改以前的构造函数去挂载,而是把函数风格贯彻到底了,在入口文件中我们使用链式调用,一直use一直爽。

import { createApp } from "vue";
import App from "./App.vue";
import Router from "./router";
import Vuex from "./store";
import Vant from "vant";
import "vant/lib/index.css";

createApp(App).use(Router).use(Vant).use(Vuex).mount("#app");

Router

import { createRouter, createWebHistory } from "vue-router";
import Login from "../pages/Login/Index.vue";
import Index from "../pages/Index/Index.vue";
const routerHistory = createWebHistory();
const Router = createRouter({
  history: routerHistory,
  routes: [
    {
      name: "Index",
      path: "/",
      component: Index
    },
    {
      name: "Login",
      path: "/Login",
      component: Login
    }
  ]
});

export default Router;

可以看到Router还是保留了vueRouter3的路由配置,但是创建路由的方式改为了createRouter,原先的路由模式也从原来的mode: "history"也改为了通过函数引入:

"history":createWebHistory()
"hash":createWebHashHistory()
"abstract":createMemoryHistory()

Vuex

import Vuex from "vuex";
export default Vuex.createStore({
  state: {
    user: null,
    planList: []
  },
  mutations: {
    // 删除一个计划
    deletePlan(state, index) {
      state.planList.splice(index, 1);
    },
    // 新增一个计划
    addPlan(state, plan) {
      state.planList.push(plan);
    },
    setUser(state, user) {
      state.user = user;
    }
  },
  actions: {},
  modules: {}
});

我们通过vuex4中的createStore创建了实例,其中我们定义了一些方法和state,这些我们在登录和添加删除计划时会用到,下来我们再看看如何在页面中使用Vue3的组合API以及路由和状态管理。

登录页面

import { reactive } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { Toast } from "vant";
export default {
  name: "App",
  setup() {
    const store = useStore();
    const router = useRouter();
    // 用户名和密码
    const Form = reactive({
      username: "",
      password: "",
    });
    // 登录
    function handelLogin() {
      store.commit("setUser", {
        username: Form.username,
        password: Form.password,
      });
      Toast(`登录成功,你好: ${Form.username}, 请添加你的计划吧~`);
      // 跳转到首页
      router.push({
        path: "/",
      });
    }
    return {
      Form,
      handelLogin,
      handelNavBack,
    };
  }

我们使用路由以及状态管理需要从2个包中引入对应的hook去调用

调用vuex中的mutation:

store.commit(fnName, args);

路由也沿用了VueRouter3中一些老API,replace, go, back等方法都基本不变

 const router = useRouter();
 router.push({
   path: "/",
 });

如果从来没有使用过组合API的开发者,应该需要了解一下前置知识,例如在老版本中,我们定义变量和方法是这样:

data(){
  return {
    count: 0
  }
},
methods: {
  back(){
    return "It is back"
  }
}
{{count}} //在template中这样渲染

我们在vue3中需要把这些东西写到setup这个函数中,包括变量,函数,监听,计算属性,生命周期等等,然后把变量return出去供模板使用,很显然我们在登录时候用了reactive这个API,它可以传入一个普通对象,返回一个响应式的代理,我们用这个对象中的用户名和密码与视图绑定最后调用vuex的方法即可让全局状态管理知道此时 “计划APP” 是有身份进入的。

const Form = reactive({
  username: "",
  password: "",
});

// 调用vuex登录方法
store.commit("setUser", {
  username: Form.username,
  password: Form.password,
});

首页

import { ref, reactive, computed, watchEffect } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { Toast, Dialog } from "vant";

export default {
  name: "Index",
  setup() {
    const store = useStore();
    const router = useRouter();
    const planList = store.state.planList;
    const addPlanForm = reactive({
      title: "",
      remark: "",
    });
    // 添加的弹出层
    const addPopup = ref(false);
    // 计算计划的个数
    const planCount = computed(() => store.state.planList.length);
    // 如果没登录重定向到登录
    if (store.state.user === null) {
      Toast("未登录,请先登录");
      router.replace("Login");
    }
    const handelNavBack = () => {
      router.go(-1);
    };
    const handelAddPlan = (e) => {
      store.commit("addPlan", {
        ...e,
        id: Math.random().toString(36).substr(2),
        date: new Date().toDateString(),
      });
      Toast("添加计划成功");
      addPopup.value = false;
      addPlanForm.title = "";
      addPlanForm.remark = "";
    };
    const handelDeletePlan = (index) => {
      Dialog.confirm({
        title: "提示",
        message: "您缺点要删除此计划吗?",
      }).then(() => {
        store.commit("deletePlan", index);
        Toast("删除计划成功");
      });
    };
    return {
      handelNavBack,
      planList,
      addPopup,
      addPlanForm,
      handelAddPlan,
      handelDeletePlan,
      planCount,
    };
  },
};

引入了vuex和router的hook之后,我们导出了vuex和router的实例,从这个实例中我们可以获得到vuex中的state,则可以判断APP是否有登录用户,如果没有登录就重定向到登录页面。

const store = useStore();
if (store.state.user === null) {
  Toast("未登录,请先登录");
  router.replace("Login");
}

添加计划额外传递id和时间

store.commit("addPlan", {
    ...e, // Form表单的回调,是计划标题和计划备注
    id: Math.random().toString(36).substr(2),
    date: new Date().toDateString(),
  });

使用refAPI将传入的参数返回其响应式代理

// 控制弹窗显示/隐藏的变量
const addPopup = ref(false);

如果传入的是一个对象,那么Vue内部自动会调用reactive,值得注意的是如果需要更改此响应变量,需要对响应式对象中的value属性进行更改。

响应式变量在template渲染时我们不需要写.value

计划APP的全部代码都已经梳理完毕,我们可以在这个APP中学到,状态管理,路由,组合常用的API的简单应用,在组合API中还有一些我们以前经常用到的API,比如计算属性,生命周期,watch等等在组合API拓展阅读中有简单的总结。


组合API拓展阅读

  • computed

计算属性在vue3中非常简单和vue2中如出一辙,在新版本中计算属性需要从vue引入:

import { computed } from "vue"

setup(){
  const planList = computed(() => state.list);
}

计算属性默认传入一个get函数,当然可以像以前一样传入一个set函数

 const planList = computed({
  get(){
    return state.list
  },
  set(list){
    state.list = list
  }
})

planList.value = []; // 重新set值
  • watchEffect

这个Hook非常简单,如果使用过reactHook的useEffect应该非常好理解,如果这个函数所依赖的内容变化,它会重新执行这个函数。

const countEffect = watchEffect(() => {
  console.log(count); // 定义的响应式变量,如果该变量有变更,将会重新打印count
});

当组件卸载或者调用返回中的stop方法即可停止监听,这个机制和vue2中的watch是一样滴。

watchEffect我们称之为副作用函数,我们的业务场景中如果有好友列表,鼠标移动上去能异步获取好友详情,那么如果在鼠标在移动新好友之后,上一次好友详情的请求还没结束,这可能会造成数据混淆的Bug,所以我们副作用函数支持我们这样清除副作用:

watchEffect((onInvalidate) => {
  const detail = getFriendDetail(id.value)
  onInvalidate(() => {
    // id 改变时 或 停止侦听时
    detail.cancel() // 取消请求
  })
})

第一遍看文档时感觉这样清除副作用很麻烦,React是这样清除的:

useEffect(() => {
  // do something
  return () => {
    // 清除副作用
  }
})

Vue设计副作用返回是通过传入一个回调注册清除函数,是因为使用effect钩子往往是异步请求,而异步请求返回的是Promise,所以清除函数一定要在被resolve之前注册,当然文档还说这样的好处还有可以自动帮助我们处理Promise潜在的错误(这个就是后话了嘻嘻)。

下面就是重头戏了,大胆预测一波,未来关于Vue3面试题必将有它的一席之地:watchEffect的刷新时机是什么?

第一次看文档就有猜测过这样傻白甜的问题,副作用监听了依赖它的变量,是如何很好的控制多个变量触发的机制呢,是每个变量触发都会快速的执行一次吗?

用户自定义的副作用函数会在全局缓存一遍会异步地刷新它们,Vue组件的更新函数也是一个副作用函数,刷新机制是在更新函数之后去一遍一遍走自定义的副作用函数
  • watch

watch和Vue2一样,只不过和watchEffect的区别就是:

1. 仅在依赖数据源变化才会回调副作用函数

2. 可以访问到变化前和变化后的值

3. 可以自由设置哪些值是需要监听的

共同点就是:

1. 清除副作用和停止监听

2. 副作用刷新机制也是一样的哟

const star = ref(0);
watch(star, (star, prevStar) => {
  // do something
});

// 监听多个
watch([star, rose, flower], ([star, rose, flower], [prevstar, prevrose, prevflower]) => {
  // do something
});


结语

这段时间拖得太久,Vue系列还有一个Vite的浅析还没发,也基本差亿点就结束了,因为是国庆节写的,也希望大家多多支持呀,冲鸭!

1_-Ijet6kVJqGgul6adezDLQ.png

在以前的react 开发中我们习惯使用class类的写法写react组件,但是现在随着函数式编程的流行,函数作为js的第一公民,我们在react使用函数编写业务组件,在原来只能写无状态的组件,没有自身的state,hook的出现可以让函数组件钩入一些方法,可以让我们在函数组件中使用state和一些class类的组件写法,那么我们这篇笔记将以react-hook文档为基础来做一些总结。

Hook 简介 - React

State Hook

import React, { useState } from "react";

// 计算器的组件
function Main() {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        增加
      </button>
    </div>
  );  
}

export default Main;

我们可以使用useState这个hook来声明state,返回是一个数组,第一个是我们的变量,第二个是设置这个变量的函数,我们使用数组解构的方式结构出来,那么在这个函数的作用域中直接使用count来展示这个state,我们调用函数直接调用setCount这个函数,这样我们就简单的使用statehook这个钩子来实现class组件this.state这个功能了,注意这个set函数和class中的setState不同,在class组件中的setState中,是要传入当前示例的全部state对象,我们往往要使用对象合并API来操作,但是hook中的set函数只需要传递当前的要设置的值即可。

setCount(100);

我们可以设置更多的state

const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: '学习 Hook' }]);

我们没有必要使用这样的hook来声明多个变量,在之后的Q&A中,文档会建议我们更好的方法。

Effect Hook

在class写法中的各种生命周期的钩子,在函数组件中也有对应的hook来替代它们,那么这些钩子我们叫做effect hook,在组件渲染成功后通常会进行一些基于业务的操作,所以我们也称之为“副作用hook”,下面我们对比一下class写法和hook写法

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

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

在componentDidMount以及componentDidUpdate这两个生命周期中,当组件被挂载或者已有prop或者state变更都会触发对应的document.title的业务逻辑,那么在class这样的写法,同样的逻辑在不同的生命写,这会让代码又臭又长,不好维护,我们使用hook来写,那么就非常简单。

import React, { useState, useEffect } from "react";

// 计算器的组件
function Main() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = "你点击了" + count + "次";
  });
  return (
    <div>
      {count}
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        增加
      </button>
    </div>
  );
}

export default Main;

useEffect可以传递一个函数作为入参,我们写一个useEffect就相当于写了组件挂载和更新2个钩子,那么当组件销毁(清除副作用)我们可以这么写

useEffect(() => {
    API.on(); // 比如当页面进入调用订阅API
        return () => {
            API.off(); // 销毁时再取消订阅API
        }
});

useEffect会在每次挂载和更新都会执行我们传入的逻辑,如何控制它将在后面我们会有具体的例子。

那么我们使用effectHook和传统的class中的挂载和更新生命周期还是有区别的。effectHook不会阻塞浏览器的屏幕更新,它会让应用看起来更快。

我们肯定写过下面的逻辑代码:

componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
        // 调用A API
        A();
        // 调用B API
        B();
        // 调用C API
        C();
  }

不得不说,不同的业务逻辑混在一个生命去写,尽管我们有时会把不同的逻辑封装到不同函数中进行组合调用,但是代码还是难以直接去表达业务逻辑,这让维护代码的人叫苦连连,那么我们使用hook,将可以使用像stateHook一样,可以调用多个hook:

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  useEffect(() => {
    API.on(); // 比如当页面进入调用订阅API
    return () => {
      API.off(); // 销毁时再取消订阅API
    };
  });
  // ...
}

react会顺序执行多个effectHook

那么在官方文档中,提到了一个我也很苦恼的问题,在我进行第一次学习effectHook的时候,为什么组件更新还需要调用effect,直到文档做了一个很简单的查询好友信息的demo:

通过好友ID查询好友详情,好友详情组件需要接收一个prop,那么如果当prop进行更新,组件内的effect回调不会触发还是展示上一个好友的信息,而在清除副作用的时候,因为ID错误导致程序异常,那么显然effect设计如此是为了规避程序BUG,那么在class类中,我们应该使用如下方式来规避这样的更新问题:

componentDidUpdate(prevProps) {
    // 取消订阅之前的 friend.id
      API.off(prevProps.id); // 销毁时再取消订阅API
    // 订阅新的 friend.id
    API.on(prevProps.id);
 }

而使用hook则不需要关心更新组件带来的异常, 因为使用effect时,在调用一个新effect时就会清除上一个effect(触发清除回调effect),也就不会产生因为忘记写update逻辑造成的BUG

我们既然知道了effectHook更新设计的原因,那么当部分业务逻辑因为更新而造成的多余性能浪费,class和hook解决方案是怎么样的呢?

// class的写法,比较ID是否变更,变更再进行逻辑
componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

// effectHook提供了第二个可选参数,我们可以传入一个数组,比如
useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

当我们传入一个空数组时

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, []);

那么此effect将在挂载销毁执行,任何值的update都和此effect无关,所以这样的写法一定要注意应用场景。

总结Effect Hook:

  1. 自带的清除机制,可以避免在class中因为update而造成的问题
  2. 使用effect可以让业务逻辑分离解耦,不会像class中的生命周期分离逻辑变的很难

Hook规则概要

hook本质就是JS的函数,但是使用它也需要规则,虽然有专门的语法检查工具:

eslint-plugin-react-hooks

  1. hook需要写在函数中最高的层级,不要在循环,嵌套中去使用它们
  2. 只能在React中使用hook,不要在普通JS函数中去使用hook

关于插件的使用,文档也有非常清楚的配置方法

Hook 规则 - React

至于为什么hook只能在函数中最高层级去使用,官方也在文档中说明了:

demo1:

function Form() {
  // 1. Use the name state variable
  const [name, setName] = useState('Mary');

  // 2. Use an effect for persisting the form
  useEffect(function persistForm() {
    localStorage.setItem('formData', name);
  });

  // 3. Use the surname state variable
  const [surname, setSurname] = useState('Poppins');

  // 4. Use an effect for updating the title
  useEffect(function updateTitle() {
    document.title = name + ' ' + surname;
  });

  // ...
}

useState和useEffect如果都在最高层(函数作用域的最高层)中,那么React就可以把对应的Maryname 进行关联Poppinssurname进行关联,react通过声明的顺序去查找对应的变量,每次渲染的顺序和声明的顺序都是相同的,所以React才可以找到,那么如果我们写了如下的代码:

if(name !== null){
    useEffect(function persistForm() {
    localStorage.setItem('name ', name);
  });
}

这样触犯了hook的第一条规定,导致了程序有可能不会声明useEffect这个钩子,如果没有声明,那么声明的顺序和渲染的顺序就会不一致,就导致这个钩子之后的所有声明的钩子都将会被提前执行,导致BUG产生

自定义Hook的实现

我们自己写一个求差的小demo,参数传入减数和被减数简单体验一下自定义hook:

这个demo展示了hook和组件的传参,当组件的count改变时会触发到hook中的effect,effect会重新计算差并返回。

import { useState, useEffect } from "react";

export function useSeekingDifference(
  subtraction: number,
  minuend: number
): number {
  const [difference, setDifference] = useState(0);
  useEffect(() => {
    setDifference(subtraction - minuend);
  }, [subtraction, minuend]);
  return difference;
}

使用hook

import React, { useState, useEffect } from "react";
// 引入自定义hook
import { useSeekingDifference } from "./../hook/computed";

// 计算器的组件
function Main() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = "你点击了" + count + "次";
  });
 console.log(useSeekingDifference(count * 2, count)); // 使用自定义hook
}

export default Main;

我们写自定义hook的目标就是:

  1. 抽离业务逻辑

但是对于业务性非常强,以及对状态管理有频繁操作的建议使用redux(尽管我没用过,但是官方建议这样)

我们书写hook时需要注意:

  1. hook不是react特有,只是遵循了hook的规定的函数
  2. 必须使用use开头,因为不仅仅代表了它是一个hook,也可以让插件去做校验规则
  3. 相同hook不会共享state,每一个hook都是有独立的state和effect的,是重用状态逻辑机制