Skip to content

Vue2中自定义hooks代替鸡肋的mixin

当React开发者炫耀Hooks时,Vue2用户只能沉默?不! 本文带你解锁Vue2中更优雅的逻辑复用方案——自定义Hooks,无需 composition-api 垫片,更不用升级Vue3,就能获得:

✨ 比mixins更干净的代码组织
✨ 比高阶组件更直观的逻辑抽离
✨ 比EventBus更精准的状态管理

小贴士

文章中涉及到的代码示例你都可以从 这里查看

前言

React在19年就提出了hooks开发范式,并最早实现了支持。而Vue面对庞大的用户基数也必须紧跟其后,满足开发者的使用需求

Vue2由于天生的设计缺陷很难实现hooks的开发功能,因此尤大在V3中通过破坏性的重构,满足了这些先进功能,比如:按需加载、函数式编程、类型编程等等

现在2021年初,我抱着好奇心体验了一把Vue3.0正式版本,通过和Vue2进行对比,开发体验和效率上确实提升不少

那么让开发者苦苦诉求的hooks到底是什么呢❓

什么是hooks

Hooks 是代码的乐高积木——将复杂逻辑拆解为可拼装、可复用的函数单元✨

Hooks(钩子) 是一种函数式编程范式,核心是通过特定函数实现:

  • 1️⃣ 逻辑复用:抽离组件中的状态/副作用逻辑,告别复制粘贴
  • 2️⃣ 关注点分离:将代码按功能(如数据请求、监听事件)而非生命周期拆分
  • 3️⃣ 无侵入扩展:不修改组件结构即可增强功能(对比高阶组件/Mixin)

这种形式可以很好的将可复用的逻辑封装,在任意组件调用,并且永远不可能产生冲突

vue3中的hooks

在vue3中自定义一个hooks也很简单:

js
import { reactive } from "vue";

export function useCounter(init = 0) {
  const state = reactive({ count: init });

  function increment() {
    state.count++;
  }

  function decrement() {
    state.count--;
  }

  return {
    state,
    increment,
    decrement,
  };
}

从函数中可以看到我们用 reactive 定义一个响应式对象,然后定义几个方法最后以对象的形式return出去

在页面中直接使用其返回的响应式对象或方法都是可以的

jsx
export default {
  setup() {
    const { state, increment } = useCounter()
    return { state, increment }
  },
  render() {
    return <p>{ this.state.count }</p>
  }
}

如果要用到组件内部的生命周期钩子,直接使用vue提供的钩子函数,比如:onMounted等等

js
import { onMounted } from 'vue'

export function useCounter() {
  onMouted(() => { /* 逻辑 */})
}

从上总结hooks就是通过函数调用产生闭包作用域,内部的响应式对象通过和组件render函数(本质)关联,达到响应式的效果

mixin的痛苦

mixin可能是大家在vue2中常用的逻辑复用手段,但它有太多的缺陷:

  • 变量命名冲突
  • 变量来源不清晰,像个无底洞
  • 无法有效的类型推导
  • 复用逻辑分散,很难实现复杂逻辑组装
jsx
const CounterMixin = {
  data() {
  	return { count: 0 }
  },
  methods: {
    increment() { this.count++ },
    decrement() { this.count-- },
  }
}

// 在某个页面使用
export default {
  mixins: [CounterMixin],

  // 组件特有...
  render() {
  	return <p>{ this.count }</p>
  }
}

当你试了vue3后,啊真香还用啥vue2!别急,有些老项目或者有很多历史包袱情况下只能用2时,你或许可以使用下面这些手段实现hooks开发

Observable

Observable 是今天的主角,想在vue2中实现hooks开发,使用它可以很轻松实现

温馨提示

Observable 在 2.6 版本中才被支持

观察vue3中的hooks开发或者所有的hooks,都能看出hooks内部通常会有自己的状态,不会被外部环境所污染,其他就是一些普通方法,通过方法来改变内部的状态等等。vue3中可以使用提供的响应式APIreactiveref或其它等等

如果你了解Observable或者源码就会知道其本质是内部的 observe 函数,作用就是将一个对象变成响应式

那么就来了,可以通过它在自定义hooks中管理状态

实现自定义hooks

还是上面的例子,我们通过vue2自定义hooks实现下:

js
import Vue from "vue";

export function useCounter(init = 0) {
  const state = Vue.observable({
  	count: init
  });

  function increment() {
    state.count++;
  }

  function decrement() {
    state.count--;
  }

  return {
    state,

    increment,
    decrement,
  };
}

然后在页面组件中使用:

jsx
export default {
  data() {
    this.counter = useCounter(10)
    return {}
  },
  render() {
    return <div>
      <p>{ this.counter.state.count }</p>
      <button onClick={this.counter.increment}>增加</button>
      <button onClick={this.counter.decrement}>减少</button>
    </div>
  }
}

来看下效果:

上面代码可能你会有一些疑问❓比如:为什么 this.counter 没有 放在return 里面等等,这个我们后面再说

通过实践确实实现了useCounter的Vue2版本,也符合hooks功能封装的规范及特点,而且不会产生命名冲突的风险

生命周期钩子

基本的响应式对象hooks实现基本没有任何问题,现在需求要求在某个生命周期执行一些逻辑,那该怎么办呢❓

同样的如果你熟悉源码,很容易就会想到关于生命周期的一些hack写法

这里我们来实现一个监听鼠标坐标的hooks——useMouse

js
export function useMouse(vm: Vue, selector: string) {
  const state = Vue.observable({ mouseX: 0, mouseY: 0 });

  vm.$on("hook:mounted", () => {
    const el: HTMLElement = document.querySelector(selector) as HTMLElement;
    if (!el) return;
    el.addEventListener("mousemove", handleMouseMove);
  });

  vm.$on("hook:destroyed", () => {
    const el: HTMLElement = document.querySelector(selector) as HTMLElement;
    if (!el) return;
    el.removeEventListener("mousemove", handleMouseMove);
  });

  function handleMouseMove(e: MouseEvent) {
    state.mouseX = e.pageX;
    state.mouseY = e.pageY;
  }

  return {
    state,
  };
}

没错,通过组件实例监听 this.$on("hook:生命周期", cb) 就可以实现编程式生命钩子注册

来看下效果怎么样:

通过编程式生命周期钩子注册也实现了hooks内部生命周期的调用,现在和Vue3对比下基本上都能实现

计算属性

如果对于一个值需要经过一些简单逻辑的计算的话,那么通常都会用到计算属性

在Vue3中提供了 computed 函数可以轻松在hooks中实现计算属性

js
import { reactive, computed } from 'vue'

export function useCounter() {
  const state = reactive({ mouseX: 0, mouseY: 0 });
  const total = computed(() => state.mouseX + state.mouseY)

  return { state, total }
}

那么在vue2中自定义hooks中也能实现码❓答案是可以的,但是如果是用Observable是无法实现的,这里我们仅仅实现下通过计算后得到值的属性

js
const state = Vue.observable({ mouseX: 0, mouseY: 0 })

如果直接通过在observable传入一个对象的方式,是没法通过添加属性后进行其它属性值计算得到结果的,那么我们可以结合js的特性,巧妙实现属性的计算:

js
class State {
  mouseX: number;
  mouseY: number;
  constructor() {
    this.mouseX = 0;
    this.mouseY = 0;
  }

  get total() {
    return this.mouseX + this.mouseY;
  }
}

export function useMouse() {
	const state = Vue.observable(new State())
  // 省略其他...
}

看到了吧,将响应式数据抽取成一个单独的State类,通过get标识符实现属性值获取计算(当然通过对象其它形式也可以实现,只是这种更专业些)

在组件实例上,也可以看出当前的响应式对象是一个State实例

来看下效果:

watch

还有常用的 watch 也可以通过函数式编程实现注册

js
export function useMouse(vm: Vue, selector: string) {
  const state = Vue.observable(new State())

  vm.$watch(
    () => state.mouseX,
    () => {
      console.log("watch...");
    }
  );
}

读者感兴趣可以自己本地试试,确实可以的

到这里关于vue2中如何使用hooks开发基本上就可以掌握了,赶紧去试试吧,简直不要太爽‼️

可行性分析

通过上面的演示证明了确实可以在vue2中进行hooks开发,那么你有没有想过这背后的原因是什么呢?本章节就来看看究竟怎么个事

vue中每个组件本质都是一个对象,在组件渲染时会将内部定义的一些对象数据和方法挂载到当前组件实例的对象上,这样页面dom节点才能访问到组件内部的数据

再来看看所有的hooks,其本质也是将hooks中所有的属性和方法挂载到实例上了,而我们自定义hooks和vue3中的hooks以及mixin最大的不同就是,vue3和mixin的属性或方法的实例挂载是由框架内部完成的,我们自定义的hooks是手动挂载的

js
this.mouse = useMouse()

通过this.mouse就可访问到hooks内部的状态和方法了,像mixin也是在组件实例化过程中与当前组件进行合并挂载,而vue3中的hooks本质也是将hooks内部状态和方法进行了挂载

那么挂载后hooks内的状态改变时,页面中就会对应更新吗?从上面的例子中可以知道页面确实会更新,那么从vue2中的响应式设计来理解下

深入响应式

读者应该都知道vue2借助Object.defineProperty实现了响应式,本质是对getter/setter的拦截,通过借助DepWatcher通过发布订阅模式,通知依赖进行更新

当组件渲染时其内部的渲染函数会成为全局唯一的Dep.target,当模板访问内部的响应式对象时就会把组件的render函数与它们进行关联,那么数据变动时就会触发setter然后通知依赖更新,比如render函数页面就会更新

observable本质和组件渲染时初始化内部data时执行的过程是一致的,本质都是调用observe方法进行数据响应式处理

那么就可以明白我们自定义hooks内部state改变时页面同时也会重新渲染的原因了,说到底还是state与页面render的订阅关系;这个state不管在什么位置,只要改变了就会正确响应

为什么不是data return中赋值

上面我们留了一个问题,为什么要将hooks的执行放在data return 之前,放在 return 中行不行❓

js
export default {
  data() {
  	this.mouse = useMouse()
    return {}
  }
}

当然可以放在return中,只不过有一些性能损耗

组件在实例化时会将data return出的所有东西进行响应式,源码如下:

ts
function initData (vm: Component) {
  let data = vm.$options.data

  // 如果data是函数,执行函数拿到返回值
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

  // 省略...

  // 将return的对象进行响应式处理
  observe(data, true /* asRootData */)
}

从源码中可以看出,data函数返回后对象会做响应式处理,而我们的hooks本身内部有自己的状态,并且由内部自身管理,和组件是没有半毛钱关系的,如果放在data return里将会对hooks返回的对象所有属性或方法做一次响应式处理,完全多此一举

而在return之前执行既可以拿到当前组件this,也可以避免性能消耗。有同学说了,那我可以放在其它地方吗,比如:

js
export default {
  mounted() {
    this.mouse = useMouse()
  }
}

其实也是可以的,但是不推荐!原因还是和组件的实例化时内部各个属性处理的时期有关,如果放在mounted阶段,只会在挂载时组件才会有hooks内部的相关状态和方法,这之前组件dom节点无法拿到状态,也就是undefined,体验会大大降低

setup很爽我可以用吗

setup很爽但确实不能用,毕竟这是vue3破坏性的更改,框架内部本身就无法支持的

如果聪明的你就是想往setup靠拢,或许可以这么做

上面我们已经证明了vue2中是可以进行hooks开发的,那我们将所有的东西都写进一个hooks中,把这个单独的hooks当做setup入口来执行,是不是有点那种味道:

  1. 定义setup hooks
js
import Vue from "vue";
import { useMouse } from "@/hooks/use-mouse.hook";

class State {
  constructor() {
    this.count = 0;
  }
}

function setup(vm) {
  // 其实直接使用this就是组件实例
  // 这里vm外面也不需要传,框架内部在初始化时会自动传入

  const state = Vue.observable(new State());
  const { state: mouseState, ...rest } = useMouse(vm, "#box");

  function inc() {
    state.count++;
  }

  // 将方法直接挂载到实例上
  const proxy = [...Object.values(rest), inc];
  proxy.forEach((l) => (vm[l.name] = l));

  this.state = state;
  this.mouseState = mouseState;

  // 返回空,这样不会重复进行响应式处理
  return {};
}

setup中我们也使用了之前封装好的useMouse,现在我们在页面中使用:

jsx
import setup from './setup'

export default {
  name: "test-setup-hooks",
  // 这是我们的setup
  data: setup,
  render() {
    return (
      <div>
        <h1>{this.state.count}</h1>
        <button onClick={this.inc}>增加</button>

        <hr />

        <div id="box">
          {this.mouseState.mouseX},{this.mouseState.mouseY}
        </div>
      </div>
    );
  },
};

我们来看下效果:

看上去是没有什么问题的,但本人还是不推荐这样做,觉得复杂度稍微有点高,有点牵强,读者自行决定

不过我们可以稍微优化下,定义一个useSetup钩子函数专门用来处理setup返回的结果,将所有属性挂载到组件实例上:

ts
export function useSetup(vm: Vue) {
  // 拿到组件内部的customSetup选项,也就是自定义的setup函数,命名任意
  const setup = vm.$options.customSetup;
  const result = setup(vm);

  // 这里将 customSetup 返回的值代理到 组件this上
  Object.keys(result).forEach((key) => {
    vm[key] = result[key];
  });

 // 当前组件data返回空对象
  return {};
}

页面组件中使用:

jsx
import Vue from "vue";
import { useMouse } from "@/hooks/use-mouse.hook";
import { useSetup } from "@/hooks/use-setup.hook";

export default {
  name: "test-setup-hooks",
  data() {
    return useSetup(this);
  },
  customSetup(vm) {
    class State {
      constructor() {
        this.count = 0;
      }
    }
    // 其实直接使用this就是组件实例,这里vm外面也不需要传,框架内部在初始化时会自动传入

    const state = Vue.observable(new State());
    const { state: mouseState, ...rest } = useMouse(vm, "#box");

    function inc() {
      state.count++;
    }

    return {
      state,
      mouseState,

      inc,
      ...rest,
    };
  },
  render() {
    return (
      <div>
        <router-link to="/">Home</router-link>
        <hr />
        <h1>{this.state.count}</h1>
        <button onClick={this.inc}>增加</button>

        <hr />

        <div id="box">
          {this.mouseState.mouseX},{this.mouseState.mouseY}
        </div>
      </div>
    );
  },
};

这么一觉得有点那么个意思😂

new Vue

上面都是在说observable来管理响应式状态,而new Vue同样是可以的,可能很多开发者都拿他来做eventBus,那么同样的道理也可以拿他当做响应式对象

observable我们好像遗留了一个计算属性的问题,只是通过js特性巧妙的实现了,但本质不是vue的计算属性,那如果用new Vue代替observable,那什么都可以实现了

那为什么文章一开始不使用new Vue呢❓

太重了‼️ 如果就为了实现hooks内部的响应式状态而用new Vue,那么会经历实例化过程很多复杂的逻辑,完全没有必要

类型推导

这里来看看vue2借助 vue-property-decorator 实现Typescript类型编程,现在我们在组件内使用我们的hooks

vue
<template>
  <div id="container" class="container">
    {{ mouse.state.mouseX }},{{ mouse.state.mouseY }}
  </div>
</template>

<script lang="ts">
import { useMouse } from "@/hooks/use-mouse.hook";
import { Component, Prop, Vue } from "vue-property-decorator";

@Component
export default class TestTsHooks extends Vue {
  @Prop() private title!: string;

  private mouse: ReturnType<typeof useMouse> = useMouse(this, "#container");
}
</script>

这里是2张关于编辑器类型推导的截图

这个是在template模板中的类型推导

可以看到使用hooks后类型推导更加友好,如果使用mixin竟会是一头雾水

不过这里有一个小问题,就是在class component中定义的所有属性都会作为data return中的属性,也就是说会被再次执行响应式处理,可以通过特殊手段优化

这里采用Inject注解来避免这个行为:

ts
import { useMouse } from "@/hooks/use-mouse.hook";
import { Component, Inject, Prop, Vue } from "vue-property-decorator";

@Component
export default class TestTsHooks extends Vue {
  @Prop() private title!: string;

  @Inject({
    from: "mouse",
    default: function () {
      return useMouse(this, "#container");
    },
  })
  private mouse!: ReturnType<typeof useMouse>;
}

这种方式功能也正常,同时避免重复响应式处理

经典hooks

到这里关于vue2中的hooks开发就结束了,下面我们来封装几个使用频率很高的hooks

useQueryTable

useQueryTable通常用来封装表格数据的请求等等,比如:表格数据、分页状态、排序状态、加载状态等等

js
import Vue from "vue";

class State {
  constructor({ pageSize }) {
    this.page = 1;
    this.pageSize = pageSize || 10;
    this.total = 0;

    this.isLoaded = false;
    this.isEnded = false;
    this.loading = false;

    this.dataSource = [];
  }

  get isEmpty() {
    return this.isLoaded && this.dataSource.length === 0;
  }
}

export function useQueryTable(opts) {
  const config = Object.assign(
    {
      pageSize: 10,
      tableScrollCls: null,
      scrollIntoView: true,
      loadData: async () => null,
      tapResult,
    },
    opts
  );

  const state = Vue.observable(new State({ pageSize: config.pageSize }));

  const searchModel = [];

  async function tapResult(_state: InstanceType<State>, res) {
    if (typeof res === "function") {
      await res(_state);
    } else {
      _state.total = Number(res.count);
      _state.dataSource = res.dataList;

      if (_state.page * _state.pageSize >= _state.total) {
        _state.idEnded = true;
      }
    }
  }

  async function loadData(...args) {
    scrollIntoView();

    backupSearchModel(...args);

    try {
      if (state.isEnded) return;

      state.loading = true;
      state.isLoaded = true;

      const res = await config.loadData(
        JSON.parse(JSON.stringify(state)),
        ...getSearchModel(...args)
      );

      await config.tapResult(state, res);

      if (!state.dataSource.length & (state.page > 1)) {
        onPageChange(state.page - 1);
      }
    } finally {
      state.loading = false;
    }
  }

  async function reset() {
    state.isEnded = false;
    state.page = 1;
    state.pageSize = config.pageSize;
    resetSearchModel();

    await loadData();
  }

  async function onPageChange(page) {
    state.page = page;
    state.isEnded = false;

    await loadData();
  }

  async function onPageSizeChange(pageSize) {
    state.page = 1;
    state.pageSize = pageSize;
    state.isEnded = false;

    await loadData(...searchModel);
  }

  async function search(...args) {
    state.page = 1;
    state.isEnded = false;

    await loadData(...args);
  }

  async function reload() {
    state.isEnded = false;
    await loadData(...searchModel);
  }

  function backupSearchModel(...args) {
    if (args && !!args.length) {
      searchModel.length = 0;
      searchModel.push(...args);
    }
  }

  function resetSearchModel() {
    searchModel.length = 0;
  }

  function getSearchModel(...args) {
    if (!args || !args.length) return searchModel;

    return args;
  }

  function scrollIntoView() {
    if (config.scrollIntoView && config.tableScrollCls) {
      const elem = document.querySelector(config.tableScrollCls);

      if (!elem) return;

      elem.scrollTo({ x: 0, y: 0 });
      elem.scrollTop = 0;
    }
  }

  return {
    state,

    loadData,
    search,
    reload,
    reset,
    onPageChange,
    onPageSizeChange,
  };
}

这里只是简单的写了下逻辑,读者可以尝试以下:

jsx
export default {
  data() {
    this.qt = useQueryTable({ loadData: this.loadData })
    return {}
  },
  methods: {
  	async loadData(state) {
      const res = fetch()

      return res
  	}
  },
  render() {
    return <div loading={this.qt.state.loading}>
      { this.qt.state.dataSource }
    </div>
  }
}

其它

由于篇幅原因这里就不再举例了,其实你在其它地方看到的hooks,学完本篇文章完全可以迁移到vue2中,更多案例可点击查看文章案例

总结

通过此篇文章希望读者能对hooks开发以及vue有更深的理解,也希望本篇文章在你日常工作中有实际帮助,感谢阅读

感谢支持

再次感谢您的支持,您的支持将鼓励我继续创作。文章通常首发公众号,可以关注我的公众号,获取最新优质文章;同时如果你有 珠宝首饰之类 的需求,也可以微信扫码光临小店,种类多多欢迎来选👏🏻
大卫talk
 
aphrodite-u

若您在阅读过程中发现一些错误:如语句不通、文字、逻辑等错误,可以在评论区指出,我会及时调整修改,感谢您的阅读。同时您觉得这篇文章对您有帮助的话,可以打赏作者一笔作为鼓励,金额不限,感谢支持🤝。
支付宝捐赠   微信支付捐赠

Released under the MIT License.