前两天在油管上看了 VueConf 2024
的回放,其中关注到一个有意思的点就是 Vue
可以在单文件中写多个组件,感觉挺有意思,下面让我们开启新的探索之旅吧!
系好安全带,准备发车啦!
嘀 ~
Vue SFC
我们先来介绍下什么是 Vue SFC
Vue SFC
全称 Vue Single File Component
,翻译过来就是 Vue 单文件组件,说白了就是可以把一个 Vue
组件的模板、逻辑和样式写在一个文件中。
下面是一个单文件组件的示例:
<script setup>
import { ref } from 'vue'
const greeting = ref('Hello World!')
</script>
<template>
<p class="greeting">{{ greeting }}</p>
</template>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
通过以上代码可以看到单文件组件是由 <template>
、<script>
和 <style>
三个块构成的,这三个块在同一个文件中封装、组合了组件的视图、逻辑和样式。
对于组件来讲,其模板、逻辑和样式本身就是有内在联系的、是耦合的,将它们放在一起,实际上使组件更有内聚性和可维护性。
基于 SFC 的单文件多组件
那么也有不少 Vue
用户表示能不能在一个文件中写多个组件呢?
在此之前,社区中已经有类似的解决方案了,我们分别来介绍下:
Vue Use 的 createReusableTemplate 函数
我们在编写组件模板时对于需要重用的部分是很常见的,比如下面这个对话框例子:
<template>
<dialog v-if="showInDialog">
<!-- 重用的部分 -->
</dialog>
<div v-else>
<!-- 重用的部分 -->
</div>
</template>
通常的做法是将需要重用的部分提取成一个新的组件,但这样做将会失去访问本地组件的能力,同时还会增加额外的 props
和 emits
等。
这里我们使用 Vue Use 库的 createReusableTemplate 函数,该函数可以在组件范围内定义和重用模板。下面是示例:
<script setup>
import { createReusableTemplate } from '@vueuse/core'
const [DefineTemplate, ReuseTemplate] = createReusableTemplate()
</script>
<template>
<DefineTemplate>
<!-- 重用的部分 -->
</DefineTemplate>
<dialog v-if="showInDialog">
<ReuseTemplate />
</dialog>
<div v-else>
<ReuseTemplate />
</div>
</template>
createReusableTemplate
函数的返回结果是一个长度为 2,元素类型为 Vue 组件
的数组,其中第一个元素为 <DefineTemplate>
组件,第二个元素为 <ReuseTemplate>
组件。
<DefineTemplate>
将注册模板并且不渲染任何内容。
<ReuseTemplate>
将呈现 <DefineTemplate>
提供的模板。
实现原理:
下面是 createReusableTemplate
函数的具体实现:
// 1. 使用 Vue 的 shallowRef 保存需要重复使用的插槽函数
const render = shallowRef<Slot | undefined>()
// 2. 使用 Vue 的 defineComponent 定义 DefineTemplate 组件
// 此组件的 render 函数将返回 undefined 表示不渲染任何内容
// 同时把 DefineTemplate 组件的默认插槽函数保存在 render ref 变量中
const define = defineComponent({
setup(_, { slots }) {
return () => { // render 函数
// 赋值默认插槽函数
render.value = slots.default
}
},
}) as unknown as DefineTemplateComponent<Bindings, MapSlotNameToSlotProps>
// 3. 使用 Vue 的 defineComponent 定义 ReuseTemplate 组件
// 此组件的 render 函数内将调用 render ref 变量保存的插槽函数,并把返回的 vnode 作为该组件的 vnode
const reuse = defineComponent({
inheritAttrs,
setup(_, { attrs, slots }) {
return () => { // render 函数
if (!render.value && process.env.NODE_ENV !== 'production')
throw new Error('[VueUse] Failed to find the definition of reusable template')
// 3.1 执行 render ref 变量保存的插槽函数
const vnode = render.value?.({ ...keysToCamelKebabCase(attrs), $slots: slots })
// 3.2 把上面的 vnode 直接作为返回结果
return (inheritAttrs && vnode?.length === 1) ? vnode[0] : vnode
}
},
}) as unknown as ReuseTemplateComponent<Bindings, MapSlotNameToSlotProps>
通过上面的代码我们发现 <DefineTemplate>
必须在 <ReuseTemplate>
之前使用,这是因为 <ReuseTemplate>
组件的 vnode
直接使用了 <DefineTemplate>
组件的默认插槽的 vnode
,所以必须要保证先定义再使用的顺序。
vite-plugin-vue-nested-sfc
vite-plugin-vue-nested-sfc 是 Vite
的一个插件,它的作用就是在 SFC
中嵌套使用 SFC
。
安装
注意:当前使用的 vite-plugin-vue-nested-sfc
包版本为 v0.1.3
下面是 vite-plugin-vue-nested-sfc
包的 package.json 文件,其中 peerDependencies
字段要求 Vite 的版本是在 >=4.0.0 <5.0.0-0
这个范围,所以这里需要注意 Vite 的版本,否则在安装 vite-plugin-vue-nested-sfc
包时会报错的。
^4.0.0
的 ^
符号表示插入符范围(Caret Ranges),具体解释可以参考 semver 这个库。
{
"name": "vite-plugin-vue-nested-sfc",
"peerDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.0",
"vue": "^3.2.25"
},
}
那我们直接使用 create-vite
包的 v4.5.3
版本
# 当前 create-vite 在 4 的最新版本为 v4.5.3
# https://github.com/vitejs/vite/tree/v4.5.3/packages/create-vite#create-vite
npm create vite@4 my-vue-app --template vue-ts
下面是 create-vite
包在版本是 v4.5.3
,模板是 vue-ts
情况下的 package.json 文件,可以看到这里使用的 Vite 版本是符合 vite-plugin-vue-nested-sfc
包要求的
{
"name": "vite-vue-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"typescript": "^5.0.2",
"vite": "^4.4.8",
"vue-tsc": "^1.8.8"
}
}
接下来安装 vite-plugin-vue-nested-sfc
包,因为当前最新版本为 v0.1.3
,所以这里就不加后缀 @0.1.3
了
npm install -D vite-plugin-vue-nested-sfc
接着在 vite.config.js
中添加此插件
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueNestedSFC from "vite-plugin-vue-nested-sfc"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), vueNestedSFC()],
})
使用
所谓的嵌套 SFC
就是把一个 SFC
放在另一个 SFC
的 <component>
块中。
下面是示例:
在 Header.vue
的 <component>
块中又定义了一个 Logo.vue
<!-- Header.vue -->
<script setup lang="ts"></script>
<template>
<Logo />
</template>
<style scoped></style>
<!-- 使用 component 标签 -->
<component name="Logo" lang="vue">
<script setup lang="ts"></script>
<template>
<h1>Logo</h1>
</template>
<style scoped></style>
</component>
我们可以使用 export
属性表示导出这个嵌套组件。
<!-- Button.vue -->
<template>
<button>
<slot />
</button>
</template>
<!-- 使用 component 块进行定义,同时使用 export 属性表示导出此组件 -->
<component name="RoundedButton" lang="vue" export>
<template>
<button>
<slot />
</button>
</template>
<style scoped>
button {
border-radius: 20px;
}
</style>
</component>
然后在其他文件中将它们导入为命名导出
<!-- App.vue -->
<script setup lang="ts">
import Header from './components/Header.vue'
// 命名导入对应上面 component 标签的 name 属性
import Button, { RoundedButton } from './components/Button.vue'
</script>
<template>
<Header />
<Button> Button </Button>
<br />
<br />
<RoundedButton> RoundedButton </RoundedButton>
</template>
<style scoped></style>
实现原理:
Vite
的插件行为是与 Rollup
相同的方式调用各类钩子的,这里我们简单介绍以下三个钩子:
Hook | Kind |
---|---|
resolveId | async、first |
load | async、first |
transform | async、sequential |
first 表示按照插件的顺序执行,执行期间一旦返回结果,那么该结果将作为此类钩子的最终执行结果,后续的钩子将不会再执行。
sequential 表示按照插件的顺序依次执行此类钩子,每次执行的结果将作为下一个钩子执行前需要处理的参数,在所有钩子执行完之后的结果将作为此类钩子的最终执行结果。
我们先来看下 vite.config.ts
是如何配置的,这里 vue
插件写在 vueNestedSFC
插件之前
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueNestedSFC from "vite-plugin-vue-nested-sfc"
// https://vitejs.dev/config/
export default defineConfig({
// 1. 这里 vue 插件写在 vueNestedSFC 插件之前
plugins: [vue(), vueNestedSFC()],
})
首先 .vue
文件通过@vitejs/plugin-vue
插件的 transform
函数进行处理会被转换为如下大致结构(这里以 Button.vue
为例):
request -> /src/components/Button.vue
// ...
// 1. 当前 sfc 文件转换后的默认导出对象
const _sfc_main = {}
// ...
// 2. 当前 sfc 文件的 template 标签转换后的 render 函数
function _sfc_render(_ctx, _cache) {
// ...
}
// ...
// 3. Button.vue 中 component 标签转换后的结果
// 3.1 生成一个包含对应信息的 id
import block0 from "/src/components/Button.vue?vue&type=component&index=0&name=RoundedButton&export=true&lang.vue"
// 3.2 这个 id 的默认导出结果是一个函数,那么会把这里的 组件导出对象 作为其参数传入并执行此函数
if (typeof block0 === 'function') block0(_sfc_main)
// ...
// 4. vite-plugin-vue-nested-sfc 插件的 transform 函数中执行的 genExportsCode 函数会对嵌套的 sfc 组件形成一个嵌套的 id
// id 的格式为 文件名 + 嵌套组件名,这里也就是 /src/components/Button.vue/RoundedButton.vue
// 同时导出命名为 RoundedButton
// 这里 vite-plugin-vue-nested-sfc 插件会维护一个名为 nestedComponentNames 的 map
// 以 文件名 为 key,嵌套组件名 为 value
// 后续通过判断是否为嵌套组件来进行对应的处理逻辑
export { default as RoundedButton } from "/src/components/Button.vue/RoundedButton.vue";
@vitejs/plugin-vue
插件的 transform
函数对 query
中 type
是 component
的 id
是不进行处理的,真正处理的是 vite-plugin-vue-nested-sfc
插件的 transform
函数中执行的 genComponentBlockCode
函数,处理结果如下:
request -> /src/components/Button.vue?vue&type=component&index=0&name=RoundedButton&export=true&lang.vue
import RoundedButton from "/src/components/Button.vue/RoundedButton.vue";
// 1. 默认导出一个函数,接收参数为对应的组件对象(Button组件对象)
export default function(Comp) {
if (!Comp.components) {
Comp.components = {};
}
// 2. 对 sfc 导出对象添加 components 属性对象,以 RoundedButton 为 key ,RoundedButton
// 组件对象为 value
// 这样我们在嵌套组件所在的组件中(也就是 Button 组件)可以不需要显示导入,直接在 template 中使用 RoundedButton 组件即可
Comp.components["RoundedButton"] = RoundedButton;
}
之后 vite-plugin-vue-nested-sfc
插件的 load
函数会对 /src/components/Button.vue/RoundedButton.vue
这个 id
进行解析,从中提取文件名(/src/components/Button.vue
)和组件名(RoundedButton
),然后把对应 component
块中的内容返回,之后把该内容又交给 @vitejs/plugin-vue
插件的 transform
函数进行转换,这里就又回到了上面转换 /src/components/Button.vue
时的逻辑,形成一个闭环。
request -> /src/components/Button.vue/RoundedButton.vue
// ...
const _sfc_main = {}
// ...
function _sfc_render(_ctx, _cache) {
// ...
}
// ...
以上两个插件中对 vue sfc
的解析工具使用的都是 vue/compiler-sfc
包。
总结
SFC
的概念为一个文件一个组件。
createReusableTemplate
函数可以对组件内的模板进行复用,仅仅是模板,并不是组件,而且它还是在 SFC
中使用的(建立在 SFC
之上)。
vite-plugin-vue-nested-sfc
插件可以对组件进行复用,已经做到最大化满足我们的需求,但它是在 SFC
之上进行扩展的。
JSX 与模板
提到 JSX
你就会想到 React
,正是因为 JSX
本质是一个 对象表达式
,所以你可以非常轻松愉快的编写一个组件,但就是这种灵活性往往需要强大的背后做支持。
而Vue
对 JSX
也有着非常不错的支持,为什么不在此基础上进行扩展呢?
我们知道 JSX
本质是一个 对象表达式
,所以它非常的灵活,这就导致无法在编译时进行优化,而 Vue
内部对 模板
在编译期间是做了大量优化的,比方说在 Vue3
中可以做到基于 VNode
之上的 靶向更新 ,这当然离不开编译期间对 模板
的静态分析,这是 JSX
方式所不能有的。
Vue Vine
我们知道,在 JavaScript
中,函数会创建一个独立上下文,我们不妨使用函数的风格来描述 Vue
组件,
同时为了更好的符合 Vue
的设计理念,使用 模板
描述视图,而 script setup
语句则可以放在函数体与函数返回值之间。
所以 Vue Vine
诞生了。
Vue Vine 以一种新方式(使用函数定义
组件)写 Vue,能够更灵活地编写和组织 Vue 组件(现在可以在一个文件中写多个组件,同时可以使用所有 Vue 的模板
特性)。
Vue Vine 在保留 Vue 在模板
这块底层优化的同时,又做到了一个文件中写多个组件的灵活性。
快速开始
在使用之前,应该了解以下注意事项:
- Vine 只支持
Vue 3.0+
和Vite
。 - Vine 仅支持 TypeScript,JavaScript 用户无法使用完整功能。
现在让我们开启一场使用新方式写 Vue 的探索旅程吧!
create-vue-vine
先来介绍下使用官方的 CLI 创建 Vue Vine 项目
npm create vue-vine my-vine-project
然后按照相应的提示进行操作,将会看到以下输出:
> npm create vue-vine my-vine-project
...
┌ Vue Vine - Another style of writing Vue components
│
◇ Use Vue Router?
│ Yes
│
◇ Project created at: /path/to/my-vine-project
│
◇ Install dependencies?
│ Yes
│
...
◇ Dependencies installed!
│
└ You're all set! Now run:
cd my-vine-project
npm dev
Happy hacking!
之后我们进入该项目后直接 npm run dev
cd my-vine-project
npm run dev
基于现有的 Vite 项目
由于 Vine
完全与 Vue
的单文件组件兼容,所以我们可以在现有的项目中直接使用它。
我们先来创建一个 vite + vue + ts
的项目
# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template vue-ts
然后按照提示操作即可!
cd my-vue-app
# 安装依赖
npm install
# 安装 vue-vine 包
npm install vue-vine -D
在 vite.config.ts
中添加 Vine 插件
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VineVitePlugin } from 'vue-vine/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
// 添加 vine plugin
VineVitePlugin()
],
})
安装 vscode 插件,直接搜索 vue vine
在 tsconfig.app.json
中添加 vine macros
类型声明文件,以对在使用 vine
宏时获得智能提示
// tsconfig.app.json
{
"compilerOptions": {
// ...
"types": [
"vue-vine/macros"
],
// ...
}
}
修改 App.vue
文件,引入并使用 Counter
组件
// App.vue
<script setup lang="ts">
// 引入
import { Counter } from './counter.vine'
</script>
<template>
<Counter :step="3" />
</template>
<style scoped>
</style>
添加 counter.vine.ts
文件
// counter.vine.ts
import { ref } from 'vue'
export function Counter(props: {
step: number
}) {
const count = ref(0)
vineStyle.scoped(`
h2 {
color: hotpink;
}
button {
outline: none;
}
`)
return vine`
<div class="counter">
<h2>count: {{ count }}</h2>
<button @click="count += step"> + {{ step }} </button>
<br />
<br />
<button @click="count -= step"> - {{ step }} </button>
</div>
`
}
之后运行项目
npm run dev
页面出现熟悉的计数器表示成功啦 ~
Vine 规范
文件扩展名
细心的同学会发现上面的 counter.vine.ts
文件的后缀名为 .vine.ts
。
Vine
使用 .vine.ts
作为文件扩展名,表示该文件为 Vue Vine
文件,其实就是一个 ts
文件,编写 ts
代码。
Vine 组件函数
Vine
组件函数,全称 Vine Component Function
,以下均简称 VCF
,它是一个返回 vine
标记模板字符串的函数。
这也就是说,任何函数在其返回值上显式使用 vine
标记模板字符串,那么编译器都会将其识别为 VCF
。
以下函数均为 VCF
function MyComponent() {
return vine`<div>Hello World</div>`
}
const AnotherComponent = () => vine`<div>Hello World</div>`
虽然在 ts
中 VCF
在语法上是一个合法的函数,但是不要在任意处调用 VCF
函数,因为这会导致错误。
<script setup lang="ts">
import { Counter } from './counter.vine'
// 我们声明的 Counter 函数在经过 Vine 编译器处理之后会被转换为 Vue 组件对象
// 所以不要这样做!
Counter()
</script>
<template>
<Counter :step="3" />
</template>
<style scoped>
</style>
在后面的 Vine 原理 章节中你会看到上面导入的 Counter
实际上是一个 Vue
组件对象。
从上面的例子能够证实 VCF
没有任何实际的运行时意义,它仅仅是用来在编译期声明
组件的。
下面我们将一一介绍 Vine
组件函数各个部分的本质
先从函数返回值开始
函数返回值 => template
VCF
的返回值 标记模板字符串
就是 SFC
中的 template
块,Vine
编译器会把其传递给 @vue/compiler-dom
进行编译,最终会被转换为对应的 render
函数。
标签模板字符串
我们在这里简单回顾下什么是 标签模板字符串 ?
它是属于 模板字符串 中的功能,模板字符串 可以紧跟在一个函数名的后面,该函数将被调用来处理这个模板字符串。这被称为“标签模板”功能(tagged template)。
标签模板其实不是模板,而是函数调用的一种特殊形式。“标签”指的就是函数,紧跟在后面的模板字符串就是它的参数。
vine`<div></div>`
// 等同于
vine(['<div></div>'])
所以这个 vine
标签其实是一个函数,它的类型在 vue-vine/types/macros.d.ts
中
// packages/vue-vine/types/macros.d.ts
declare const __VUE_VINE__: unique symbol
// ...
declare global {
interface VueVineComponent { [__VUE_VINE__]: true }
// ...
// vine 函数类型 接收模板字符串数组 函数返回值为 VueVineComponent
const vine: (template: TemplateStringsArray) => VueVineComponent
}
但是需要注意的是,虽然在上面的 macros.d.ts
文件中有它的类型,但是在 Vue Vine
包中,是没有 vine
函数的,为什么?你可能会想如果没有,那岂不是运行期间会报错?
原因很简单,因为 vine
标签模板字符串仅仅是用来声明
组件模板的,只在编译期间时使用,vine
函数没有任何实际的运行时意义,所以也就不需要具体的函数。
注意事项
Vine
组件函数内不允许使用多个 vine
标记模板
function MyComponent() {
if (Math.random() > 0.5) return vine`<p></p>` // 这将报错
return vine`<div></div>`
}
Vine
模板字符串不允许包含插值
function MyComponent() {
const name = 'World'
return vine`<div>Hello ${name}</div>` // 这将报错
}
Vue
的模板上可能在 v-bind
或是双花括号之间中存在 JS
表达式,因而也有可能出现带插值表达式的模板字符串,但在 Vine
中,这是不被允许的。
function MyComponent() {
const userName = ref('Vine')
// IDE 中无法正常显示模板部分的高亮
// 这是因为使用 @vue/compiler-dom 进行编译时会报以下错误:
// Error parsing JavaScript expression: Expecting Unicode escape sequence \uXXXX. (1:2)
return vine`
<a :href="\`\${userName}\`">
Profile
</a>
`
}
模板编译失败时客户端无法正常显示错误
在使用 Vine
的过程中,发现 模板编译失败时客户端无法正常显示错误 这个问题,随即向 Vine
官方 提个 issue
。
当前 Vine
版本:v0.1.15
bug(compiler): The client cannot properly display errors when the template fails to compile
函数体 => script setup
VCF
除返回语句外的函数体部分被视为 SFC
中的 <script setup>
,可以在其中定义组件的逻辑。
以下是一个示例,单行注释中间的部分会被编译到 Vue
组件的 setup
函数中,处理的方式就像 Vue SFC
中的 <script setup>
一样。
function Counter() {
// 这部分视为 `Vue SFC` 中的 `<script setup>`
const count = ref(0)
const handleClick = () => {
count.value += 1
}
// ---
return vine`
<div>
<button @click="handleClick"> + 1 </button>
<div>{{ count }}</div>
</div>
`
}
函数参数 => props
最开始在 Vue 中定义 props 类型的方式是使用诸如
String
、Number
、Boolean
等类型构造函数,这是用于 Vue 的运行时类型检查的。但从 Vue 3 开始,用户逐渐期望类型检查过程由 TypeScript 和 IDE 来完成,因此 Vine 决定放弃对 props 的
type
字段的支持,因为 Vine 认为当我们已经全面使用 TypeScript 时,它并不是非常有用。Vine 会在生成组件对象的
props
字段时会删除所有的类型信息。在 VCF 中定义
props
有两种方式,第一种是为函数的第一个形参提供 TypeScript 类型注解,另一种是使用vineProp
宏。如果没有为 VCF 提供形参,并且在 VCF 内部也没有
vineProp
宏的调用,组件的props
字段将为空对象{}
。
Vine 组件函数的 props
只能使用形式参数或 vineProp
宏调用来定义,不能同时使用两者 。
下面我们先来介绍使用类型注解声明的方式
用类型注解声明
给
VCF
设置形式参数props
,并且是第一个参数,并为其编写一个TypeScript
对象字面量形式的类型注解,其中包含您想要定义的所有props
。Vine 决定不再支持属性的
type
字段,因为 Vine 认为当我们已经使用 TypeScript 时,它并不是非常有用。在这种定义方式下,Vine 默认将所有 prop 视为必需的,您可以使用
?
标记其为可选 prop。
import { SomeExternalType } from './path/to/somewhere'
function MyComponent(props: { // 必须是第一个参数,其类型必须是对象字面量形式的类型注解
foo: SomeExternalType // 必需属性
bar?: number // 可选属性
baz: boolean // 必需属性
}) {
// ...
}
Vine 组件函数只能有一个参数,并且其类型注解必须是对象字面量,这个对象字面量只包含属性签名,且所有属性的 key
必须是字符串(如 { "foo": string }
中的 "foo"
这个 key)或者标识符(如 { bar: boolean }
中的 bar
这个 key)。
// 正确写法
function Counter(props: { // 为第一个参数,其类型必须是对象字面量形式的类型注解
step: number, // 这里的 step 就是标识符形式的 key
"xxx": number // 这里的 "xxx" 就是字符串形式的 key
}) {
// ...
}
// 以下均是错误写法
type CounterProps = {
step: number
}
function Counter(props: CounterProps) { // 报错,其类型不是对象字面量形式
// ...
}
interface CounterProps {
step: number
}
function Counter(props: CounterProps) { // 报错,其类型不是对象字面量形式
// ...
}
以我们的 Counter
组件为例子,看下最终编译后的 options
对象的 props
属性,正如上面引用中所说的那样,最终删除了所有的类型信息,也不会生成 prop
对应的 type
属性。
function Counter(props: {
step: number,
title?: string // 可选属性
}) {
// ...
}
// 下面是最终的编译结果
const Counter = (() => {
// ...
const __vine = _defineComponent({
name: "Counter",
props: {
step: { required: true }, // 加上 required 表示必需
title: { // 直接是一个空对象
/* Simple prop */
}
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const count = ref(0);
return { count, ref, Counter };
}
});
// ...
})()
布尔型转换机制
在编译时,我们必须知道一个属性是否为布尔型,以确定如何处理这样的属性传递:
<MyComponent foo />
。在 Web 标准 HTML 中,属性foo
的值实际上是一个空字符串。因此,你必须使用
字面量 boolean
注解来指定任何布尔型属性,不允许在这里使用其他在别处定义的类型,即使它最终的结果是布尔类型。
在表示布尔类型时一定要使用 字面量 boolean
注解。
type OtherTypeButActuallyBoolean = boolean
function MyComponent(props: {
// ...
isFrontEnd: boolean, // 使用字面量 boolean 表示布尔类型
isBackEnd: OtherTypeButActuallyBoolean
// 错误写法
// 虽然 OtherTypeButActuallyBoolean 是 boolean 类型的
// 但是这里并不是字面量 boolean 表示的布尔类型
// 所以不允许这样做
}) { ... }
vineProp
第二种方式是使用 vineProp 宏函数,下面是其类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
type VinePropValidator<T> = (value: T) => boolean
interface VinePropMacro {
<T>(validator?: VinePropValidator<T>): Readonly<Ref<T>>
optional: <T>(validator?: VinePropValidator<T>) => Readonly<Ref<T>>
withDefault: <T>(value: T, validator?: VinePropValidator<T>) => Readonly<Ref<T>>
}
const vineProp: VinePropMacro
vineProp 是一个用于定义组件 prop 的编译宏函数。
它和 Vue 官方对 props 在 <script setup>
中使用的编译时宏 defineProps 是不一样的。
使用
vineProp
宏逐个定义prop
,这种方式的优势在于可以很方便地将每个prop
的值作为一个Ref
使用,不用再手动将props
进行toRefs
处理。
这里还是以我们的 Counter
组件为例子,发现使用 vineProp
宏函数的方式经过 Vine 编译器的处理后会在 setup 函数内添加 toRefs
函数的代码。所以正如上面引用中所说不用再手动将 props 进行 toRefs
处理,因为内部会自动添加这行代码的。
function Counter() {
// ...
const step = vineProp<number>()
// ...
}
// 下面是最终的编译结果
const Counter = (() => {
// ...
const __vine = _defineComponent({
name: "Counter",
props: {
step: { required: true }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
// 使用 Vue 中的 toRefs 函数对 props 处理
// 这样 step 就是一个 ref 了
const { step } = _toRefs(props);
const count = ref(0);
return { step, count, ref, Counter };
}
});
// ...
})()
Vine 宏调用必须在 Vue Vine 组件函数内部!
vineProp
宏调用必须位于 const
变量声明内。
vineProp
宏调用的变量声明符必须是标识符,不允许使用解构模式。
vineProp
的第一个参数是 prop
的 validator
验证器属性,它是可选的。
// 位于 const 变量声明内
const foo = vineProp<string>() // <string> 就是类型参数
let foo = vineProp<string>() // 报错,不是 const 声明
const { ... } = vineProp<string>() // 报错,不能使用解构模式
// 使用属性的 validator
const title = vineProp<string>(value => value.startsWith('#') /* 这个验证器参数是可选的 */)
如果需要定义一个可选的 prop
,可以使用 vineProp.optional
。验证器参数也是可选的。
const foo = vineProp.optional<string>(/* 验证器参数是可选的 */)
若要设置带有默认值的 prop,可以使用 vineProp.withDefault
。第一个参数是默认值,第二个参数是验证器。
只要不是使用 vineProp.withDefault
,那么就必须要有一个类型参数来指定 prop
的类型。若使用了 vineProp.withDefault
,那么可以提供一个默认值来推导出类型。
由于 TypeScript 能够自动推断出默认值的类型,不需要将类型参数传递给它。
这里说一下,当确实需要一个布尔型 prop 时,类型参数应该是一个字面量
boolean
,并且在使用withDefault
时不能将变量标识符作为默认值,而是使用true
或false
字面量作为默认值。尽管 TypeScript 可以从变量中推断出类型,但 Vine 编译器并没有嵌入 TypeScript 编译器来得知这个 prop 是布尔型的。
// 正确写法
const foo = vineProp.withDefault('bar') // 通过默认值指定类型
const biz = vineProp.withDefault(someStringVariable) // 除了布尔类型外,可以使用其他的类型值变量作为默认值
const dar = vineProp<boolean>() // 通过类型参数指定类型
const bool = vineProp.withDefault(false) // 默认值如果是布尔类型需要通过字面量 false 的形式
// 错误写法
const bad1 = vineProp<SomeBooleanType>() // 布尔类型时必须是字面量 boolean,不能是其他类型,即使这个类型是布尔类型
const bad2 = vineProp.withDefault(someBooleanVariable) // 使用布尔型的默认值时必须是字面量 true 或者 false,不能是其他变量,即使这个变量的值是 true 或者 false
VCF 内调用 vineStyle 宏函数 => style
vineStyle
是一个用于定义样式的宏,在 VCF 内调用 vineStyle
宏函数就相当于 SFC
中的 style
块。
因为样式代码一写起来就会非常长的原因,Vine 并不推荐使用这个宏,而是推荐采用类似 UnoCSS、TailwindCSS 等原子化 CSS 方案或是导入外部样式表。
下面是 vineStyle 宏函数的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
interface VineStyle { [__VUE_VINE_STYLE__]: true }
interface VineStyleMacro {
(style: string | VineStyle): void
scoped: (style: string | VineStyle) => void
}
const vineStyle: VineStyleMacro
// CSS lang types helpers
const css: (style: TemplateStringsArray) => VineStyle
const scss: (style: TemplateStringsArray) => VineStyle
const sass: (style: TemplateStringsArray) => VineStyle
const less: (style: TemplateStringsArray) => VineStyle
const stylus: (style: TemplateStringsArray) => VineStyle
const postcss: (style: TemplateStringsArray) => VineStyle
在 VCF 外部不允许调用 vineStyle
。
在一个 VCF 中可以调用多次,因为可能在组件中想要同时提供 scoped 和非 scoped 样式。
起初的设计是不允许的,但是后面又改了,下面是对应的 issue
feat request: support use multiple vineStyle
feat(compiler): support multiple vineStyle
如果组件需要 scoped
,可以使用 vineStyle.scoped
。
vineStyle
宏调用不允许出现在变量声明内。
vineStyle
宏调用只能有一个且类型是字符串或 VineStyle
类型的参数。
这个 VineStyle
类型是标记模板字符串的标记函数调用得到的,也就是上面类型定义中各种的 CSS 处理器语言助手函数,这个标记函数仅支持:css
、scss
、sass
、less
、stylus
和 postcss
。
参数是模板字符串时,也是不允许有插值的。
fuction Example() {
// const style = vineStyle(``) // 报错,不允许出现在变量声明内
// vineStyle(`${xxx}`) // 报错,不允许有插值
// 正确写法
// 使用 scss 函数表示模板字符串中写的是 scss 格式的样式代码
vineStyle(scss`
.foo {
color: red;
.bar {
background: yellow;
}
}
`)
// 可以调用多次
// 使用 vineStyle.scoped 表示这块样式是 scoped 的
vineStyle.scoped(`
.test {
color: red;
}
`)
// ...
}
VCF 中使用的 vineStyle 宏函数在最终编译结果中会被转换为一个样式导入语句,这个语句所导入的 JS 代码逻辑大致是把 vineStyle 宏函数的参数也就是 css 字符串通过创建 <style>
元素添加到 head 标签内
// 生成样式导入语句的源码位置链接
// https://github.com/vue-vine/vue-vine/blob/v0.1.18/packages/compiler/src/style/create-import-statement.ts
import "/src/example?type=vine-style&comp=Example&lang=scss&scoped=false&index=0&virtual.scss";
import "/src/example?type=vine-style&scopeId=20579131&comp=Example&lang=css&scoped=true&index=1&virtual.css";
const Example = (() => {
// ...
})()
宏
随着 Vue 3.2 的发布,我们可以在
<script setup>
块中使用宏,而 Vue Macros 将这个想法推向了极致,在 Vue 3.3 中,Vue 添加了更多内置宏。宏是一些特殊的函数,它们只在编译时具有意义,它们是 Vine 编译器转换相应组件属性的提示。
Vine 宏调用必须在 Vue Vine 组件函数内部!
下面将开始一一介绍这些宏。
vineModel
这是它的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
function vineModel<T>(): Ref<T>
function vineModel<T>(modelOptions: {
default?: T
required?: boolean
}): Ref<T>
function vineModel<T>(
modelName: string,
modelOptions?: {
default?: T
required?: boolean
}
): Ref<T>
这个宏可以用来声明一个双向绑定 prop
,通过配合父组件的 v-model
来使用。
这个类型参数 T
表示的是 model
属性的类型
vineModel
宏调用函数参数最多只能有 2 个参数,函数参数 modelName
表示 model
属性的名字, 参数 modelOptions
中的 default
表示 model
属性的默认值,而 required
表示属性是否必需。
vineModel
宏函数可以在没有类型参数和函数参数的情况下调用,那么此时它将返回一个 Ref<unknown>
类型。那么这里的 model
就是一个 default model
。
default model
的意思就是 model prop 名称默认是 modelValue
,model event 名称默认为 update:modelValue
。
vineModel
宏调用必须在变量声明内,同时必须是变量标识符,不能是解构模式之类的。
如果 vineModel
宏调用没有类型参数,而它又传递了 modelOptions
参数,那么 modelOptions
参数中必须要有一个 default
字段表示默认值,否则报告未定义类型参数的错误。(因为这里的类型参数表示model
属性的类型,如果没有设置那么不知道属性的类型,而这里又使用了 modelOptions
参数,所以必须指定其中的 default
属性,通过它的默认值推导出该属性的类型。)这里注意:这个 default
字段表示的是 model
属性的默认值,不是表示当前是 default model
。
vineModel
宏在 Vue Vine 组件函数中可以调用多次,但是在一个 VCF 中只能有一个 default model
。
以下两种方式会被当作是 default model
// 默认 model
const model = vineModel()
// 带有选项的默认 model
const model = vineModel({
default: '' // 表示的是 model 属性的默认值,是个空串 ''
// ...
})
// 指定 model 属性的名字就不再是 default model 了
const Xxxmodel = vineModel('xxx')
const Xxxmodel = vineModel<string>('xxx')
const Xxxmodel = vineModel('xxx', {
default: ''
})
我们再来看一下 vineModel
宏函数经过编译之后的结果,本质其实就是定义 对应的 prop
和 值更新 emit 事件
,同时在 setup 函数中使用 useModel
钩子,这个钩子可以把是 model 的属性
转为 ref,这个 ref 在设置值时同时会触发对应的 update:[modelName]
事件。而在父组件中配合使用的 v-model
指令,其本质就是在父组件中传递相应的 prop 和 监听对应的值更新事件。
// App 组件
function App() {
// 在父组件中配合 v-model 指令使用
return vine`
<Counter
v-model="/**/"
v-model:xxx="/**/"
/>
<!--这是 v-model 指令的本质,在父组件这里其实就是传递相应的 prop 和 监听对应的值更新事件
<Counter
:modelValue="/**/"
@update:modelValue="/**/"
:xxx="/**/"
@update:xxx="/**/"
/>
-->
`
}
// Counter 组件
function Counter(props: { step: number }) {
// ...
const model = vineModel() // default model
const XxxModel = vineModel('xxx', {
default: "",
required: true
})
// ...
}
// 下面是 Counter 组件的编译结果
const __vine = _defineComponent({
name: "Counter",
props: {
step: { required: true },
// modelValue 属性
modelValue: {},
modelModifiers: {},
// xxx 属性
xxx: {
default: "", // 默认值
required: true // 必需
},
xxxModifiers: {}
},
// update:modelValue, update:xxx 事件
emits: ["update:modelValue", "update:xxx"],
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
// https://github.com/vuejs/core/blob/v3.5.0/packages/runtime-core/src/helpers/useModel.ts
// useModel hook
// 可以把是 model 的属性转为 ref,这个 ref 在设置值时同时会触发 update:[modelName] 事件
// 这里就是触发 update:modelValue 事件
const model = _useModel(__props, "modelValue");
const XxxModel = _useModel(__props, "xxx");
const count = ref(0);
return { model, XxxModel, count, ref, Counter };
}
});
vineEmits
这是它的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
type VineEmitsDefineSource = Record<string, any[]>
type VineEmitsDefineResult<D extends VineEmitsDefineSource> = IsEmptyObj<D> extends true
? never
: <E extends keyof D>(
eventName: E,
...args: D[E]
) => void
interface VineEmitsFn {
<E extends string>(events: E[]): VineEmitsDefineResult<Record<E, any[]>>
<D extends VineEmitsDefineSource>(): VineEmitsDefineResult<D>
}
const vineEmits: VineEmitsFn
为组件定义
emits
。该宏返回
emits
函数,使用时必须定义一个变量来接收返回值。这个类型参数的语法与 Vue 3.3 更简洁的语法相同,可以查阅官方文档了解更多细节。
在一个 VCF 中不允许调用多次。只能有一个类型参数。
宏调用必须在变量声明内,同时必须是变量标识符,不能是解构模式之类的。
vineEmits
类型参数必须是对象字面量,并且所有属性的 key
必须是字符串或标识符!
vineEmits 宏函数的最终编译结果就是在 options 对象的 emits 参数中定义对应的事件以及在 setup 函数内返回对应的变量名,但这个变量名本质就是上下文中的 emit
函数,只不过这里进行了重命名。
function MyComponent() {
const myEmit = vineEmits<{
update: [foo: string, bar: number]
}>()
// { 定义的事件名: [事件触发传递的参数和其类型] }
return vine`
<button @click="myEmit('update', 'foo', 1)">触发 update 事件</button>
`
}
// 以下是编译后的结果
const __vine = _defineComponent({
name: "MyComponent",
props: {},
// 定义 update 事件
emits: ["update"],
setup(__props, { emit: __emit, expose: __expose }) {
__expose();
const props = __props;
// myEmit 函数变量
const myEmit = __emit;
const count = ref(0);
return { myEmit, count, ref, Counter };
}
});
vineSlots
这是它的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
const vineSlots: <D extends Record<string, (props: any) => any>>() => D
这个宏的使用方法与 Vue 的 defineSlots 宏完全一致。
在一个 VCF 中不允许调用多次。
只接受类型参数,没有运行时参数。类型参数应该是一个对象字面量,其中属性键是插槽名称,值类型是插槽函数。函数的第一个参数是插槽期望接收的 props,其类型将用于模板中的插槽 props。返回类型目前被忽略,可以是 any
。
宏调用必须在变量声明内,同时必须是变量标识符,不能是解构模式之类的。
它返回 slots
对象,该对象等同于在 setup 上下文中暴露或由 useSlots()
返回的 slots 对象。
vineSlots 宏的本质就是在 setup 函数中使用 useSlots 这个 hook
function MyComponent() {
// 定义 默认插槽 将接收一个字符串类型的 msg 属性
// 返回一个 slots 对象
const slots = vineSlots<{
default(props: { msg: string }): any
}>()
return vine``
}
// 以下是编译后的结果
const __vine = _defineComponent({
name: "MyComponent",
props: {},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
// 通过使用 Vue 的 useSlots hook 得到这个 slots 对象
const slots = _useSlots();
const count = ref(0);
return { count, ref, Counter };
}
});
useSlots 这个 hook 的本质就是获取当前的实例对象,然后在其 setupContext 属性对象中获取 slots 属性值
// https://github.com/vuejs/core/blob/v3.5.8/packages/runtime-core/src/apiSetupHelpers.ts#L384
export function useSlots(): SetupContext['slots'] {
// 在当前实例的 setupContext 对象中获取 slots 属性值
return getContext().slots
}
function getContext(): SetupContext {
// 获取当前实例对象
const i = getCurrentInstance()!
if (__DEV__ && !i) {
warn(`useContext() called without active instance.`)
}
// 获取 setupContext 属性
return i.setupContext || (i.setupContext = createSetupContext(i))
}
vineExpose
这是它的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
const vineExpose: (exposed: Record<string, any>) => void
这个宏的使用方法与 Vue 的 defineExpose 宏完全一致。
vineExpose
宏调用不允许出现在变量声明内。
在一个 VCF 中不允许调用多次。
只能有一个对象字面量参数。
这个宏不能位于其他表达式中,只能直接调用。
该宏是用来显式指定在组件中要暴露出去的属性。当父组件通过模板引用的方式获取到当前组件的实例,获取到的实例会像这样 { a: number, b: number }
(ref 会和在普通实例中一样被自动解包)。
这个宏的本质就是在 setup 函数中通过执行上下文的 expose 函数进行暴露
function Counter() {
const a = 1
const b = ref(2)
vineExpose({
a,
b
})
// ...
}
// 以下是编译结果
const __vine = _defineComponent({
name: "Counter",
props: {},
setup(__props, { expose: __expose }) {
const props = __props;
const a = 1;
const b = ref(2);
// 通过使用 expose 函数
__expose({
a,
b
});
return { a, b, ref, Counter };
}
});
vineOptions
这是它的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
interface VineOptionsDef {
name?: string
inheritAttrs?: boolean
}
const vineOptions: (options: VineOptionsDef) => void
这个宏用于定义 Vue 的组件选项,但是只支持这两个:name
和 inheritAttrs
。
vineOptions
宏调用不允许出现在变量声明内。
在一个 VCF 中不允许调用多次。
只能有一个对象字面量参数。
这个宏不能位于其他表达式中,只能直接调用。
这个宏最终编译结果就是把宏函数的参数对象原封不动的合并到最终的 options 对象中
function Counter() {
vineOptions({
inheritAttrs: false
})
// ...
}
// 以下是编译结果
const __vine = _defineComponent({
...{
inheritAttrs: false
},
props: {},
/* ... */
});
vineCustomElement
这是它的类型定义
// vue-vine/vue-vine/blob/v0.1.15/packages/vue-vine/types/macros.d.ts
const vineCustomElement: (name: string) => void
自定义元素(Custom Elements
)是 Web Components
中的概念
Web Components 是一组 Web 原生 API 的总称,允许开发人员创建可重用的
Custom Elements
(自定义元素)。
这个宏的作用是为了把所在的 VCF
表示的组件变为一个 Custom Element
,同时注册这个 Custom Element
。
vineCustomElement
宏调用不允许出现在变量声明内。
在一个 VCF 中不允许调用多次。
这个宏不能位于其他表达式中,只能直接调用。
该宏的本质就是首先会去处理所在组件对应的样式把其变为内联 css,意思就是把对应的 css 字符串添加到对应的 options 对象的 styles
属性中,然后通过执行 Vue 的 defineCustomElement
函数把组件对象转为自定义元素构造函数,再然后将其注册到 window.customElements
中。
function Example() {
vineCustomElement('my-example')
return vine`
<div></div>
`
}
// 相当于以下代码
const Example = (() => {
const __vine = defineComponent({/**/})
// ...
// 添加 styles 属性
__vine.styles = [__example_styles]
return __vine
})()
// console.log(Example.styles) // ["/* inlined css */"]
// 使用 defineCustomElement 转换为自定义元素构造函数
const ExampleElement = defineCustomElement(Example)
// 向 window.customElements 中注册
customElements.define('my-example', ExampleElement)
在 Vue.js 官方文档可以查看 defineCustomElement
的具体细节。
Building Custom Elements with Vue
使用样式预处理器遇到的问题
npm install less -D
在 v0.1.15 版本使用 vineStyle
宏中使用 less
预处理器时报以下错误
查看最终报错是在 file:///home/projects/vitejs-vite-q8bbg1/node_modules/@vue-vine/compiler/dist/index.mjs:28:9
这个位置,我们直接定位到最终源码位置 https://www.npmjs.com/package/@vue-vine/compiler/v/0.1.15?activeTab=code
发现是最终产物的运行时代码所抛出的错误,原因在于运行时 require
函数为 undefined
出现的问题很简单,大胆猜测一下,当前所执行到的文件的后缀为 .mjs
,是一个 esm
规范,同时又因为运行时 require
函数为 undefined
,所以可以大致猜到当前是以 esm
规范执行的。那么我们在工程的根目录下的 package.json
文件中也证实了这一点,可以看到 type
字段为 module
。
这个问题已经有人向官方提 issure
了
BUG: can't use less in vineStyle macro
当前也已经得到了解决,可以直接升级到 v0.1.16 版本以上即可解决此问题
问题已解决,这是因为您的项目的根级别 package.json 已声明为
"type":"module"
,因此默认情况下每个 JS 文件都被解释为 ESM,此时require(...)
不可用。 但我们可以修改包导出规范,添加一个"node"
规范,该规范以 CJS 文件为目标,供 Node.js 解析。 该补丁将于v0.1.16
发布
fix(compiler): exports spec for running on Node & ESM
这里大致说一下 Node
以 ESM
规范是如何找包对应的产物文件并执行的过程,以当前为例子
当工程的 package.json
文件中添加了 "type": "module"
之后,Node
便以 ESM
规范执行 JS
文件,因为当前工程是 Vite
工程,所以中间会执行 vite.config.ts.timestamp-1724581056122-f6addd82b0cd.mjs
文件。注意:此文件是 Vite
进行转换的
我们关注其中的 @vue-vine/vite
包的路径经过 Vite 的处理查找到的路径为 node_modules/vue-vine/dist/vite.mjs
,再次强调这是 Vite 查找的,不是 Node
// vite.config.ts.timestamp-1724581056122-f6addd82b0cd.mjs
import { defineConfig } from "file:///home/projects/vitejs-vite-q8bbg1/node_modules/vite/dist/node/index.js";
import vue from "file:///home/projects/vitejs-vite-q8bbg1/node_modules/@vitejs/plugin-vue/dist/index.mjs";
// 这里 vue-vine/vite 被 vite 转为 file 协议
// 同时对应路径为最终的 xxx/vite.mjs
// 注意:查找此包的路径是 Vite 处理的,不是 Node,这里需要注意一下
import { VineVitePlugin } from "file:///home/projects/vitejs-vite-q8bbg1/node_modules/vue-vine/dist/vite.mjs";
var vite_config_default = defineConfig({
plugins: [
vue(),
// 添加 vine plugin
VineVitePlugin()
]
});
export {
vite_config_default as default
};
然后在 vue-vine/dist/vite.mjs
中我们看到又引入 @vue-vine/vite-plugin
,那么这里就是 Node
查找包文件路径了
在 Node
文档的 ESM
解析算法中,有这样一句话
defaultConditions is the conditional environment name array, ["node", "import"].
表示 defaultConditions
的默认值为 ["node", "import"]
所以我们查看 @vue-vine/vite-plugin
的 package.json
文件,发现没有 "node"
字段,那么这里就会找 "import"
所以就是这里的 index.mjs
,然后发现这里导入 @vue-vine/compiler
也是使用的 ESM
规范,所以查找包时,Node
是以 ESM
解析算法进行查找的
于是我们看 @vue-vine/compiler
的 package.json
文件,按照前面提到的 ESM
规范下的 defaultConditions
默认值,所以这里是以 node
字段对应的路径找的,所以就是最终执行的是 CJS
文件,所以 Node
知道它是 CommonJs
规范后,所以运行时就会有 require
函数啦
相关资料
我们可以在 rollup 的插件 node-resolve 中看到是直接迭代的 exports
的属性值对象,然后查看 conditions
数组中是否包含 target
对象中的 key
,所以这就说明了我们在配置 package.json
中的 exports
的属性值对象的 key
时,它的顺序是会影响最终所使用的文件的。
node-resolve-v15.2.3的resolvePackageTarget.ts#L141
在使用此插件时如果要和 node
解析行为一样,那么可以设置 exportConditions
为 ['node']
,这个参数就会被放在上面提到的 conditions
数组中。
node 文档中的相关资料
package.json 的 conditional-exports
package.json 的 package-entry-points
Vine 原理
经过前面的详细介绍,相信大家已经熟悉 Vine
的开发方式了,此时大家一定会对其背后的原理感到好奇,所以这里就简单说一下。
SFC 编译结果
我们先来看下 SFC
的编译结果是什么样的
以 Counter.vue
为例
// Counter.vue
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ step: number }>()
const count = ref(0)
</script>
<template>
<div class="counter">
<h2>count: {{ count }}</h2>
<button @click="count += step">+ {{ step }}</button>
<br />
<br />
<button @click="count -= step">- {{ step }}</button>
</div>
</template>
<style scoped lang="less">
h2 {
color: hotpink;
}
button {
outline: none;
}
</style>
以下是 Counter.vue
的转换结果,我们可以清晰的看到最终 export default
的是一个 component options
对象
// request -> /src/Counter.vue?t=1724766833879
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/src/Counter.vue");import { defineComponent as _defineComponent } from "/node_modules/.vite/deps/vue.js?v=7f3e3e97";
import { ref } from "/node_modules/.vite/deps/vue.js?v=7f3e3e97";
// component options 对象
const _sfc_main = /* @__PURE__ */ _defineComponent({
__name: "Counter",
props: {
step: { type: Number, required: true }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const count = ref(0);
const __returned__ = { props, count };
Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
return __returned__;
}
});
// ...
// render 函数
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
// ...
}
// 生成的样式导入语句
import "/src/Counter.vue?t=1724766553497&vue&type=style&index=0&scoped=cb80395f&lang.less";
import _export_sfc from "/@id/__x00__plugin-vue:export-helper";
// ...
// 默认导出 _sfc_main 这个组件对象
export default /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render], ["__scopeId", "data-v-cb80395f"], ["__file", "/home/projects/vitejs-vite-q8bbg1/src/Counter.vue"]]);
这里说一下 vue 中的 defineComponent
的源码实现,可以看到直接把 options
对象作为返回值了
// https://github.com/vuejs/core/blob/v3.4.38/packages/runtime-core/src/apiDefineComponent.ts#L301
// implementation, close to no-op
/*! #__NO_SIDE_EFFECTS__ */
export function defineComponent(
options: unknown,
extraOptions?: ComponentOptions,
) {
return isFunction(options)
? // #8326: extend call and options.name access are considered side-effects
// by Rollup, so we have to wrap it in a pure-annotated IIFE.
/*#__PURE__*/ (() =>
extend({ name: options.name }, extraOptions, { setup: options }))()
// 直接返回这个 options 对象
: options
}
这是 SFC
的 style
部分,可以看到在开发环境下直接把 <style>
块转换为 css
字符串,然后创建 style
标签把其添加到 <head>
标签内
// /src/Counter.vue?t=1724766553497&vue&type=style&index=0&scoped=cb80395f&lang.less
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/Counter.vue?vue&type=style&index=0&scoped=cb80395f&lang.less");
import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client"
const __vite__id = "/home/projects/vitejs-vite-q8bbg1/src/Counter.vue?vue&type=style&index=0&scoped=cb80395f&lang.less"
const __vite__css = "h2[data-v-cb80395f] {\n color: hotpink;\n}\nbutton[data-v-cb80395f] {\n outline: none;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)
// ...
我们看下客户端的 updateStyle
函数是如何工作的,大致就是创建一个 <style>
元素,然后把它添加到 <head>
元素内。
// https://github.com/vitejs/vite/blob/v5.4.1/packages/vite/src/client/client.ts#L405
export function updateStyle(id: string, content: string): void {
let style = sheetsMap.get(id)
if (!style) {
style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.setAttribute('data-vite-dev-id', id)
style.textContent = content
if (cspNonce) {
style.setAttribute('nonce', cspNonce)
}
if (!lastInsertedStyle) {
document.head.appendChild(style)
// reset lastInsertedStyle after async
// because dynamically imported css will be splitted into a different file
setTimeout(() => {
lastInsertedStyle = undefined
}, 0)
} else {
lastInsertedStyle.insertAdjacentElement('afterend', style)
}
lastInsertedStyle = style
} else {
style.textContent = content
}
sheetsMap.set(id, style)
}
VCF 编译结果
我们再来看下 VCF
的编译结果,这里以 counter.vine.ts
为例
import { ref } from 'vue'
export function Counter(props: { step: number }) {
const count = ref(0)
vineStyle.scoped(less`
h2 {
color: hotpink;
}
button {
outline: none;
}
`)
return vine`
<div class="counter">
<h2>count: {{ count }}</h2>
<button @click="count += step"> + {{ step }} </button>
<br />
<br />
<button @click="count -= step"> - {{ step }} </button>
</div>
`
}
下面是 counter.vine.ts
的编译结果,我们发现这不就是命名导出的 component options
对象么,看到这个编译结果可以先回答一个问题
为什么 Vine
可以做到一个文件写多个组件呢?
答:可以简单的说相当于 Vine
把 SFC
的编译结果直接放在了一个 IIFE
中,而这个立即执行函数的返回值就是这个 component options
对象,然后把它作为 Counter
常量的值,最后 export
导出它,所以我们在其他文件中可以直接按照命名导入(import { Counter } from './counter.vine
)的方式导入这个组件,所以在一个文件中我们可以写多个 VCF
,实际上最终会被转换为多个值为 component options
的命名导出常量,所以才可以在一个文件中导出多个组件。
立即执行函数在这里的作用是为了起到隔离的作用,因为我们知道函数执行会产生一个独立的执行上下文。
我们对照这个编译结果,清晰的展示了 VCF
各个部分的本质
- 函数参数就是
options
对象的props
属性 - 函数体会直接放在
options
对象的setup
函数内 - 函数返回值直接编译为一个
render
函数添加到options
对象的render
属性 - 函数中使用
vineStyle
宏函数,它的参数会被提取出来然后通过创建<style>
元素添加到head
标签内
// /src/counter.vine.ts?t=1724766451836
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/src/counter.vine.ts");import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, openBlock as _openBlock, createElementBlock as _createElementBlock, pushScopeId as _pushScopeId, popScopeId as _popScopeId, defineComponent as _defineComponent } from "/node_modules/.vite/deps/vue.js?v=c6c16c6f";
// VCF 中使用的 vineStyle 宏函数会被转换为一个样式导入语句
// 这个语句所 import 的 JS 代码逻辑大致是把 vineStyle 宏函数的参数也就是 css 字符串通过创建 <style> 元素添加到 head 标签内
// 生成样式导入语句的源码位置链接
// https://github.com/vue-vine/vue-vine/blob/v0.1.18/packages/compiler/src/style/create-import-statement.ts
import "/src/counter?type=vine-style&scopeId=20579131&comp=Counter&lang=less&scoped=true&index=0&virtual.less";
import { ref } from "/node_modules/.vite/deps/vue.js?v=c6c16c6f";
// 放在了一个立即执行函数中,返回结果就是组件 options 对象,之后 export 这个 Counter 常量
export const Counter = (() => { // IIFE
// ...
const __vine = _defineComponent({
name: "Counter",
// VCF 的函数参数会转为这里的 props 属性
props: {
step: { required: true }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
// VCF 的函数体部分会直接放在 setup 函数内
const count = ref(0);
return { count, ref, Counter };
}
});
// VCF 的返回值直接编译为一个 render 函数
function __sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
// ...
}
// 添加 render 属性
__vine.render = __sfc_render;
__vine.__scopeId = "data-v-20579131";
__vine.__hmrId = "20579131";
// 返回这个 options 对象
return __vine;
})();
// ...
我们再来看下样式部分,这不就和 SFC
中的处理逻辑是一样的么,这里就不说了
// /src/counter?type=vine-style&scopeId=20579131&comp=Counter&lang=less&scoped=true&index=0&virtual.less
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/counter?type=vine-style&scopeId=20579131&comp=Counter&lang=less&scoped=true&index=0&virtual.less");
import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client"
const __vite__id = "/home/projects/vitejs-vite-q8bbg1/src/counter?type=vine-style&scopeId=20579131&comp=Counter&lang=less&scoped=true&index=0&virtual.less"
const __vite__css = "h2[data-v-20579131] {\n color: hotpink;\n}\nbutton[data-v-20579131] {\n outline: none;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)
// ...
VCF 不是使用 JSX 的函数式组件
VCF 不是使用 JSX 的函数式组件。切记:千万不要搞混淆了。
先说下什么是函数式组件
函数式组件是组件的另一种形式,它自己没有任何状态。它们的行为就像纯函数:props 输入,vnodes 输出。它们的渲染无需创建组件实例(即没有 this),也无需通常的组件生命周期挂钩。
为了创建函数组件,我们使用普通函数,而不是
options
对象。该函数实际上是组件的render
函数。
下面是使用 JSX 的函数式组件
函数式组件的这个函数本质就是一个 render
函数,所以此函数是具有运行时意义的,也就是说该函数会在运行时执行的,每当依赖的数据发生改变时,此函数都会重新执行(因为它就是一个 render
函数),然后返回该组件当前最新的 vnodes
以便在 diff
阶段时和旧的 vnodes
进行比对,找出不同的地方然后进行修改对应的真实 dom
。
const count = ref(0)
// 使用 JSX 的函数式组件
function Counter(props, context) {
const { step } = props
// 某个判断逻辑
if (/**/) return <p></p>
return (
<div class="counter">
<h2>count: {{ count }}</h2>
<button @click="count += step"> + {{ step }} </button>
<br />
<br />
<button @click="count -= step"> - {{ step }} </button>
</div>
)
}
下面是使用 JSX 的普通组件
可以参考 ant-design-vue 的 Alert 组件
它和上面使用 JSX 的函数式组件是不同的,该组件在渲染时是会创建组件实例的,因为这里是 options
对象组件(而不是上面直接使用函数,也就是直接一个 render
函数作为组件),在 setup
函数内返回一个 render
函数,而这个 render
函数中使用 JSX 写法,所以该组件是一个普通组件,不是上面的函数式组件,那么该组件也是一样,在依赖的数据变化时,都会重新执行这里返回的 render
函数以返回当前组件最新的 vnodes
以便在 diff
阶段时使用。
const Counter = defineComponent({
setup(props, context) {
const count = ref(0)
// render 函数
return () => {
const { step } = props
// 某个判断逻辑
if (/**/) return <p></p>
return (
<div class="counter">
<h2>count: {{ count }}</h2>
<button @click="count += step"> + {{ step }} </button>
<br />
<br />
<button @click="count -= step"> - {{ step }} </button>
</div>
)
}
}
})
下面是使用 template 的 VCF
在上面的 Vine 原理 章节中可以知道 VCF 最终编译结果就是一个 options
对象,所以该组件在渲染时是会创建组件实例的,同时还需要注意不要错误的认为 VCF 的这个函数 和上面的函数式组件的函数是一样的(两者是不同的,前者没有运行时意义只在编译期供编译器使用,后者具有运行时意义,它其实就是 render
函数,每次数据变化都会重新执行),所以千万不能写出本该在 render 函数中写的逻辑而写在了这里的 VCF 中,从 Vine 原理 章节中可以知道 VCF 除返回值外的函数体部分会被放在最终 options 对象的 setup 函数中,并不会出现在 render 函数中,所以在 VCF 中写出与 render 函数有关的逻辑在这里其实是无意义的。
所以像下面这个例子中在 VCF 中使用 if
语句返回 render 函数返回值这样的代码都是无效的,因为 VCF 不是运行时函数,它不是函数式组件的这个函数,它只是一个在编译期间用来 声明
组件的函数以供编译器进行编译,在 Vine 原理 章节中也说明了 VCF 的这个函数各个部分的本质
我们在 SFC 中写一个组件就是 script
、template
、style
三大块,只不过在 Vine 中换了一种新形式,通过函数写法表示
组件(函数参数为 props 、函数体为 script setup,函数返回值为 template,而在函数体中使用 vineStyle 宏则是对应 style),所以底层本质还是和以前是一样的,只不过换了另外一种形式
所以不需要去担心如条件渲染、列表渲染这些写法上会不会和 JSX 是一样的(不是,和之前 SFC 时是一样的),当然还有如何结合 Pinia、Vue Router 或其他常见的库和插件(和之前 SFC 时是一样的),以前该咋用现在还咋用,所以在使用时不要感到有压力
// 使用 template 的 VCF
function Counter(props: { step: number }) {
const count = ref(0)
// 注意:在 VCF 中只能有一个 vine 标签模板字符串
// 这样写会报错的
// 同时还需要注意的一点是在 VCF 中的除返回值外的剩余函数体部分都会放在 setup 函数内
// 所以如果在这里写一些与 render 函数返回值相关逻辑的代码是无效的,因为最终不会出现在 render 函数中
// 而是放在了 setup 函数内,所以这样写是无意义的。
// if (/**/) return vine`<p></p>`
return vine`
<!--某个判断逻辑-->
<!--因为本质还是 template 所以就需要使用 template 的方法啦,使用 v-if 指令-->
<p v-if="/**/"></p>
<div v-else class="counter">
<h2>count: {{ count }}</h2>
<button @click="count += step"> + {{ step }} </button>
<br />
<br />
<button @click="count -= step"> - {{ step }} </button>
</div>
`
}
再次强调 VCF
不是使用 JSX
的函数式/普通组件,它就是一个披着 Function
外衣的 SFC
。
最后
至此,关于 Vue Vine
的讲解就到这里。
如果觉得文章不错,点个赞再走吧!
:)