前言
自从vue3彻底支持函数式编程后,终于支持jsx的写法了。写过react的朋友可能更容易理解无状态组件与有状态组件的区别。对写惯了vue2对象式编程的人来说,可能对于函数式组件还有些一知半解,时常不知道普通函数与jsx函数有什么区别,毕竟习惯了在.vue文件中的template一把梭。分不清在函数组件中直接返回jsx模板与返回一个对象又有什么区别。这篇文章我将浅谈一下自己的理解,希望为你解惑。
*基础好的朋友可直接阅读第四章
一、脚手架的.vue文件是什么
这里我们不讨论脚手架具体是如何处理.vue文件的,这个涉及到打包器的知识,感兴趣的朋友可以自行拓展了解。我们在基于vite或webpack的脚手架中开发vue时,能够很方便的在.vue文件中开发,一个.vue文件的基本三要素就是“template”、“style”以及“script”。在其他vue文件中使用时,只需要 import A from 'A.vue',然后在template模板中写上 <A />即可渲染。这是一个大家再熟悉不过的流程,可.vue文件的本质是什么呢?我们不妨输出一下我们引入的组件:
Test.vue
<template>
<div>测试</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped></style>
在App.vue中引入Test.vue,然后输出Test
<script setup lang="ts">
import Test from './Test.vue';
console.log(Test);
</script>
查看浏览器中的输出结果
image.png我们可以很清楚的看到,我们打印的Test输出了一个对象,并且对象中存在一个render函数,其他两个带下划线的我们暂且忽略。这里最重要的是一个render对象。 我们由此可见,在模板中引入的vue组件,实际上是一个带有render函数的对象。也就是说,vue在执行我们的组件时,会去对象中找render函数并且执行它,render函数会返回一个Vnodes渲染树,Vue就能根据这个树进行渲染显示。
二、render函数是什么
在vue中,render函数实际上返回的是一个h函数返回的虚拟dom树,实际上就是一个对象。它里面存储了很多dom的节点信息,这些信息都是虚拟的。这里我们不考虑vue的template,我们暂时把它忘掉。这里我们考虑一个场景,我给定你一个对象,例如
const obj = {
div:{
style:"color:red;font-size:13px"
}
}
我的需求是,希望你用这个对象,使用你知道的所有javascript知识,将它渲染到网页里。
这里我就不贴代码了。
我相信对你来说这是一件很简单的事情,不就是读取这个obj对象,然后创建相对应的div标签,接着将style设置为对象中的style,最后append到dom容器里不就好了吗?
是的,你的做法以及思想都完全没有错,而且这也不算很有难度。那么,如果我希望在div下再渲染一个span呢?像这样:
const obj = {
div: {
style: 'color:red;font-size:13px',
children: [
{ span: { style: 'color:red', content: '内容' } },
{ span: { style: 'color:red', content: '内容2' } }
]
},
};
你可能注意到,我在div下增加了一个叫children的数组,为什么是数组?因为div下不可能只有一个元素嘛!那么现在我希望在div下渲染出这两个span,并且content 内容也要显示在标签里,你会怎么做呢?
我觉得这个对于看我文章的人来说,应该也是毫无压力的,无非就是处理div的时候,顺便找一下有没有children,有的话就一起处理,最后一起append到dom容器里就好了嘛!
你真是个天才!根本难不倒你,你的想法完全可行。
可是content内容不可能一直不变,如果我需要修改span中的内容,那么你又得重新执行一遍你刚刚的处理的流程,我假设你很聪明的把刚刚对于obj的处理写了一个函数,那就意味着,你会想办法监听到我对obj到更改。只要有更改,你就会重新执行,确保页面显示的跟我的obj对象是一致的。
如果你真的这么做了,当obj这个对象所表达的标签足够多时,可能只是其中一个标签中的content改变,你就需要重新运行整个函数,然后整个处理完再全部append。你的页面性能将会迎来瓶颈,说人话就是卡到爆。
你会如何处理这个性能问题呢?
我觉得可以自己悄悄再写一个函数,专门用来缓存上次保存的obj对象,当原始obj对象更改时,我将缓存里的obj对象和更改的obj对象先进行一次比对,找出实际被更改的那个标签,然后单独对这个标签进行append。这样,我就可以做到只对有更改的标签进行append到页面上,做到了局部的DOM更新。 很棒对吧?!
如果你也觉得上面这个方案很棒,尤雨溪也是这么觉得的,这就是我们所谓的diff算法。
看到这里,实际上我们已经实现了最基本的数据驱动视图,我们只需要控制obj这个对象,具体的渲染更新我们交给了事先封装好的针对处理obj的函数(我假设你封装好了)。
我们刚刚讨论的那个obj,里面的结构完全是我定义的,你是被动的。虽然可以实现想要的效果,但每个人都有自己的想法。比如那个content,你偏偏改成text或者value来表达不行吗? 那当然行啦!谁能犟过你啊。 但是无规矩不成方圆,既然这个方案很好用,能实现数据驱动视图的最基本方案。是不是应该大力推广呢?那用的人一多,那就必须有一套规范了,可不能乱来。
这套规范叫hyperscript,简称为h,就是我们熟悉的h函数啦。实际上,h函数就是我们上面讨论规范后的结构。具体的可以参考vue官方文档:https://cn.vuejs.org/guide/extras/render-function.html#creating-vnodes
为什么是h函数而不是对象?
在实际开发中,框架除了要处理我们的模板外,还要处理很多其他的逻辑。比如说,你写了一个虚拟DOM表达对象:{type:"div",....},但一个成熟可用的框架要做的事情很多,例如vue可能还需要维护虚拟DOM的唯一性,需要往对象里插入更多属性配合性能优化。所以vue自身提供了一个h函数,我们可以把虚拟dom表达在函数中。vue调用h函数会经过一层处理,再次返回一个可用的虚拟DOM树,也就是VNode。同样,在vue3中使用jsx时,它的逻辑也是h函数的语法糖,最终vue拿到的都需要是一个Vnode。也就是一个实际可用的虚拟DOM树。
写了这么多,终于可以回到主题了,什么是render函数? 其实render函数返回的就是我们上面写的obj对象,只不过vue自身封装了一个h函数,h函数基于hyperscript规范同时又增加了vue中特有的字段,比如props。最终h函数会返回一个vue实际可用的Vnode虚拟DOM对象。总的来说,render是返回一个可执行的h函数,h函数返回一个Vnode对象,对象结构里表达了各种标签渲染规则。
还记得一开始我打印的组件吗?留给大家一个小问题:为什么vue调用render返回h函数,为什么不直接命名为h而是命名为render呢?
三、什么是JSX
我们上面聊过h函数,在vue中,它就是接收hyperscript基础规范语法的函数。并且h函数返回一个vue可用的虚拟DOM树。对象里描述了将来渲染真实html的规则。又由于对象可以层层嵌套描述更多html规则,所以我们把这种对象称之为虚拟DOM树。为什么说是虚拟?因为它是一个js对象,并不是真实dom,真实dom是需要你根据这个对象再去处理渲染的。又因为它层层嵌套很像一棵树分叉,所以我们称之为虚拟DOM树。
说这么多,那什么是jsx呢?要不说程序员偷起懒来,就会促进科技的进步呢。实际上JSX就是一种偷懒的结果。我们先看下面这个示例:
const test = h('div', { id: 'foo' }, 'hello')
这是一个很简单的h函数,里面描述了一个div,它代表根节点。自身id为"foo",并且这个div内有一个文本叫"hello"。我把这个h函数用test包装起来,Vue的h函数将会为我们返回vue可执行的虚拟DOM树,将来它渲染出html就是以下样式:
<div id="foo">
hello
</div>
看起来结果很好,但现在我们只有一个div。我们知道在实际开发中,要写完整个页面可不止这一个div。那总不可能所有写页面过程都面向这个h函数吧,要一个个手写对象去描述html结构,想想就头大了。 所以这个时候jsx来了:
const test = <div id="foo">hello</div>
是的你没看错,我把html丢到了变量里。那有朋友就要问了:这么写js不会报错吗?
会!而且大错特错!js才不认识你这堆玩意儿呢。js里有这种奇葩的做法吗?再不济写到js里也应该用字符串包裹一下吧,你这直接扔过来算什么?报错是必然的。
所以,JSX它注定要经过一层编译处理。无论你是在原生scirpt里写jsx语法还是在脚手架里写jsx语法。要么你就用scirpt引入解析jsx的插件,要么你就是在脚手架里配置好jsx的插件。无论如何,它都必须要经过一层处理。
处理什么?如何处理?实际上,就是把jsx的语法转换为h函数,然后再交给实际要处理h函数渲染的逻辑去处理。
说到这里,肯定有朋友要问了:那既然jsx还得转换成h函数才能执行,那不是比直接写h函数执行效率更低了吗?
我说朋友,你要这么想,也没错。但是jsx我们一般是在开发环境使用,毕竟一般开发react和vue的都上脚手架了是吧。实际上在我们最后执行打包操作后,打包完的代码里是没有jsx的,所以在打包后的运行代码是没有性能消耗的,也就是开发运行时耗一点性能。再说这都2024了,你是要jsx呢?还是要自己手写h函数里的DOM结构描述对象?
jsx作为h函数的语法糖,它并不只是带给我们布局更方便的好处。由于我们将布局揉合到了js里,也就意味着可以在jsx模板里一起把变量渲染的逻辑做了。
比如:
const name = '张三'
const test = <div id="foo">{name}</div>
渲染结果:
<div id="foo">
张三
</div>
是不是很方便?那有同学说了,如果我变量更新了,如何保证重新渲染呢?
我们之前讨论过,jsx只不过是h函数的语法糖,而h函数也只不过是返回一个标准化的虚拟DOM树而已。说人话就是 只是统一了数据规范。至于如何处理jsx中的逻辑,不同的框架语言有自己的优化逻辑。比如react能搭配hooks和state对虚拟DOM树进行动态更新渲染。同样vue3中的ref也可以搭配jsx使用,就像vue3的h函数在返回可用虚拟DOM之前,会往对象里插入更多其他的属性,这些都是vue的一部分。
总之,jsx是h函数的语法糖。除此之外 我们需要搭配框架语言本身提供的功能使用。也就是说,在vue3里写jsx跟react里最简单的不同点是,你们可以在jsx基本模板语法保持一致的情况下,可以使用自身框架的状态管理和hook来编写代码。
四、VUE多种组件形式之:函数式组件(无状态组件)
函数式组件通常用来做无状态组件和有状态组件,这里并不是说.vue文件那种不能写无状态和有状态无状态组件,只是在无状态组件比较简单时,更优先采用函数式。在vue3中,平时当我们编写组件时,我们会在组件内写很多响应式变量,可能还会写点击事件改变某些变量从而重新渲染页面。这种有自己内部响应式变量需要管理维护的组件叫有状态组件。而相反,如果一个组件它只是接收一个参数,然后渲染到页面,自身并没有定义响应式变量和方法来异步控制页面更新,那么这种就叫无状态组件。 类似组件比如时间转换组件,可能你有一个组件接收一个时间props,处理后展示到页面上,仅此而已没有其他变量状态需要管理。这种我们就可以称之为无状态组件,它就两件事 1.接收变量2.处理变量渲染。也有可能就一件事:渲染静态。
没有任何自身其他的响应式变量需要管理。
一般无状态组件,很适合编写一个函数,然后函数内返回一个jsx模板,比如下面的示例:
假设有个index.jsx有以下代码:
export default function HandleTime(){
return <div>2024年x月x日<div>
}
这是一个很普通的函数式组件,那如何使用这个组件呢?
假设我们有个index.vue:
<templat>
<HanldeTime />
</template>
<script setup>
import HanldeTime from 'index.jsx'
</script>
以上代码会在页面渲染出以下结果
<div>2024年x月x日</div>
这是很显而易见的结果,但是 我编写的HandleTime不是一个函数吗?为什么vue能把我的函数当作模板渲染?
在第一小节中,我打印了一个.vue后缀的组件,在浏览器中能看到它实际上是一个对象,里面有个render方法。所以我们其实可以得到一个结论:vue的.vue后缀文件被解析成一个对象,并且在解析过程中,template会被解析成render函数。那render函数又返回什么呢?
在第二小节我已经给了解答,实际上render返回的是一个h函数。而h函数又是返回一个包装好的虚拟DOM树,也就是Vnode,vue拿到Vnode就能执行渲染逻辑了。
那到底为什么vue可以把HanldeTime函数当作模板渲染?
因为一个我编写的函数组件,采用的是jsx的方式返回
return <div>2024年x月x日<div>
这一段return实际上就是jsx,然而我们前面总结过,在vue中jsx其实就是h函数的语法糖。 而我们又看到一个组件输出的是一个对象里面包含render,调用render仍然也是为了拿到h函数的结果得到Vnode。所以如果我们函数里直接返回了jsx也就意味着直接返回了Vnode,所以它可以直接放在模板中当作组件渲染。
既然如此,如果项目中没有用到jsx,应当如何返回一个无状态组件?
那就不得不手动引入vue提供的h函数了,你可以用以下写法
import { h } from 'vue'
export default function HandleTime(){
return h('div','2024年x月x日')
}
引入这个HandleTime,渲染的效果跟用jsx是一样的,因为jsx就是h函数的语法糖。在没有jsx的情况下,用h函数来应急也是不错的选择,但不建议大量在项目中使用,是真的很难维护。
五、VUE多种组件形式之:函数式组件(有状态组件)
我们了解过无状态函数组件后,总会发现一个组件不能传参、不能管理自己的变量的话,使用场景会变得很局限。所以如何给一个函数式组件传参?
其实很简单:
<templat>
<HandleTime name='张三' />
</template>
<script setup>
import HandleTime from 'index.jsx'
</script>
我们可以把vue的模板使用当作函数的调用来看待,这里其实就是调用了HandleTime这个函数,并且将name当作函数参数传递进去了。
export default function HandleTime(props){
return <div>{props.name}<div>
}
参数会变成一个对象传递进来,在HandleTime这个函数中接收,直接使用这个name就可以了。
那有人会问了,如果外面的name变化了怎么办?
其实,在vue3中,只需要保证你传递的这个name是响应式变量,当name更新时,HandleTtime组件就会重新渲染。
比如你可以这么写:
<templat>
<HandleTime :name='name' />
</template>
<script setup>
import {ref} from 'vue
import HandleTime from 'index.jsx'
const name = ref('张三')
</script>
我把name用ref包裹,并且传递到HandleTime组件中,后续只要name更新,组件内也会跟着一起重新渲染。
到此为止,我们其实还算是无状态组件,无非就是传递了一个参数进去,如果Handle需要自己的响应式变量怎么办?
其实也很简单,直接引用vue的函数就好了,这也是vue3的特点:
import { ref} from 'vue'
export default function HandleTime(props){
const count = ref(0)
return <div onClick=()=>{ count++ }>{props.name}<div>
}
这就是一个最基础的有状态组件,我们可以引入Vue的hook来搭配使用。是不是很简单?
*问题解答:为什么vue调用render返回h函数,为什么不直接命名为h而是命名为render呢?
因为.vue的文件中,render只是转换了你的template模板代码,render实际上是不包含你的业务代码的,包括生命周期也不包含在内。render负责渲染html那么就只是需要返回一个h函数的Vnode即可。 除了渲染工作,还有其他的事情要做,所以重新定义一个render函数,可以在render函数中做很多中间处理,最后只需要返回一个可用的Vnode即可。类似于:
{
render(){
return h('div')
}
}
而在render中还可以做很多中间操作
{
render(){
const name = 'content'
return h('div',name)
}
}
vue在渲染时,调用render即可拿到需要渲染的Vnode信息,而除了render,vue还可以在这个对象里存储其他的数据,比如生命周期或一些私有变量。
这里也就意味着,我们直接用函数式组件 等于就直接返回了Vnode,不需要再次使用render来包装,我们打印一个函数式组件会发现控制台有如下输出:
image.png
这就可以证明,函数式组件确实没有render,而是直接返回的一个可执行的函数,并且里面就是Vnode,执行就可以渲染。
但如果在特殊情况下,我们的jsx渲染或者组件不得不自己写在对象里,也是可以起名叫render的,毕竟这个单词它好理解,直观。
六、VUE多种组件形式之:对象式组件
细心的朋友可能发现了,讲了半天函数式组件,生命周期呢?昂?生命周期呢?
不好意思,函数式组件没办法使用生命周期hook。
想要在组件中使用生命周期,我们先看文档是怎么说的:
image.png
什么意思呢?意思就是说,生命周期必须要在setup周期内执行,生命周期钩子也可以封装到其他函数里,但前提是函数调用必须在setup周期内。但请大家注意了,它这里指的是.vue模板开发中,你可以把生命周期钩子写在别的函数里,在setup作用域使用即可。 但我们这个函数式组件是直接给模板使用了,不存在什么setup,在函数式组件里写生命周期是无效的。
但同时它也给出了答案,在setup上下文中使用即可。
还记得vue的对象式编程刚转vue3的时候吗?是不是用setup函数代替的?那时候还没有script setup这个用法。这里,我们也可以这样使用:
在index.jsx中
import {onMounted} from 'vue'
export default {
setup(){
onMounted(()=>{
console.log('生命周期加载')
})
return '<div>你好</div>'
}
}
同样在模板中
<template>
<HandleTime />
</template>
<script setup>
import HandleTime from 'index.jsx'
<script>
有朋友会细心的发现,不对啊,setup最终return不是代表对外暴露的变量吗?
的确是,我们回想一下,在vue3刚出来的时候,我们是不是可以在写template的情况下,然后对象上加一个setup就好了? 但是我们这边是不是没有template了,我们也没办法使用template,因为我们这不是约束在.vue模板中了,那没有template了怎么办?
这里的问题还是,template是什么?我们前面讨论过,templte就是render函数的语法糖,而render返回的是h函数。h函数又会返回Vnode。没错吧?所以没有了template,我们需要手动写上render,所以以下的代码也是可用的:
import {onMounted} from 'vue'
export default {
render(){
return '<div>你好</div>'
},
setup(){
onMounted(()=>{
console.log('生命周期加载')
})
}
}
而以上写法,会使得变量的传递显得异常的麻烦,因为上面的写法不多,这里不再过多赘述,我们了解一下本质就可以。
因为变量传递过于麻烦,所以vue3还支持在setup中直接return返回模板,至于变量我们在setup中做就可以了,生命周期也能用了。
那么问题来了,本身setup返回的是对外暴露的变量,这么一写,还怎么暴露变量?现在都默认暴露的是JSX了,也就是Vnode。 怎么办?
我们可以使用expose函数,它不是一个需要显示导入的hook,以下是官方示例用法:
export default {
setup(props, { expose }) {
// 让组件实例处于 “关闭状态”
// 即不向父组件暴露任何东西
expose()
const publicCount = ref(0)
const privateCount = ref(0)
// 有选择地暴露局部状态
expose({ count: publicCount })
}
}
此时我们可以用expose替代原本的return暴露属性,return我们就用来返回一个jsx。 具体的细节可以查看官方文档:https://cn.vuejs.org/api/composition-api-setup.html#usage-with-render-functions
不知道大家注意到没有,我导出的是一个对象。而函数组件我导出的是一个函数,无论是函数还是对象,vue都可以正常解析,这里面的基础逻辑是什么呢?可以总结为:
当vue模板渲染时,如果拿到的是一个函数,会尝试执行它,如果返回的不是一个Vnode,将会在控制台报错,如果拿到的是Vnode,将会正常渲染成html。
如果拿到的是一个对象,vue会去找这个对象的render函数,如果没有render函数将会执行setup,最终拿到setup返回的Vnode。
七:VUE多种组件形式之:辅助函数包装组件:defineComponent
辅助函数包装组件这个名字是我自己取的,我也不知道具体如何称呼它,但也是字面意思,它通过vue提供的辅助函数编写组件。
它的作用主要是为Ts提供类型推导,以下是官方语法:
import { ref, h } from 'vue'
const Comp = defineComponent(
(props) => {
// 就像在 <script setup> 中一样使用组合式 API
const count = ref(0)
return () => {
// 渲染函数或 JSX
return h('div', count.value)
}
},
// 其他选项,例如声明 props 和 emits。
{
props: {
/* ... */
}
}
)
它接收两个参数,参数1可以当setup函数使用,最终返回一个Vnode,也就是JSX。但它这里没有基于jsx,所以返回的是h函数。如果有引入jsx这里直接返回jsx就可以。
第二个参数是组件的一些选项,这里不再过多赘述了,就是提供多了一些TS支持,如果有需要可以用defineComponent包装一下,它既支持生命周期又支持TS推导,也是一个不错的选择,不过我比较少用。
具体的可看文档了解:https://cn.vuejs.org/api/general.html#definecomponent
最后
这篇文章我尝试从vue的h函数开始到JSX到templat模板语法,接着给大家介绍基于这些概念的几种组件编写方式。知识点多而杂,如有理解错误的地方 还请大家指出,多多包涵。感谢你的耐心阅读。
完。
网友评论