vuejs/rfcs

Alternative idea to simplify reactive code

jods4 opened this issue Β· 16 comments

jods4 commented

I don't want to spam #222 with more comments unrelated to the RFC design than I already did.
If some admin wants to move the comments related to auto here, please do.

Goal

Enable users to write JS code that is reactive, while remaining as "plain" as possible.
In other words: use toolchain to remove the need to manipulate refs and write .value.

This is an alternative idea to ref: which was introduced in #222.
As we shall see it is not an alternative to <script setup>, as it is totally orthogonal to that proposal.

Example

Here's what the traditional Todo List would look like using this idea and the export idea from <script setup>:

<script setup>
  import { auto, reactive } from "vue"

  let todos = reactive([])

  // checkbox showing/hiding completed todos
  export let showCompleted = auto.ref(false) 
  // filtered list of todos
  export let shownTodos = auto.computed(() => todos.filter(t => showCompleted || !t.completed))
  // count of todos displayed
  export let count = auto.computed(() => shownTodos.length)

  // textbox for new todo
  export let newTodo = auto.ref("") 
  // button to add said todo
  export function add() {
    if (newTodo == "") return
    todos.push({ name: newTodo, completed: false })
    newTodo = ""
  }
</script>

Observe that outside of variable initializations, which uses auto, it's plain JS code. You can read if (newTodo == "") and write newTodo = "" reactive variables normally, in fact newTodo is typed as plain string in this code.

If you're familiar with Vue 3 reactivity, also observe that it's the same initialization, except we've swapped auto.ref for ref and auto.computed for computed.

API

This introduce a single new function called auto:

function auto<T>(value: Ref<T>): T

It takes a ref and unwraps its value, except the result is still reactive.

Because auto(ref(x)) and auto(computed(() => y)) would be very common, two shortcuts functions are provided that perform just that: auto.ref and auto.computed.

Sometimes you need to access a ref directly, not its value. For example if you want to pass it to a function that expects reactive inputs. For this purpose, ref() is re-purposed and will return the initial value that was passed to auto().

// Example function that takes Ref as input
function useLength(text: Ref<string>) {
  return computed(() => unref(text).length)
}

// How it can be called with auto refs:
let x = auto.ref("abcd")
let length = useLength(ref(x))

// Alternatively, you can keep the ref in another variable if you prefer:
let xRef = ref("abcd")
let x = auto(xRef)
let length = useLength(xRef)

Also, if you write advanced code that manipulates refs in weird ways, you can mix regular refs code with auto refs code to your heart's content, it's just javascript after all.

Implementation

Plain JS variables can't be reactive.
This all works because the code is instrumented by a JS transform that would be provided by a loader, e.g. vue-auto-loader. In fact, this can all be done without core Vue support.

That JS loader will track variables that are initialized with auto calls.

  1. The initializer auto() call is removed. let x = auto.ref(3) becomes let x = ref(3) and x is actually the ref at runtime.
  2. Any read or write from such a variable x is replaced by a read or write to x.value (except pattern 3 below).
  3. Calls to ref(x) where x is a tracker auto-ref are removed and replaced by x.

Those changes are visible when debugging code without source maps. It's pretty trivial transformations and the result is idiomatic Vue code.

Using auto() in any place other than a variable initializer is an error that would be reported by the loader.

Tooling

The original code is valid Javascript, with the correct and expected semantics.

This means that you can edit this in any IDE: Atom, Sublime, VS Code, etc, without any plugin and still get complete language support (completion, refactorings, code navigation, etc).

You can use any language: Javascript, Typescript or even Coffeescript if you like, as the loader operates on the resulting JS code.

This works in any place, including .vue SFC but also regular standalone JS/TS files, which is handy if you write composable functions or template-less components.

why we should track the vars made by auto.ref and not track ref directly?(sorry for my pool englishοΌ‰

jods4 commented

@NickLiu635
No worry, your english is as good as mine!

A ref and its value are not the same thing. Depending on context you want to use one or the other.
Consider this example:

// A reactive string
let name = ref("jods") 

// A function working with plain values
function length(s: string): number {
  return s.length
}

// This is a non-reactive snapshot of the length of the string currently in the ref `name`
length(name.value) == 4 // notice the .value

// A function working with reactive values
function useLength(s: Ref<string>): Computed<number> {
  return computed(() => s.value.length)
}

// This is a reactive computed containing the length of the reactive name variable
useLength(name)  // notice the absence of .value

// Some functions will commonly be coded to accept either one
// so you can't statically analyze what you're supposed to do
// (not to mention the dynamic nature of JS in general)
function someLength(s: string | Ref<string>) {
  return unref(s).length
}

That's why you can't track all refs automatically.
We need a way (syntax or otherwise) to sometimes manipulate refs as reactive boxes, other times as their values.

When you use ref, you use the ref easily and have access to its value with additional syntax .value.
When you use auto.ref you optimize for the reverse case: direct access to the value and additional syntax auto.get for the underlying ref.

Bonus chatter:
Usually in templates you want to read the value, so Vue unrefs automatically in that context but even there it has turned out to be too limiting at times. That's why refs inside arrays are not automatically unref'ed even in templates (so you can reorder the refs).

How would you expose raw ref object? Still via value.$ref? What if the value is null or undefined?

The original code is valid Javascript, with the correct and expected semantics.

I would argue this is NOT "JavaScript with expected semantics". In standard JavaScript, it's impossible for variable assignments to lead to side effects. It's also impossible for variable access to "track" dependencies. So the underlying semantics is different: you can't pretend you are writing "just JavaScript" - you know auto.ref is a compiler hint (more like a macro).

This works in any place, including .vue SFC but also regular standalone JS/TS files, which is handy if you write composable functions or template-less components.

Following the reasoning from above, using this in standalone JS/TS files means you are applying non-standard semantics to your entire codebase. In my opinion, this is more dangerous than ref: - which is explicitly "not standard JavaScript" - because your mental model will be trained to accept variable mutations leading to side effects, and because this is not contextually bound to Vue SFCs anymore, it is much more likely to create mental overhead when you work on codebases that do not have this transform enabled.


My overall impression of this proposal is that while it circumvents the tooling issues, it isn't fundamentally better in terms of being "more JavaScript" on the mental front. In fact I'd argue it would be much harder to explain to beginners.

jods4 commented

How would you expose raw ref object? Still via value.$ref? What if the value is null or undefined?

Two options:
You can either keep the ref aside if you need it:

let $name = ref("jods")
let name = auto($name)

Or I settled for auto.get(value) as a syntax to grab the raw ref. Feel free to bikeshed the names as much as you want.

I excluded my initial value.$ref idea mostly because it won't play nice with the type system. It's propagated on copy (bad), and lost on assignment (also bad).

I would argue this is NOT "JavaScript with expected semantics".

It is in the sense that if I tell you auto.ref(4) returns a number, then all the code that follows is semantically valid with that definition and any JS tool will be perfectly happy with it, including TS type system.

I might as well tell you that the variable will be reactive but that's not a concept that JS defines and for all you know I might use any strategy to actually implement that (maybe I'm gonna create a dirty-checking function that poll your local variables, Angular-style, maybe I'll just insert notify calls, Svelte-style).

The important bit for users is that yes, this requires a loader and there's a bit of magic going on to make auto variable reactive. How it does it exactly is not important for most users. And as far as official JS semantics go, it does what it says.

your mental model will be trained to accept variable mutations leading to side effects, and because this is not contextually bound to Vue SFCs anymore

I think your mental model will be trained from the start that it is contextually bound to auto calls, which are imported from Vue and that it doesn't work if you stray away from that.

I might be wrong but I don't see how anyone could get used to the idea that any JS variable is reactive when there's specifically a constraint to enable this: import and use auto, which is certainly very noticeable compared to a plain assignment let x = 4.

In the end it's just a syntax that people can't invent and will be taught when starting Vue: "use a ref: label" or "use an auto.ref" call... it's pretty much the same education path.

you can't pretend you are writing "just JavaScript" [...]
Following the reasoning from above, using this in standalone JS/TS files means you are applying non-standard semantics to your entire codebase.

If you think about it, that's exactly what Angular did and (partly) led to its success.
People saw "just javascript" on the Todo List front-page example and were thrilled that it worked, even though they had no idea how.

This might as well be a personal call. Just as you said that nobody has to use <script setup> nor ref: in SFC if they want to stick to pure JS; nobody is forced to use this in their whole codebase. If someone thinks JS should be pure, then stick to ref() and .value code. It's perfectly fine.
BTW if this is such a strong concern, I find it a bit hard to justify even in SFC context.

My overall impression of this proposal is that while it circumvents the tooling issues, it isn't fundamentally better in terms of being "more JavaScript" on the mental front.

To be honest, "being more Javascript" is not my main driver here, the fact that it works in JS and tooling actually are.

Svelte is different from Vue and has limitations when you compose complex reactive code from plain JS files.
Vue is better in this regard, but it means that a lot of reactive code will be written outside SFC.
It would be a sad to have a way to efficiently write SFC code but fallback to cumbersome code when you're in JS.
And it's even worse if you have to convert code because you factor it out.

In fact I'd argue it would be much harder to explain to beginners.

This is arguable but I differ here.

You need to explain that it is a label (some comments in PR didn't recognize it), that the assignment is an undeclared global variable but that it won't really be global, and that "ref:" is a magic label name that a tool will use to make that variable reactive.

Here's how I'd explain auto.ref to a newcomer:
auto.ref(4) is a function that returns its value (4) but the assigned variable will automatically be reactive.

You don't need to know more to get started, and if you know JS it's just that: a passthrough function that is automatically reactive.

If we talk about explaining things to beginners, you need to take into considerations how many dislikes the ref: idea has generated. Even if it is a great idea, this will also be the reaction of people who want to diss Vue. The idea can be defended here, but you won't be invited to respond when some bloggers say Vue misuses weird JS syntax and Angular or React is cleaner and should be used instead. That's bad PR, regardless of technical merit.

To be honest, "being more Javascript" is not my main driver here, the fact that it works in JS and tooling actually are.

If that is your main driver... check out https://github.com/johnsoncodehk/volar which already supports the ref: sugar, including proper type inference for ref:, dollar variables, their counterparts in the template, and even cross-script-template refactoring (e.g. renaming the symbol). I just tested it myself. (make sure to set it to support RFC#222 in settings, and have a tsconfig.json file in the project)

I think the fear of "tooling is never gonna be there" can be fully refuted by the fact that @johnsoncodehk got it supported in Volar in a matter of days. Or put it in another way: at this point I think we should just remove "tooling is hard" from the arguments altogether.

jods4 commented

@yyx990803
That is cool and many thanks and congratulations to @johnsoncodehk for his work! πŸ‘ ❀️
I don't have time to check out Volar right now, I'll be sure to do it in the future.

My #1 frustration with the label proposal is the impossibility to use it in non-SFC files, and that remains.

About Volar refs support: a quick glance at the code leads me to believe that it's not as a finished product as you make it sound. You know getting a proof of concept ready is quick, Pareto principle, etc.

Tell me if I'm wrong but just from reading the code I have the following impressions:

  • All unused variables warnings are muted? I'd expect truly unused variables to still be reported, which must include the template analysis.
  • Un-unref refactoring doesn't introduce import { ref } from "vue" neatly. A robust tool should merge that in my existing imports, I might even have it already which would lead to a duplicate warning.
  • Extract method refactoring is not supported?

Tooling is hard, in general.
Are you confident this will work fast when typing in very large files? It won't crash on massively invalid code?

Webstorm IDE has great Typescript, and Vue, support. They'll have to replicate those efforts so that Webstorm Vue users can have access to similar features.

Given the great Vue team, I'm sure there's nothing that time and effort can't solve.

It doesn't mean I wouldn't love if you spent some of that energy on other things.
Hearing that "tooling isn't hard" and "support was added in a matter of days" is frustrating when Vue users have to cope with sub-optimal dev experience on a daily basis.

SFC is not "standard" JS and to this day basic language features involving <script> blocks are not working (I'm not gonna touch <template>, which I acknowledge might be easier to tool in <script setup>).

For example, could you tell me when I'll be able to:

  • Find references in <script> from a TS file?
  • Rename inside <script> (from inside and outside)?
  • Get the right level of indentation when auto-importing?
  • Have access to code actions, such as organizing imports?
  • Have access to all TS refactorings, such as extract function?
jods4 commented

I'll take this opportunity to point out something else about tooling:
Non-standard tech relying on plugins is limiting us in our choices and work practices.

Here we intensively use Visual Studio (not Code). We have done so for years while building full-stack platforms with other frameworks such as Aurelia, Knockout.

Adopting Vue has forced us to change our habits, and work with 2 different IDE at the same time; using VS Code for the front and VS for the back (far superior when it comes to .NET).
There's really no denying that using Vetur makes a huge difference if you write SFC, and we do.
But it's not the best setup and in an ideal world I'd ask you about a Visual Studio plugin.
I know it's not gonna happen and TBH it's not the best part of relying on plugins to develop non-standard code.

@yyx990803 Thank you for sharing it!
@jods4 Thank you for your opinion, your consideration is sufficient! Let me answer your question:

All unused variables warnings are muted? I'd expect truly unused variables to still be reported, which must include the template analysis.

Will implement it. I have implemented the properties that are not used in the template in the report setup() return, and their principles are similar.

Un-unref refactoring doesn't introduce import { ref } from "vue" neatly. A robust tool should merge that in my existing imports, I might even have it already which would lead to a duplicate warning.

I haven't provided this feature yet (you may have seen the code, but it is not available to users), I need move time to consider a lot of the issues like you mentioned, so I will put it in the future.

Extract method refactoring is not supported?

Will be supported in some way, I am thinking whether ref sugar conversion tool is sufficient or not.

Are you confident this will work fast when typing in very large files? It won't crash on massively invalid code?

Yes, this tool is built entirely to solve performance problems. (See: vuejs/language-tools#17 (comment))

Here we intensively use Visual Studio (not Code). We have done so for years while building full-stack platforms with other frameworks such as Aurelia, Knockout.

This tool is based on the Language Server Protocol, which can be provided to support IDEs. But now there are only a few users, and I want to temporarily avoid fragmentation.

@jods4 I can understand some of the points raised but it seems to be shifting the point from "tooling specific to the ref: proposal is hard" to generic "tooling for SFC is hard".

My point is, many of the pet peeves you raised applies to SFCs in general and isn't particularly ref: related. I do acknowledge that SFC tooling in general is hard. But specifically to what ref: sugar adds on top of that, I think Volar has already tackled the most important ones.

That said, I think magic-function-based ref sugar does have its benefits. I'm just concerned about using it outside of SFCs - essentially leaking non-standard semantics into all code. I wouldn't mind it being implemented in userland as a babel plugin though, since it doesn't rely on specific tooling.

jods4 commented

I wouldn't mind it being implemented in userland as a babel plugin though, since it doesn't rely on specific tooling.

Yes, I thought the same!
A proposal that doesn't require IDE support such as this one can be made independently, e.g. as a webpack loader.

There's just the side-effect of community fragmentation, standardization when onboarding new team members, showing code on github, or copy-pasting from/to stackoverflow questions, etc.

But in a team that really want sugar in JS file and doesn't mind using alternative tools, it's 100% doable.

Since it's a magical function underlying to simplify unwrap ref, I'd suggest using $ref, to make the intention clearer.
Because the name starts from $ is considered preserved in Vue, it's already kind of magical to developers.
So instead of auto, the name $ref is neat and clear enough.

jods4 commented

It occured to me that the unwrap operation could just re-use the well-known ref(x) verb so I changed the initial issue description in that sense.

I like that idea because:

  • ref returns a ref from a value, it's exactly the meaning we want.
  • Similarly, the TS types are already correct.
  • Conceptually, ref is idempotent, ref(ref(0)) returns the inner one.
  • It's a good name, so I figured there's no need to introduce more magic functions.

@dsonet I don't think the naming I proposed is particularly good, so if Vue teams takes this further I would be happy with any name, $ref or otherwise. πŸ˜ƒ

some updates

  • All unused variables warnings are muted? I'd expect truly unused variables to still be reported, which must include the template analysis.

Considering that this feature has little benefit for previewing ref sugar, I will work for this feature after the RFC is passed.

  • Un-unref refactoring doesn't introduce import { ref } from "vue" neatly. A robust tool should merge that in my existing imports, I might even have it already which would lead to a duplicate warning.

Already released to 0.15.6!

I have a proposal that is somewhere in between #228 and this one.

Returning two values should make it easier to understand conceptually, even for beginners.

jods4 commented

If anyone wants to play with this, I built a working prototype that you can use with Vite.
Look here: https://github.com/jods4/vite-auto-ref

It should work in <script setup>, plain old SFC <script> or even any JS/TS file.

If you try it and have some feedback about the idea, here's a good place to post.

If the Vite plugin has bugs, open an issue in the linked repo and I'll see if I can fix it.

jods4 commented

The new official proposal #369 is based on the same idea, so I think this issue serves no purpose anymore and can be closed.
Names are different but it was never about naming and I happen to like $ref better than auto.ref πŸ˜ƒ