Subscribe on changes!

[lifecycle]: return cancel function to remove listener of lifecycle hook

avatar
Aug 9th 2021

What problem does this feature solve?

There is a scene, I want to cancel the unfinished request when the component is unMounted.

import { getCurrentInstance, onUnmounted } from "vue";
import { useApi } from '@/api';

const http = useApi(getCurrentInstance(), onUnmounted)

// send http request
// the request will be abort when component is Unmounted
http.get("/v1/foo/bar")

But, It will registry the onUnmounted on every request, if there are many requests, so there will be a lot of listeners.

So if the life cycle can return a Cancel function, I can accurately control the registration and destruction of the life cycle.

What does the proposed API look like?

import { onUnmounted } from 'vue'

const cancelUnmountedHook = onUnmounted(() => {
  console.log('clear somthing')
})

cancelUnmountedHook() // cancel the hook
avatar
Aug 9th 2021

Note you can keep track of the state of it with a variable:

let shouldSkipUnmountedHooks

onUnmounted(() => {
  if (shouldSkipUnMountedHooks) return
  // ...
})

function cancelUnmountedHook() {
  shouldSkipUnmountedHooks = true
}

You could even write your own onUnmounted function:

function cancelableonUnmounted(fn) {
  let shouldSkipUnmountedHooks

  onUnmounted(() => {
    if (shouldSkipUnMountedHooks) return
    fn()
  })

  return () => shouldSkipUnmonutedHooks = true
}
avatar
Aug 9th 2021

@posva I knew how to hack it to make it works well.

But, it's not a good solution.

function cancelableonUnmounted(fn) {
  let shouldSkipUnmountedHooks

  // even the hook will return immediately
  // but listener is still there, if the function `cancelableonUnmounted` invoke too many times
  // there is still a problem
  onUnmounted(() => {
    if (shouldSkipUnMountedHooks) return
    fn()
  })

  return () => shouldSkipUnmonutedHooks = true
}
avatar
Aug 9th 2021

Maybe I misunderstand what you are asking for, but I don't really see the problem with having "many" unmount hooks "listeners" (I guess you mean callbacks?).

Like, they are cheap, I can't imagine a scenario where you would call a composition function that uses a lifecycle hook 1000's of times.

Can you provide some insight into a situation where this is actually an issue?

Also, I don't really see how this cancel return function really helps. If you call your composable multiple times, you would end up with multiple cancel functions, no?

avatar
Aug 9th 2021

Like, they are cheap, I can't imagine a scenario where you would call a composition function that uses a lifecycle hook 1000's of times.

There is indeed such a scenario that will call many times lifecycle hook. Especially some users stay on one page for a long lone time.

A callback is registered in the lifecycle, but there is no way to destroy it. This is not elegant.

There is a scene, I want to cancel the unfinished request when the component is unMounted.

To implement it, it is very simple, but it will trigger redundant callbacks, because I have no way to destroy the registered callbacks

avatar
Aug 9th 2021

api.ts

import axios, { AxiosRequestConfig } from "axios";
import { onUnmounted, getCurrentInstance } from 'vue'
import { message } from "ant-design-vue";
import type { IAgentInfo } from "@/lib/Socket";
import { store } from "@/store";
import { router } from "@/route";

type disposable = typeof onUnmounted
type VM = ReturnType<typeof getCurrentInstance>

export interface IResponse<T = unknown> {
  code: string;
  success: boolean;
  message: string;
  data: T;
  meta?: IMeta;
}

export interface IMeta {
  total: number
  page: number
  per_page: number
  num: number
}

export interface IRelease {
  name: string;
  tag_name: string;
  description: string;
  created_at: string;
  released_at: string;
  commit: ICommit[]
}

export interface IBranch {
  name: string;
}

export interface ITask {
  id: string;
  agent_id: string;
  created_at: string;
  doned_at: string;
  success: boolean;
  context: {
    [key: string]: string
  }
}

export interface ICommit {
  id: string;
  title: string;
  committed_date: string;
}

interface IAgentTaskDuration {
  id: string
  duration: number
}

export interface IProfile {
  username: string
  nickname: string
  created_at: string
  updated_at: string
}

export interface IShareSession{
  id: string
  user_id: string
  task_id: string
  max_download_times: number
  downloaded_times: number
  duration: number
  expired_at:string
  created_at: string
  task_info: ITask
}

interface LoginData {
  user_info: IProfile
  token_info: {
    access_token: string
  }
}

const http = axios.create({
  baseURL: process.env.NODE_ENV === "development" ? "http://localhost:9527" : location.origin,
  // baseURL: process.env.NODE_ENV === "development" ? "http://192.168.4.254:9527" : location.origin,
});

export class HTTPError extends Error {
  public data: any

  constructor(err: Error, data: any) {
    super()
    this.message = err.message
    this.name = err.name
    this.stack = err.stack

    this.data = data
  }
}

interface Api {
  readonly baseURL: string;
  request<T>(config: AxiosRequestConfig): Promise<IResponse<T>>;
  post(url: "/v1/auth/login", data: { username: string, password: string }, config?: AxiosRequestConfig): Promise<IResponse<LoginData>>;
  get(url: "/v1/user/profile", config?: AxiosRequestConfig): Promise<IResponse<IProfile>>;
  put(url: "/v1/user/password/change", data: { old_password: string, new_password: string }, config?: AxiosRequestConfig): Promise<IResponse<null>>;
  get(url: "/v1/gitlab/customs", config?: AxiosRequestConfig): Promise<IResponse<string[]>>;
  get(url: "/v1/gitlab/releases", config?: AxiosRequestConfig): Promise<IResponse<IRelease[]>>;
  get(url: "/v1/gitlab/branchs", config?: AxiosRequestConfig): Promise<IResponse<IBranch[]>>;
  get(url: "/v1/gitlab/commits", config?: AxiosRequestConfig): Promise<IResponse<ICommit[]>>;
  post(url: `/v1/build/${string}/rebuild`, data: {}, config?: AxiosRequestConfig): Promise<IResponse<ITask>>;
  get(url: `/v1/build/${string}/log`, config?: AxiosRequestConfig): Promise<IResponse<string[]>>;
  post(url: `/v1/build/${string}/share`, data: { duration: number, download_times: number, password?: string }, config?: AxiosRequestConfig): Promise<IResponse<string>>;
  get(url: `/v1/build/share/${string}/session`, config?: AxiosRequestConfig): Promise<IResponse<string>>;
  post(url: `/v1/build/share/${string}/password/validate`, data: {password: string}, config?: AxiosRequestConfig): Promise<IResponse<boolean>>;
  delete(url: `/v1/build/share/${string}`, config?: AxiosRequestConfig): Promise<IResponse<void>>;
  get(url: `/v1/build/share/${string}`, config?: AxiosRequestConfig): Promise<IResponse<IShareSession>>;
  delete(url: `/v1/build/${string}`, config?: AxiosRequestConfig): Promise<IResponse<void>>;
  get(url: `/v1/build/${string}`, config?: AxiosRequestConfig): Promise<IResponse<ITask>>;
  get(url: "/v1/build", config?: AxiosRequestConfig): Promise<IResponse<ITask[]>>;
  post(url: "/v1/build", data: {custom: string, platform: string, arch:string, env: string, tag?: string, branch?: string, hash?: string, release?: string}, config?: AxiosRequestConfig): Promise<IResponse<ITask>>;
  get(url: `/v1/agent/${string}/tasks`, config?: AxiosRequestConfig): Promise<IResponse<ITask[]>>;
  get(url: `/v1/agent/${string}/duration`, config?: AxiosRequestConfig): Promise<IResponse<IAgentTaskDuration[]>>;
  get(url: `/v1/agent/${string}`, config?: AxiosRequestConfig): Promise<IResponse<IAgentInfo>>;
  download(url: `/v1/build`, config?: AxiosRequestConfig): Promise<Blob>;
  get<T>(url: string, config?: AxiosRequestConfig): Promise<IResponse<T>>;
}
class Http implements Api {
  constructor(private vm?: VM, private onUnmounted?: disposable) {
  }

  public get baseURL() {
    return (http.defaults.baseURL as string).replace(/\/$/, "");
  }

  public request<T>(config: AxiosRequestConfig) {
    const headers = {
      ...(config.headers || {}),
    }

    const token = localStorage.getItem('token')

    if (token) {
      headers.Authorization = 'Bearer ' + token
    }

    let done: Function | null // 请求结束的回调函数

    // 当组件被销毁时,则取消请求
    if (this.onUnmounted) {
      const CancelToken = axios.CancelToken;
      const source = CancelToken.source();

      done = this.onUnmounted?.(() => {
        source.cancel()
      }, this.vm) as Function

      config.cancelToken = source.token
    }


    return http.request({ ...config, headers })
      .then((resp) => {
        done && done()
        const data = resp.data as IResponse<T>

        if (data.success) {
          return Promise.resolve(data);
        } else {
          return Promise.reject(new HTTPError(new Error(data.message), resp.data));
        }
      })
      .catch((err) => {
        done && done()
        if (err instanceof HTTPError) {
          return Promise.reject(err)
        } else if (axios.isAxiosError(err)) {
          // token 失效
          if (err.response?.status === 401) {
            message.error('请先登录')
            store.dispatch('logout')
            router.push('/login')
          }
          return Promise.reject(new HTTPError(new Error(err.message), err.response))
        } else {
          return Promise.reject(err)
        }
      });
  }

  private _download(config: AxiosRequestConfig) {
    return http
      .request({
        ...config,
        responseType: "blob",
      })
      .then((resp) => {
        return Promise.resolve(new Blob([resp.data]) as Blob);
      });
  }

  public get<T>(url: string, config: AxiosRequestConfig = {}) {
    return this.request<T>({
      method: "GET",
      url: url,
      ...config,
    });
  }

  public post<T>(url: string, data: any, config: AxiosRequestConfig = {}) {
    return this.request<T>({
      method: "POST",
      url: url,
      ...config,
      data: data
    });
  }

  public put<T>(url: string, data: any, config: AxiosRequestConfig = {}) {
    return this.request<T>({
      method: "PUT",
      url: url,
      ...config,
      data: data
    });
  }

  public delete<T>(url: string,config: AxiosRequestConfig = {}) {
    return this.request<T>({
      method: "DELETE",
      url: url,
      ...config
    });
  }

  public download(url: string, config: AxiosRequestConfig = {}) {
    return this._download({
      method: "GET",
      url: url,
      ...config,
    });
  }
}

export function useApi (vm: VM, onUnmounted: disposable): Api{
  return new Http(vm, onUnmounted) as Api
}

app.vue

import { getCurrentInstance, onUnmounted } from "vue";
import { useApi } from './api.ts'
const http = useApi(getCurrentInstance(), onUnmounted);

// This request will be abort when current component onUnmounted
http.get('/v1/foo/bar')

The keypoint is here. Yeah, The life cycle is indeed called multiple times, and it cannot be destroyed.

    if (this.onUnmounted) {
      const CancelToken = axios.CancelToken;
      const source = CancelToken.source();

      done = this.onUnmounted?.(() => {
        source.cancel()
      }, this.vm) as Function

      config.cancelToken = source.token
    }
avatar
Aug 9th 2021

The way you are using the hook is suboptimal because you are tightly coupling the Http class to Vue-specific implementation details

rather, I'd suggest something like this:

export function useApi (vm: VM, onUnmounted: disposable): Api{
  const http = new Http()
  onUnmounted(() => {
    http.cancelAllRequests()
  })
}

And then in Http, you would keep a list of currently active abortControllers, and remove each controller from that list in a .finally() handler in request.

That way:

  1. Http is decoupled from vue
  2. You only need one lifecycle hook, safely called during setup

Edit: I kinda mixed up axio's source.token with native AbortController, but I think you get the idea.

avatar
Aug 9th 2021

The way you are using the hook is suboptimal because you are tightly coupling the Http class to Vue-specific implementation details

agree! 👍

I totally agree with you, I thought so at the beginning

It may be that I have obsessive-compulsive disorder, and I don’t want to have an uncontrolled "subscription"

avatar
Aug 9th 2021

I don't see this as necessary because you already can create your own abstractions.