[lifecycle]: return cancel function to remove listener of lifecycle hook
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
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
}
@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
}
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?
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
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
}
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:
Http
is decoupled from vue- 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.
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"