分享

Vue.js函数式组件的全面了解

 新用户604881da 2021-11-11
目录

· 前言

· React 函数式组件

· Vue(2.x) 中的函数式组件

· 

· 渲染上下文

· template

· emit

· filter

· 插槽

· provide / inject

· HTML 内容

· 样式

· TypeScript

· 结合 composition-api

· 单元测试

· re-render

· 多个根节点

· fragment 组件

· Vue 3 中的函数式组件

· 真正的函数组件

· 单文件组件

· 总结

前言

如果你是一位前端开发者,又在某些机会下阅读过一些 Java 代码,可能会在后者中看到一种类似 ES6 语法中箭头函数的写法

1

(String a, String b) -> a.toLowerCase() + b.toLowerCase();

这种从 Java 8 后出现的 lambda 表达式,在 C++ / Python 中都有出现,它比传统的 OOP 风格代码更紧凑;虽然 Java 中的这种表达式本质上还是一个生成类实例的函数式接口(functional interface)语法糖,但无论其简洁的写法,还是处理不可变值并映射成另一个值的行为,都是典型的函数式编程(FP - functional programming)特征。

1992 年的图灵奖得主 Butler Lampson 有一个著名的论断:

All problems in computer science can be solved by another level of indirection
计算机科学中的任何问题都可以通过增加一个间接层次来解决

这句话中的“间接层次”常被翻译成“抽象层”,尽管有人曾争论过其严谨性,但不管怎么翻译都还说得通。无论如何,OOP 语言拥抱 FP,都是编程领域日益融合并重视函数式编程的直接体现,也印证了通过引入另一个间接层次来解决实际问题的这句“软件工程基本定理”。

还有另一句同样未必那么严谨的流行说辞是:

OOP 是对数据的抽象,而 FP 用来抽象行为

不同于面向对象编程中,通过抽象出各种对象并注重其间的解耦问题等;函数式编程聚焦于最小的单项操作,将复杂任务变成一次次 f(x) = y 式的函数运算叠加。函数是 FP 中的一等公民(First-class object),可以被当成函数参数或被函数返回。

同时在 FP 中,函数应该不依赖或影响外部状态,这意味着对于给定的输入,将产生相同的输出 -- 这也就是 FP 中常常使用“不可变(immutable)”、“纯函数(pure)”等词语的缘由;如果再把前面提过的 “lambda 演算”,以及 “curring 柯里化” 等挂在嘴边,你听上去就是个 FP 爱好者了。

以上这些概念及其相关的理论,集中诞生在 20 世纪前半叶,众多科学家对数理逻辑的研究收获了丰硕的成果;甚至现在热门的 ML、AI 等都受益于这些成果。比如当时大师级的美国波兰裔数学家 Haskell Curry,他的名字就毫不浪费地留在了 Haskell 语言和柯里化这些典型的函数式实践中。

React 函数式组件

如果使用过 jQuery / RxJS 时的“链式语法”,其实就可以算做 FP 中 monad 的实践;而近年来大多数前端开发者真正接触到 FP,一是从 ES6 中引入的 map / reduce 等几个函数式风格的 Array 实例方法,另一个就是从 React 中的函数式组件(FC - functional component)开始的。

React 中的函数式组件也常被叫做无状态组件(Stateless Component),更直观的叫法则是渲染函数(render function),因为写出来真的就是个用来渲染的函数而已:

1

2

3

const Welcome = (props) => {

  return <h1>Hello, {props.name}</h1>;

}

结合 TypeScript 的话,还可以使用 type 和 FC<propsType> 来对这个返回了 jsx 的函数约束入参:

1

2

3

4

5

6

7

type GreetingProps = {

 name: string;

}

const Greeting:React.FC<GreetingProps> = ({ name }) => {

 return <h1>Hello {name}</h1>

};

也可以用 interface 和范型,更灵活地定义 props 类型:

1

2

3

4

5

6

7

interface IGreeting<T = 'm' | 'f'> {

 name: string;

 gender: T

}

export const Greeting = ({ name, gender }: IGreeting<0 | 1>): JSX.Element => {

 return <h1>Hello { gender === 0 ? 'Ms.' : 'Mr.' } {name}</h1>

};

Vue(2.x) 中的函数式组件

Vue 官网文档的【函数式组件】章节中,这样描述到:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

...我们可以将组件标记为 functional,这意味它无状态 (没有响应式数据),也没有实例 (没有 this 上下文)。一个函数式组件就像这样:

Vue.component('my-component', {

  functional: true,

  // Props 是可选的

  props: {

    // ...

  },

  // 为了弥补缺少的实例

  // 提供第二个参数作为上下文

  render: function (createElement, context) {

    // ...

  }

})

...

2.5.0 及以上版本中,如果你使用了[单文件组件],那么基于模板的函数式组件可以这样声明:

<template functional>

</template>

写过 React 并第一次阅读到这个文档的开发者,可能会下意识地发出 “啊这...” 的感叹,写上个 functional 就叫函数式了???

实际上 Vue 3.x 中,你还真的能和 React 一样写出那种纯渲染函数的“函数式组件”,这个我们后面再说。

在目前更通用的 Vue 2.x 中,正如文档中所说,一个函数式组件(FC - functional component)就意味着一个没有实例(没有 this 上下文、没有生命周期方法、不监听任何属性、不管理任何状态)的组件。从外部看,它大抵也是可以被视作一个只接受一些 prop 并按预期返回某种渲染结果的 fc(props) => VNode 函数的。

并且,真正的 FP 函数基于不可变状态(immutable state),而 Vue 中的“函数式”组件也没有这么理想化 -- 后者基于可变数据,相比普通组件只是没有实例概念而已。但其优点仍然很明显:

因为函数式组件忽略了生命周期和监听等实现逻辑,所以渲染开销很低、执行速度快

相比于普通组件中的 v-if 等指令,使用 h 函数或结合 jsx 逻辑更清晰

更容易地实现高阶组件(HOC - higher-order component)模式,即一个封装了某些逻辑并条件性地渲染参数子组件的容器组件

可以通过数组返回多个根节点

先来直观感受一个适用 FC 的典型场景:

 

这是 ElementUI 官网中对自定义表格列给出的例子,其对应的 template 部分代码为:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

<template>

  <el-table

    :data="tableData"

    style="width: 100%">

    <el-table-column

      label="日期"

      width="180">

      <template slot-scope="scope">

        <i class="el-icon-time"></i>

        <span style="margin-left: 10px">{{ scope.row.date }}</span>

      </template>

    </el-table-column>

    <el-table-column

      label="姓名"

      width="180">

      <template slot-scope="scope">

        <el-popover trigger="hover" placement="top">

          <p>姓名: {{ scope.row.name }}</p>

          <p>住址: {{ scope.row.address }}</p>

          <div slot="reference" class="name-wrapper">

            <el-tag size="medium">{{ scope.row.name }}</el-tag>

          </div>

        </el-popover>

      </template>

    </el-table-column>

    <el-table-column label="操作">

      <template slot-scope="scope">

        <el-button

          size="mini"

          @click="handleEdit(scope.$index, scope.row)">编辑</el-button>

        <el-button

          size="mini"

          type="danger"

          @click="handleDelete(scope.$index, scope.row)">删除</el-button>

      </template>

    </el-table-column>

  </el-table>

</template>

在实际业务需求中,像文档示例中这种小表格当然存在,但并不会成为我们关注的重点;ElementUI 自定义表格列被广泛地用于各种字段繁多、交互庞杂的大型报表的渲染逻辑中,通常是 20 个以上的列起步,并且每个列中图片列表、视频预览弹窗、需要组合和格式化的段落、根据权限或状态而数量不定的操作按钮等等,不一而足;相关的 template 部分也经常是几百行甚至更多,除了冗长,不同列直接相似的逻辑难以复用也是个问题。

正如电视剧《老友记》中台词所言:

欢迎来到现实世界!它糟糕得要命~ 但你会爱上它!

vue 单文件组件中并未提供 include 等拆分 template 的方案 -- 毕竟语法糖可够多了,没有最好。

有洁癖的开发者会尝试将复杂的列模版部分封装成独立的组件,来解决这个痛点;这样已经很好了,但相比于本来的写法又产生了性能隐患。

回想起你在面试时,回答关于如何优化多层节点渲染问题时那种气吞万里的自信

首先尝试的是把原本 template 中日期列的部分“平移”到一个函数式组件 DateCol.vue 中:

1

2

3

4

5

6

<template functional>

  <div>

    <i class="el-icon-time"></i>

    <span style="margin-left: 10px; color: blue;">{{ props.row.date }}</span>

  </div>

</template>

 

在容器页面中 import 后声明在 components 中并使用:

 

基本是原汁原味;唯一的问题是受限于单个根元素的限制,多套了一层 div,这一点上也可以用 vue-fragment 等加以解决。

接下来我们将姓名列重构为 NameCol.js:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

export default {

  functional: true,

  render(h, {props}) {

    const {row} = props;

    return h('el-popover', {

        props: {trigger: "hover", placement: "top"},

        scopedSlots: {

          reference: () => h('div', {class: "name-wrapper"}, [

            h('el-tag', {props: {size: 'medium'}}, [row.name + '~'])

          ])

        }

      }, [

          h('p', null, [`姓名: ${ row.name }`]),

          h('p', null, [`住址: ${ row.address }`])

      ])

  }

}

 

 

效果没得说,还用数组规避了单个根元素的限制;更重要的是,抽象出来的这个小组件是真正的 js 模块,你可以不用 <script> 包装它而将其放入一个 .js 文件中,更可以自由地做你想做的一切事情了。

h 函数可能带来些额外的心智负担,只要再配置上 jsx 支持,那就和原版几无二致了。

另外这里涉及到的 scopedSlots 以及第三列里将面临的事件处理等,我们后面慢慢说。

渲染上下文

回顾上面提到的文档章节,render 函数是这样的形式:

1

render: function (createElement, context) {}

实际编码中一般习惯性地将 createElement 写为 h,并且即便在 jsx 用法中表面上不用调用 h,还是需要写上的;在 Vue3 中,则可以用 import { h } from 'vue' 全局引入了。

It comes from the term "hyperscript", which is commonly used in many virtual-dom implementations. "Hyperscript" itself stands for "script that generates HTML structures" because HTML is the acronym for "hyper-text markup language". -- Evan You

官网文档继续写到:

组件需要的一切都是通过 context 参数传递,它是一个包括如下字段的对象:
 
    props:提供所有 prop 的对象
    children:VNode 子节点的数组
    slots:一个函数,返回了包含所有插槽的对象
    scopedSlots:(2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。
    data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件
    parent:对父组件的引用
    listeners:(2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。
    injections:(2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的 property。

这个 context 也就是被定义为 RenderContext 的一个接口类型,在 vue 内部初始化或更新组件时,是这样形成的:

 

熟练掌握 RenderContext 接口定义的各种属性,是我们玩转函数式组件的基础。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多