vuejs / rfcs

RFCs for substantial changes / feature additions to Vue core
4.88k stars 546 forks source link

Open Reactive API a little more #129

Closed jods4 closed 4 years ago

jods4 commented 4 years ago

This issue is meant to start a discussion that -- I hope -- might lead to opening up the reactive API a little more.

Brief recap of reactive API

reactivity is an internal library of Vue 3 that provides state observation. It can be seen as having two layers.

The lowest layer are the primitives that define state observation. track and trigger define state (read detection and changes); effect and stop define actions that are called when the state changes.

reactivity itself provides higher-level building blocks for state: ref wraps a reactive single value; react creates a reactive object (or array), i.e. multiple values. There's also computed, which is a kind of ref (I'll come back to what this means later), that wraps the (cached) result of a function rather than having its own value. Of course, those are built on top of track and trigger.

Vue (nit: why not reactivity?) provides a higher-level building block for actions: watch, built on top of effect and stop.

Currently, none of the lower layer track, trigger, effect or stop is publicly exposed. watch is an ok replacement of effect, so I'll focus on the state side only.

Motivations

The reactive system under Vue 3 is incredibly flexible and powerful. Advanced users may want to create their own primitives for multiple reasons: to adopt a different programming style, to glue different apis together, to achieve more efficiency/performance or to work around limitations...

Some examples:

These are just a few ideas, I'm sure the community will have more.

The bottom line here is that it's not reasonable to think Vue core wants or even should create all these and maintain them. Those are best created in 3rd party libraries, esp. the ones that have dependencies with other frameworks or tools.

Why ref and react are not enough

Some people are gonna say: "but it's easy enough to do that with a ref". Yes, for some examples, depending on the API shape you're content with, it is.

But the point is that there are lots of things that we may want to do; and for many of them ref or react are awkward primitives that don't map naturally and are inefficient. If you want to use ref as a proxy to the functions track and trigger, you must do this:

// you need state, even if you could be stateless otherwise
let r = ref(0);
let i = 0;
function track() { 
  ref.value; // let's hope my minifier isn't configured with pure (side-effect free) getters
}
function trigger() {
  ref.value = ++i; // `i` avoids reading r, which would call track!
}

Plus the arguments that would appear in track and trigger when debugging would make no sense in context. Plus the value getter/setter does some extra work we don't need.

And this is just for a single trigger call. I won't suggest code for a multi-value (react) equivalent, it's even worse.

Also note that wrapping a ref means that you are not a ref yourself, and you won't be automatically unwrapped in the component template.

Finally consider this: computed is built out of track and trigger, not a ref. Could it? yes. Is it a good idea? probably not.

Maintenance burden

Public APIs have a cost, as they must be maintained for the lifetime of the framework.

  1. I think track and trigger are pretty stable APIs that have a very low probability of changing. (I can argue why if you think otherwise);

  2. Few people are going to depend directly on them. Those are advanced APIs only. So the impact of a future breaking change is low in terms of impacted user code.

  3. Lots of people could benefit from having those APIs exposed. It's likely that at least some of the examples I gave previously will end up published as npm packages by the community. They could then be used by many projects.

Overall I think it's worth doing. I would be sad if Vue was not extensible at the reactive layer and the primitives that are provided by core were the only "blessed" ones.

Suggestions

I think track and trigger should be exposed as advanced APIs.

isRef is a bit problematic. isRef doesn't really check if it has an instance of ref, but if it as something that implements the ref contract, which can be summed up as: "I am a reactive value wrapper: I have a value property and I'm reactive". Best example: computed is not a ref but pretends to be one (i.e. isRef(computed) === true). This matters because Vue automatically unwraps ref (in the "reactive values" sense) in several places: e.g. inside templates, or when returned from a reactive getter. Also it assigns to value when writing to a ref target.

So it would be nice to have the same behavior on reactive value wrappers other than ref and computed, but we need a public API to define what's a ref in the isRef sense. Today it is { _isRef: true, value: T } but as the underscore shows, it's not exactly a public API.

A similar issue arises with isReactive, though less so. There's less magic attached to reactive and we can prevent the wrapping of our own reactive objects by calling markNonReactive on them. But other reactive primitives don't know this information, hence the ecosystem can't compose nicely (in deeply reactive wrappers).

We probably need some kind of registerReactive api that adds an object to reactiveToRaw. This is then used by isReactive, which is already public.

LinusBorg commented 4 years ago

reactivity is an internal library of Vue 3 that provides state observation. It can be seen as having two layers.

There seems to be a misconception: @vue/reactivity is a publicly available package that exposes all of these features, and you can use it completely standalone, or import it in your Vue app to access stuff like track and effect.

And as Vue's esm-bundler build will have @vue/runtime-dom -> @vue/runtime-core -> /@vue-reactivity as dependencies, so importing from this package yourself will not add any duplicate code when you use this with any modern bundler setup.

jods4 commented 4 years ago

@LinusBorg That is great news! Looking more carefully at the packages I see that module points to *.esm-bundler.js, which -- unlike the other built files -- depends on the other package. So πŸ‘ πŸŽ‰

It's probably even better that way, because those are advanced features that are probably best accessed with import from '@vue/reactivity' than the general public surface of import 'vue'.

So what's left of my long tirade?

Just the discussion about making reactive and ref -- more precisely isReactive and isRef -- more of a concept / contract / interface than a concrete implementation.

I think isReactive should mean "is this a reactive object/array" rather than specifically "is this an instance returned by reactive()" and isRef should mean "is this a reactive value" rather than specifically "is this ref or computed".

This enables us to create our own primitives, that mesh well with the built-in ones and Vue in general.

For isRef, it means making official that the ref interface is { _isRef: true, value: any }. For isReactive, it means either adding a similar mark to reactive objects or creating a registerReactive function (backed by a WeakMap, maybe reactiveToRaw).

jods4 commented 4 years ago

I wish this could move forward. Today I wanted to create a debounced ref. Here's how:

function debouncedRef<T>(delay: number, value?: T) {  
  let timeout = 0;
  return <Ref<T>><any>{
    _isRef: true,
    get value() {
      track(this, TrackOpTypes.GET, 'value');
      return value;
    },
    set value(v) {
      value = v;
      clearTimeout(timeout);
      timeout = setTimeout(<Function>(() => trigger(this, TriggerOpTypes.SET, 'value')), delay);
    },
  };
}

It does exactly what I want, but I have to fight TS (see the ugly <Ref<T>><any> casts) because Vue actively wants to prevent me from doing this, by having a private symbol in its Ref interface.

Having an implementable Ref interface would be nice.

aztalbot commented 4 years ago

@jods4 out of curiosity, does something like this work for your use case? It still only auto-completed value for me when I tested.

type RefImpl<T> = Ref<T> | { _isRef: true; value?: T }

function debouncedRef<T>(delay: number, value?: T): RefImpl<T> {
  let timeout = 0
  return {
    _isRef: true,
    get value() {
      track(this, TrackOpTypes.GET, 'value')
      return value
    },
    set value(v) {
      value = v
      clearTimeout(timeout)
      timeout = setTimeout(
        <Function>(() => trigger(this, TriggerOpTypes.SET, 'value')),
        delay
      )
    }
  }
}

edit: never mind, clearly the problem with this is you can't pass it in as a Ref to a function because RefImpl is a different type and doesn't have the symbol. I'm toying with changing the ref symbol to just export declare const isRefSymbol: unique symbol; but the problem with that is the symbol will get auto-completed which I think the current implementation is trying to avoid. πŸ€”


ok, here is a rough draft of a utility function that might(?) work for your use case:

export function refImpl<T>(customRef: {
  value: T
}): T extends Ref ? T : Ref<typeof customRef.value>
export function refImpl(customRef?: unknown) {
  if (isRef(customRef)) {
    return customRef
  }
  if (
    typeof customRef === 'object' &&
    customRef !== null &&
    'value' in customRef
  ) {
    ;(customRef as any)._isRef = true
  }
  return (customRef as unknown) as Ref<unknown>
}

so then you can do

export function debouncedRef<T>(delay: number, value?: T) {
  let timeout = 0
  return refImpl({
    get value() {
      track(this, TrackOpTypes.GET, 'value')
      return value
    },
    set value(v) {
      value = v
      clearTimeout(timeout)
      timeout = setTimeout(
        <Function>(() => trigger(this, TriggerOpTypes.SET, 'value')),
        delay
      )
    }
  })
}

β€”-

not sure why I am over complicating this. I think your use case really intriguing and it seems like you just need Vue to export a markRef function that takes a value wrapper adds _isRef (Vue should handle because it does look private) and returns with the correct Ref typing. Seems like a small but useful thing to add.

LinusBorg commented 4 years ago

Today I wanted to create a debounced ref.

This could be implemented as a computed pref, pretty much - but I understand that it' just an example for the general usecase you see for a more open API.

However we currently focus all efforts on getting Vue 3 finalized, and I don't think that we currently want to get sidetracked by discussing more "exotic" use cases for the reactivity package right now.

Don't get me wrong I think opening this up a bit might introduce new possibilities, but I personally also think we should let the current API 2) stabilize and 2) be adapted by the ecosystem/community in order to see what kinds of use cases like yours might emerge before we make rushed decisions in the current phase of development.

jods4 commented 4 years ago

@aztalbot

I think your use case really intriguing and it seems like you just need Vue to export a markRef function that takes a value wrapper adds _isRef (Vue should handle because it does look private) and returns with the correct Ref typing. Seems like a small but useful thing to add.

Good thinking. Seems like a reasonable API to me. πŸ‘

@LinusBorg I understand you want to finalize v3 and as a user I'm eagerly looking forward to its release. That said, I feel like getting the fundamental low-layers right is critical. I'd say reactivity is as low-level as it gets.

What I'm asking for is a contract for what is a "reactive" object and what is a "ref". With @aztalbot idea, I don't think we're discussing/missing much here.

function markRef<T>(x: { value: T }): Ref<T> { 
  x._isRef = true;
  return x; 
}

function markReactive<T extends object>(x: T, raw?: object = x): T {
  reactiveToRaw.set(x, raw);
  return x;
}

If you validate this design and need manpower, I can have a go at a PR.

EDIT: I changed the signature of markReactive slightly, to account for the fact that x and raw may have different members, e.g. if x performs any modifications, such as auto-unwrapping refs or otherwise.

jods4 commented 4 years ago

If the API design gets a thumb up, I've done the implementation work, including tests, in this PR: https://github.com/vuejs/vue-next/pull/834

jods4 commented 4 years ago

Today I encountered one more reason why we need markReactive.

I was wrapping a reactive into my own Proxy (its purpose is unrelated to reactivity). Because this object is in fact a reactive under the hood, to prevent further wrapping and issues I marked it as non-reactive. Basically I did this:

let entity = markNonReactive(new Proxy(reactive(raw), handler))

The result won't work (and you're really good if you can tell why at this point).

The culprit is here: https://github.com/vuejs/vue-next/blob/master/packages/reactivity/src/baseHandlers.ts#L101-L102

I don't really understand which weird use case this check is protecting against, even with the comment.

The only case that I can see where receiver is not gonna be linked to the target is when using proxies! In my case that check fails, and the trigger is never notified, losing all reactivity.

So reactive is not compatible with proxies in general, which is expected anyway given how wrap/unwrap is registered.

But in this case I want to create an "extended" reactive and it would definitely work given markReactive:

let entity = markReactive(new Proxy(reactive(raw), handler), raw))`

EDIT: "definitely" is probably not the right word here. We would have 2 reactive proxies linked with a single object (although only one is publicly accessible), which is slightly tricky. It would work because unwrapping for both of them leads to the correct raw object, and when "wrapping" the raw object into a new reactive you get the outer one (defined last).

yyx990803 commented 4 years ago

Doesn't it work with reactive(new Proxy(raw, handler))?

jods4 commented 4 years ago

It most likely does. I didn't go this way because I didn't want to create reactive dependencies on the stuff added by my proxy.

jods4 commented 4 years ago

BTW what scenario does that receiver line cover? The comment mentions the prototype chain but I don't really get what is the unwanted scenario.

You could put a reactive object inside a prototype chain, but that wouldn't be very smart, would it? And if an instance is modified this way, I don't see why it shouldn't notify, after all it is modified.

jods4 commented 4 years ago

The scope has changed a lot between what I initially wrote and all the comments, changes in alphas, and discussions that happened around reactvity since then.

To help finalize reactivity for the beta, I starting anew with the current state in #157 and closing this one.