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

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

Closed
wants to merge 1 commit into from
Closed

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

wants to merge 1 commit into from

Conversation

Jinjiang
Copy link
Member

@Jinjiang Jinjiang commented Sep 6, 2020

@RobbinBaauw
Copy link

RobbinBaauw commented Sep 6, 2020

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 (vuejs/core#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 #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
Copy link
Contributor

CyberAP commented Sep 6, 2020

In Vue things should work both in Composition and Options API, so there should be a fallback to Options API (as suggested here: #135)

@yyx990803
Copy link
Member

WIP branch for testing: vuejs/core@c6033fb

@caikan
Copy link

caikan commented Oct 21, 2020

One of the problems introduced by this ʻexpose()API is whether the result returned byreturn will 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
Copy link

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
Copy link

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
Copy link

jods4 commented Oct 21, 2020

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
Copy link
Member

Justineo commented Oct 22, 2020

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
Copy link
Member

znck commented Oct 23, 2020

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

@yyx990803
Copy link
Member

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

This comment has been minimized.

@yyx990803

This comment has been minimized.

@yyx990803
Copy link
Member

yyx990803 commented Nov 13, 2020

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
Copy link
Member

znck commented Nov 16, 2020

This should work with TS plugin too.

@patak-dev
Copy link
Member

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
Copy link

KaelWD commented Mar 4, 2021

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 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: vuejs/test-utils#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
Copy link
Member

pikax commented Mar 4, 2021

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
Copy link

KaelWD commented Mar 5, 2021

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
Copy link
Member

pikax commented Mar 5, 2021

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
Copy link
Member

Justineo commented Mar 5, 2021

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
Copy link
Member

pikax commented Mar 5, 2021

There's a drawback of using expose:

  • When you use expose regardless of the API you are using, it will hide the vm.$*, which breaks a lot of more advanced usages, such as vue-test-utils because it relies on vm.$el.

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
Copy link
Member

pikax commented Mar 23, 2021

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: vuejs/core#3399

@CyberAP
Copy link
Contributor

CyberAP commented Mar 23, 2021

@pikax it was suggested before the Composition API version: #135

@pikax
Copy link
Member

pikax commented Mar 23, 2021

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
Copy link
Contributor

wenfangdu commented May 28, 2021

Can it support using with SFC State-driven CSS Variables (v-bind in <style>)? e.g.

<script lang="tsx">
  import { defineComponent } from 'vue'

  export default defineComponent({
    setup(props, { expose }) {
      expose({
        color: 'red',
        font: {
          size: '2em',
        },
      })

      return () => <div class='text'>Hello</div>
    },
  })
</script>

<style>
  body {
    background: #000;
    text-align: center;
  }

  .text {
    color: v-bind(color);

    /* expressions (wrap in quotes) */
    font-size: v-bind('font.size');
  }
</style>

Currently, the above code won't work.

@jods4
Copy link

jods4 commented Jul 1, 2021

This will be a great addition in 3.1.3!!

There's one semi-related thing that will need some work: consuming TS types.

Let's assume that the type of your component API is manually or automatically exported from the SFC.
E.g. maybe in a script setup there's an export interface Api {...} that's automatically added.

Consuming this is non-trivial:

  • Inside the VS Code, vuedx can provide this precise information.
  • When building the project with TS there's currently no way to have this generated into the compilation and it fails. Here I rely on a traditional module "*.vue" but that doesn't cover exports other than the default one.

I suppose we would need some kind of TS plugin that can be plugged into the compilation.
Sadly MS doesn't seem to want to open up plugin for compilation yet, although most TS loaders give you a backdoor for that, and there exists tsc wrappers.

Related: vuedx/languagetools#228

@yyx990803
Copy link
Member

Closing in favor of #343

@yyx990803 yyx990803 closed this Jul 5, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.