Closed jods4 closed 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.
@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
).
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.
@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.
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.
@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.
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
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).
Doesn't it work with reactive(new Proxy(raw, handler))
?
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.
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.
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.
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
andtrigger
define state (read detection and changes);effect
andstop
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 alsocomputed
, which is a kind ofref
(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 oftrack
andtrigger
.Vue
(nit: why notreactivity
?) provides a higher-level building block for actions:watch
, built on top ofeffect
andstop
.Currently, none of the lower layer
track
,trigger
,effect
orstop
is publicly exposed.watch
is an ok replacement ofeffect
, 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:
Observable
into ref;signal
API (i.e. manual change tracking of large complex changes);markNonReactive
on every value you set;merge
function rather thantoRefs
for composition;react
variant that automatically wraps every array with the previous bullet point instead of the defaultreact
;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
andreact
are not enoughSome 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
orreact
are awkward primitives that don't map naturally and are inefficient. If you want to useref
as a proxy to the functionstrack
andtrigger
, you must do this:Plus the arguments that would appear in
track
andtrigger
when debugging would make no sense in context. Plus thevalue
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 aref
yourself, and you won't be automatically unwrapped in the component template.Finally consider this:
computed
is built out oftrack
andtrigger
, not aref
. 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.
I think
track
andtrigger
are pretty stable APIs that have a very low probability of changing. (I can argue why if you think otherwise);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.
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
andtrigger
should be exposed as advanced APIs.isRef
is a bit problematic.isRef
doesn't really check if it has an instance ofref
, but if it as something that implements theref
contract, which can be summed up as: "I am a reactive value wrapper: I have avalue
property and I'm reactive". Best example:computed
is not aref
but pretends to be one (i.e.isRef(computed) === true
). This matters because Vue automatically unwrapsref
(in the "reactive values" sense) in several places: e.g. inside templates, or when returned from areactive
getter. Also it assigns tovalue
when writing to aref
target.So it would be nice to have the same behavior on reactive value wrappers other than
ref
andcomputed
, but we need a public API to define what's aref
in theisRef
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 toreactive
and we can prevent the wrapping of our own reactive objects by callingmarkNonReactive
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 toreactiveToRaw
. This is then used byisReactive
, which is already public.