Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic Lifecycle Injection #25

Closed
yyx990803 opened this issue Mar 20, 2019 · 6 comments
Closed

Dynamic Lifecycle Injection #25

yyx990803 opened this issue Mar 20, 2019 · 6 comments

Comments

@yyx990803
Copy link
Member

  • Start Date: 03-05-2019
  • Target Major Version: 2.x & 3.x
  • Reference Issues: N/A
  • Implementation PR: N/A

Summary

Introduce APIs for dynamically injecting component lifecycle hooks.

Basic example

import { onMounted, onUnmounted } from 'vue'

export default {
  beforeCreate() {
    onMounted(() => {
      console.log('mounted')
    })

    onUnmounted(() => {
      console.log('unmounted')
    })
  }
}

Motivation

In advanced use cases, we sometimes need to dynamically hook into a component's lifecycle events after the component instance has been created. In Vue 2.x there is an undocumented API via custom events:

export default {
  created() {
    this.$on('hook:mounted', () => {
      console.log('mounted!')
    })
  }
}

This API has some drawbacks because it relies on the event emitter API with string event names and a reference of the target component instance:

  1. Event emitter APIs with string event names are prone to typos and is hard to notice when a typo is made because it fails silently.

  2. If we were to extract complex logic into external functions, the target instance has to be passed to it via an argument. This can get cumbersome when there are additional arguments, and when trying to further split the function into smaller functions. When called inside a component's data() or lifecycle hooks, the target instance can already be inferred by the framework, so ideally the instance reference should be made optional.

This proposal addresses both problems.

Detailed design

For each existing lifecycle hook (except beforeCreate), there will be an equivalent onXXX API:

import { onMounted, onUpdated, onDestroyed } from 'vue'

export default {
  created() {
    onMounted(() => {
      console.log('mounted')
    })

    onUpdated(() => {
      console.log('updated')
    })

    onDestroyed(() => {
      console.log('destroyed')
    })
  }
}

When called inside a component's data() or lifecycle hooks, the current instance is automatically inferred. The instance is also passed into the callback as the argument:

onMounted(instance => {
  console.log(instance.$options.name)
})

Explicit Target Instance

When used outside lifecycle hooks, the target instance can be explicitly passed in via the second argument:

onMounted(() => { /* ... */ }, targetInstance)

If the target instance cannot be inferred and no explicit target instance is passed, an error will be thrown.

Injection Removal

onXXX calls return a removal function that removes the injected hook:

// an updated hook that fires only once
const remove = onUpdated(() => {
  remove()
})

Appendix: More Examples

Pre-requisite: please read the Advanced Reactivity API RFC first.

When combined with the ability to create and observe state via standalone APIs, it's possible to encapsulate arbitrarily complex logic in an external function, (with capabilities similar to React hooks):

Data Fetching

This is the equivalent of what we can currently achieve via scoped slots with libs like vue-promised. This is just an example showing that this new set of API is capable of achieving similar results.

import { value, computed, watch } from 'vue'

function useFetch(endpointRef) {
  const res = value({
    status: 'pending',
    data: null,
    error: null
  })

  // watch can directly take a computed ref
  watch(endpointRef, endpoint => {
    let aborted = false
    fetch(endpoint)
      .then(res => res.json())
      .then(data => {
        if (aborted) {
          return
        }
        res.value = {
          status: 'success',
          data,
          error: null
        }
      }).catch(error => {
        if (aborted) {
          return
        }
        res.value = {
          status: 'error',
          data: null,
          error
        }
      })
    return () => {
      aborted = true
    }
  })

  return res
}

// usage
const App = {
  props: ['id'],
  data() {
    return {
      postData: useFetch(computed(() => `/api/posts/${this.id}`))
    }
  },
  template: `
    <div>
      <div v-if="postData.status === 'pending'">
        Loading...
      </div>
      <div v-else-if="postData.status === 'success'">
        {{ postData.data }}
      </div>
      <div v-else>
        {{ postData.error }}
      </div>
    </div>
  `
}

Use the Platform

Hypothetical examples of exposing state to the component from external sources. This is more explicit than using mixins regarding where the state comes from.

import { value, onMounted, onDestroyed } from 'vue'

function useMousePosition() {
  const x = value(0)
  const y = value(0)

  const onMouseMove = e => {
    x.value = e.pageX
    y.value = e.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', onMouseMove)
  })

  onDestroyed(() => {
    window.removeEventListener('mousemove', onMouseMove)
  })

  return { x, y }
}

export default {
  data() {
    const { x, y } = useMousePosition()
    return {
      x,
      y,
      // ... other data
    }
  }
}
@Akryum
Copy link
Member

Akryum commented Mar 20, 2019

I could totally see a babel plugin compiling res = to res.value = 😸

@LinusBorg
Copy link
Member

Love it as well as the other part of this.

A nice side effect of this approach would be that it solves the problem of naming conflicts when we would introduce new lifecycle hooks in later versions, right?

Right now, in the public discussion about the class API, we are in a back and forth how to namespace lifecycle hooks when using the class API. If we declare lifecycle hooks deprecated, or committed to introducing new hooks as these dynamic lifecylce hooks only, risks of breaking our user's code in the future disappear.

@posva
Copy link
Member

posva commented Mar 23, 2019

I don't get why using value instead of state here.
Do the same rule for automatic watcher removal apply to hook listeners?

Everything else looks good!

Regarding replacing lifecycle hooks in class declarations, I don't think we should replace them because it's a less intuitive api

@yyx990803
Copy link
Member Author

I don't get why using value instead of state here.

Yes, the fetch example can use state instead.

For the mouse example, if you return a plain object, x and y becomes actual JavaScript values, and they will never update. Then you would have to bind the object like this instead:

data() {
  const mouse = useMousePosition()
  return {
    mouse
  }
}

Then access it as mouse.x and mouse.y. This works of course, but you'd have to do this even if the function only exposes a single value - which essentially is a ref.

Do the same rule for automatic watcher removal apply to hook listeners?

Lifecycle hooks don't need to be explicitly removed if the component is already unmounted (because they would never fire again)

@yyx990803
Copy link
Member Author

Published: vuejs/rfcs#23

@381510688
Copy link

in vue2

<Child @hook:mounted="doSomething"></Child>

parent components can listen to its children's’ lifecycle hooks. especially for third-party components.

What should we do in VUE3?

@github-actions github-actions bot locked and limited conversation to collaborators Oct 29, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants