vuejs / rfcs

RFCs for substantial changes / feature additions to Vue core
4.86k stars 548 forks source link

Add a composition API to explicitly `expose()` public members #210

Closed Jinjiang closed 3 years ago

Jinjiang commented 4 years ago

Rendered

RobbinBaauw commented 4 years ago

Some questions:

TS support I believe @znck is working on making it possible for TS to recognize .vue SFC files as actual TS files (https://github.com/znck/vue-developer-experience?).

This would currently make it possible to get the return type of the setup function to determine what properties a ref of a component has:

export type ComponentType<
    T extends ComponentOptionsBase<any, object, any, any, any, any, any, any>
> = T extends ComponentOptionsBase<any, infer U, any, any, any, any, any, any> ? U : never;

for example.

I don't know if this is something that is deemed useful but I do think it is. I'm not sure how this would work with the expose function, do you have any ideas?

Duplicate calls What happens if you call it twice within setup? Is that ok? If so, which takes precedence when naming conflicts occur?

Proxied Are the results proxied in proxyRefs, similar to setup state (https://github.com/vuejs/vue-next/pull/1682)? I.e. are 1st level refs automatically unreffed?

Component I/O Currently, the input of a component can be declared using props. I wouldn't have a problem with defining the output of a component similarly as well, as described in https://github.com/vuejs/rfcs/pull/135. You could then kind of think of the component object as: props input, setup some transformation function, expose output.

expose as part of the component object would also make it easier to type, you can extract the keys of the returned object from the setup function here and thus create a nice type of the exposed output, solving the first part of my comment.

CyberAP commented 4 years ago

In Vue things should work both in Composition and Options API, so there should be a fallback to Options API (as suggested here: https://github.com/vuejs/rfcs/pull/135)

yyx990803 commented 3 years ago

WIP branch for testing: https://github.com/vuejs/vue-next/commit/c6033fb32718cd0268342f3ea6cd6b8b557acc02

caikan commented 3 years ago

One of the problems introduced by this ʻexpose()API is whether the result returned byreturnwill be exposed and increase uncertainty, which depends on whether ʻexpose() is called in setup(). 此expose()API引入的一个问题是,return返回的结果是否会暴露增加了不确定性,这取决于是否在setup()里调用了expose()

I considered another solution, which can keep the result of return behavior unchanged: Add two APIs context.proxy() and context.render(). When you need to mount a variable in the rendering context, but do not want to expose it as an instance property, use context.proxy() explicitly. When you need to expose instance properties in the return statement and want to use the render function, please use context.render() explicitly. 我考虑了另一种方案,它能让return的行为结果保持不变: 增加两个APIcontext.proxy()context.render()。 当需要在渲染上下文中挂载变量,但不希望作为实例属性暴露时,请显示地使用context.proxy()。 当需要在return语句中暴露实例属性,又想要使用render函数时,请显示地使用context.render()

function setup(props, context) {
  const count = ref(0)
  const inc = () => count.value++
  context.proxy({count}) // or: context.proxy('count', count)
  context.render(() => JSX)
  return {count, inc}
}

Or, you can also consider making three APIs, and at the same time clarify the declaration of instance members, and turn the return statement into an optional convenient way of writing. 或者,还可以考虑做成3个API,同时将实例成员的声明也明确化,将return语句变成一个只是可选的便捷写法。

context.public()
context.private()
context.render()
ycmjason commented 3 years ago

What happens when expose is called multiple times? Do the exposed values get merged?

If multiple "exposes" get merged, I am immediately worried that it will become confusing what are "exposed"; because this means that third party compositions could also expose values?

RobbinBaauw commented 3 years ago

Yep that was also my concern. This means that 3rd party compositions may override your own exposed values & the order of the calls matters: hard to debug.

Besides this, I think my TS support point still stands. I don't see how this will benefit TS type inference for components once this gets merged. Imagine TS support for Vue gets to the point where we can do this:

<template>
    <my-component ref="myComponentRef"/>
</template>

<script lang="ts">
// SetupType<MyComponent> could actually infer the return type of `setup`
import MyComponent from "./MyComponent.vue"

const myComponentRef = ref<SetupType<MyComponent>>();

how would such a thing work with expose?

jods4 commented 3 years ago

What if we use a specific symbol in the returned object to denote a public component API?

import { componentApi } from 'vue';
// componentApi: symbol

function setup() {
  return {
    internalState: 42,

    [componentApi]: {
      publicMethod() { }
    }
  }
}
  1. We don't have to specify how multiple calls behave.
  2. Composables and function calls during setup can't influence your public api.
  3. This might be easier to extract with TS. If TS knows the return type of setup, it's easy to infer the type of [componentApi].
Justineo commented 3 years ago

What about making expose only accessible inside setup (by passing it into the context argument)? In this way third party composables cannot mess up with the public API.

import { ref } from 'vue'
export default {
  setup(_, { expose }) {
    const count = ref(0)

    function increment() {
      count.value++
    }

    expose({
      increment
    })

    return { increment, count }
  }
}
znck commented 3 years ago

We need address how exposed properties or functions be added to instance type of the component.

yyx990803 commented 3 years ago

I agree expose() being a global function that can be called even in external functions can lead to unpredictability.

@jods4 's idea of using a symbol is interesting, but it won't work with <script setup> which has no return statement.

@Justineo 's idea of exposing expose via setup context works in <script setup>, but may not have the same typing benefits. You also need to explicitly declare function expose().

cereschen commented 3 years ago

how about this? https://github.com/cereschen/vue-setup-generator

It's important that the type is supported

yyx990803 commented 3 years ago

@cereschen it's an interesting experiment (reminds me of https://crank.js.org/) but I don't think it is related to this specifically. This RFC is about exposing a different set of properties for template ref usage.

yyx990803 commented 3 years ago

Regarding type inference: I'm thinking maybe the exposed type doesn't have to be somewhat provided via the component type itself - if we can support named type exports from SFCs.

For example:

<!-- Foo.vue -->
<script setup lang="ts">
import { ref, Ref, defineOptions } from 'vue'

const { expose } = defineOptions()

const count = ref(0)

// public API
export interface API {
  count: number
}
expose<API>({ count })
</script>

In another file:

<!-- Bar.vue -->
<script lang="ts">
import { ref, watchEffect } from 'vue'
import Foo, { API as FooAPI } from './Foo.vue'

const foo = ref<FooAPI | undefined>()

watchEffect(() => {
  console.log(foo.value && foo.value.count)
})
</script>

<template>
  <Foo ref="foo"/>
</template>

This also already works for non-SFC components written in TS or TSX.

znck commented 3 years ago

This should work with TS plugin too.

patak-dev commented 3 years ago

Now that export is no longer used in <script setup> to expose bindings to the template, was it discussed to use it for exposing public members of components?

<script setup>
import { ref } from 'vue'

export const count = ref(0)

export function increment() {
  count.value++
}
</script>

The sfc compiler would aggregate all the exported binding using expose inside setup(). With ref sugar, the export can not be in the same line as the declaration, but in that case it is the same as with expose where the variable will need to be repeated:

ref: count = 0
export { count }

If this is an issue, a shortcut could be also provided:

export_ref: count = 0
KaelWD commented 3 years ago

For setup + render functions the ideal implementation would be something like this:

import { defineComponent, ref, render } from 'vue'

export default defineComponent({
  setup (props) {
    const publicState = ref('public')
    const privateState = ref('private')

    return {
      publicState,
      [render]: () => <div>{publicState.value}, {privateState.value}</div>
    }
  }
})

The current expose() implementation in 3.0.7 is kinda broken - it completely replaces the component's proxy, so things like this.$refs.foo.$el don't work. This also breaks vue-test-utils: https://github.com/vuejs/vue-test-utils-next/issues/435
There's also a TODO to "infer public instance type based on exposed keys", which is possible with options.expose but that doesn't work with render functions, and you can't do it with setupContext.expose.

pikax commented 3 years ago

For setup + render functions the ideal implementation would be something like this:

import { defineComponent, ref, render } from 'vue'

export default defineComponent({
  setup (props) {
    const publicState = ref('public')
    const privateState = ref('private')

    return {
      publicState,
      [render]: () => <div>{publicState.value}, {privateState.value}</div>
    }
  }
})
  • The return value of setup can be used to infer the public API instead of explicitly defining an interface
  • render has access to the entire setup state

The expose is not only used on the setup with a render function. This implementation only focus on the render returned by the setup(), personally I don't like this API because you are passing Symbols

The current expose() implementation in 3.0.7 is kinda broken - it completely replaces the component's proxy, so things like this.$refs.foo.$el don't work.

For this case I think you would rely on the setup template ref instead of the vm.$refs:

setup(){
 const foo = ref()

  expose({foo})
  return ()=> h(div, {ref: foo})
}
KaelWD commented 3 years ago

To get the RawBindings type the state has to be returned from setup, and the render function also needs to be declared in setup so it has access to the entire scope. Another alternative is to pass the render function to a callback instead:

// Render function
defineComponent({
  setup(props, { render }) {
    const foo = ref('public')
    const bar = ref('private')

    render(() => <div>{foo.value}, {bar.value}</div>)
    return {
      foo,
    }
  }
})

// Template
<div>{{ foo }}, {{ bar }}</div>

defineComponent({
  expose: ['foo'], // Only foo is available externally
  setup(props) {
    const foo = ref('public')
    const bar = ref('private')

    return {
      foo,
      bar,
    }
  }
})

// External use
setup () {
  const renderComponent = ref()
  const templateComponent = ref()

  onMounted(() => {
    renderComponent.value.foo // 'public'
    renderComponent.value.bar // undefined

    templateComponent.value.foo // 'public'
    templateComponent.value.bar // undefined
  })

  return () => (<>
    <RenderComponent ref={renderComponent} />
    <TemplateComponent ref={templateComponent} />
  </>)
}

For this case I think you would rely on the setup template ref instead of the vm.$refs

It's about the child, if $refs.foo is a component that uses expose then the parent can't access its $el.

pikax commented 3 years ago

The way I see it is: If you explicit expose something you want to prevent something else, because by default everything is accessed via the vm, when you expose especially on options API, it would be fair to think everything else gets "hidden", you should be able to declare everything you want to expose.

Justineo commented 3 years ago

It's about the child, if $refs.foo is a component that uses expose then the parent can't access its $el.

Isn't this part of the motivation of the expose API? On one hand it offers a way to expose API when returning render function in setup, on the other hand it can help stop leaking internal implementations.

pikax commented 3 years ago

There's a drawback of using expose:

I think is expected a vue component to expose those vm.$* internal apis, altho if you are exposing explicitly the vm.$data might not be good to also expose.

pikax commented 3 years ago

Altho expose is intended for composition-api, I think it would be useful to also have it as an option on the options API

the use case:

defineComponent({
  expose: ["process"],

  data(){
    return {
      internalData: 1
    }
  },

  methods: {
    process() {
      this.internalData++;
    }
  }
})

In this case I would expect the public instance only allow access to process method and the $data to be readonly (altho the readonly is arguable)

The typing for it: https://github.com/vuejs/vue-next/pull/3399

CyberAP commented 3 years ago

@pikax it was suggested before the Composition API version: https://github.com/vuejs/rfcs/pull/135

pikax commented 3 years ago

Thank @CyberAP, I saw the implementation on the vue-next and thought it was this RFC, my bad.

I think this two RFC are closely related, because the internal behaviour will be the same, only the declaration API is different.

They can even work together for declaring the typescript typing:

// no expose typing
defineComponent({
  setup(){
    expose({test: 1})
  }
})

// with expose typed
defineComponent({
  expose: undefined as { test: number},

  setup(){
    expose({ test: 1})
  }
})

// equivalent
defineComponent({
  expose: ['test']
  setup(){ 
    return { test: 1 } 
  }
})

// or options
defineComponent({
  expose: ['test'],
  data(){
    return {
      test: 1
    }
  }
})
wenfangdu commented 3 years ago

Can it support using with SFC State-driven CSS Variables (v-bind in