温馨提示×

温馨提示×

您好,登录后才能下订单哦!

密码登录×
登录注册×
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》

Vue.js函数式组件是什么

发布时间:2022-03-04 14:19:26 来源:亿速云 阅读:146 作者:小新 栏目:开发技术

这篇文章主要为大家展示了“Vue.js函数式组件是什么”,内容简而易懂,条理清晰,希望能够帮助大家解决疑惑,下面让小编带领大家一起研究并学习一下“Vue.js函数式组件是什么”这篇文章吧。

    前言

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

    (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),因为写出来真的就是个用来渲染的函数而已:

    const Welcome = (props) => { 
      return <h2>Hello, {props.name}</h2>; 
    }

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

    type GreetingProps = {
     name: string;
    }
     
    const Greeting:React.FC<GreetingProps> = ({ name }) => {
     return <h2>Hello {name}</h2>
    };

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

    interface IGreeting<T = 'm' | 'f'> {
     name: string;
     gender: T
    }
    export const Greeting = ({ name, gender }: IGreeting<0 | 1>): JSX.Element => {
     return <h2>Hello { gender === 0 ? 'Ms.' : 'Mr.' } {name}</h2>
    };

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

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

    ...我们可以将组件标记为 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)模式,即一个封装了某些逻辑并条件性地渲染参数子组件的容器组件

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

    ? 举个栗子:优化 el-table 中的自定义列

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

    Vue.js函数式组件是什么

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

    <template>
      <el-table
        :data="tableData"
        >
        <el-table-column
          label="日期"
          width="180">
          <template slot-scope="scope">
            <i class="el-icon-time"></i>
            <span >{{ 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 中:

    <template functional>
      <div>
        <i class="el-icon-time"></i>
        <span >{{ props.row.date }}</span>
      </div>
    </template>

    Vue.js函数式组件是什么

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

    Vue.js函数式组件是什么

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

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

    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 }`])
          ])
      }
    }

    Vue.js函数式组件是什么

    Vue.js函数式组件是什么

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

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

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

    渲染上下文

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

    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 内部初始化或更新组件时,是这样形成的:

    Vue.js函数式组件是什么

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

    template

    在前面的例子中,我们使用一个带 functional 属性的 template 模版,将表格中日期列部分的逻辑抽象为一个独立模块。

    上面的原理图中也部分解释了这一点,Vue 的模板实际上被编译成了渲染函数,或者说 template 模版和显式的 render 函数遵循同样的内部处理逻辑,并且被附加了 $options 等属性。

    也就是说,处理一些复杂的逻辑时,我们依然可以借助 js 的力量,比如在 template 中习惯地调用 methods 等 -- 当然这并非真正的 Vue 组件方法了:

    Vue.js函数式组件是什么

    emit

    函数式组件中并没有 this.$emit() 这样的方法。

    但事件回调还是可以正常处理的,需要用到的就是 context.listeners 属性 -- 正如文档中提到的,这是 data.on 的一个别名。比如之前的例子中,我们想在容器页面中监听日期列的图标被点击:

    <date-col v-bind="scope" @icon-click="onDateClick" />

    在 DateCol.vue 中,这样触发事件就可以了:

    <i class="el-icon-time" 
          @click="() => listeners['icon-click'](props.row.date)">
        </i>

    唯一需要留意的是,虽然以上写法足以应付大部分情况,但如果外部监听了多个同名事件,listeners 就会变为一个数组;所以相对完备的一种封装方法是:

    /**
     * 用于函数式组件的事件触发方法
     * @param {object} listeners - context 中的 listeners 对象
     * @param {string} eventName - 事件名
     * @param {...any} args - 若干参数
     * @returns {void} - 无
     */
    export const fEmit = (listeners, eventName, ...args) => {
        const cbk = listeners[eventName]
        if (_.isFunction(cbk)) cbk.apply(null, args)
        else if (_.isArray(cbk)) cbk.forEach(f => f.apply(null, args))
    }

    filter

    在 h 函数或 jsx 的返回结构中,传统 Vue 模板中的 <label>{ title | withColon }</label> 过滤器语法不再奏效。

    好在原本定义的过滤函数也是普通的函数,所以等效的写法可以是:

    import filters from '@/filters';
     
    const { withColon } = filters;
     
    //...
     
    // render 返回的 jsx 中
    <label>{ withColon(title) }</label>

    插槽

    普通组件 template 部分中使用使用插槽的方法,在函数式组件 render 函数中,包括 jsx 模式下,都无法使用了。

    在前面例子中将姓名列重构为 NameCol.js 的时候,已经演示过了相对应的写法;再看一个 ElementUI 中骨架屏组件的例子,比如普通的 template 用法是这样的:

    <el-skeleton :loading="skeLoading">
        real text
        <template slot="template">
          <p>loading content</p>
        </template>
    </el-skeleton>

    这里面实际就涉及了 default 和 template 两个插槽,换到函数式组件 render 函数中,对应的写法为:

    export default {
      functional: true,
      props: ['ok'],
      render(h, {props}) {
        return h('el-skeleton' ,{
          props: {loading: props.ok},
          scopedSlots: {
            default: () => 'real text',
            template: () => h('p', null, ['loading context'])
          }
        }, null)
      }
    }

    如果遇到 v-bind:user="user" 这样传递了属性的作用域插槽,那么将 user 作为插槽函数的入参就可以了。

    官网文档中还提及了 slots()children 的对比:

    <my-functional-component>
      <p v-slot:foo>
        first
      </p>
      <p>second</p>
    </my-functional-component>
     
    对于这个组件,children 会给你两个段落标签,而 slots().default 只会传递第二个匿名段落标签,slots().foo 会传递第一个具名段落标签。同时拥有 children 和 slots(),因此你可以选择让组件感知某个插槽机制,还是简单地通过传递 children,移交给其它组件去处理。

    provide / inject

    除了文档中提到的 injections 用法,还要注意 Vue 2 中的 provide / inject 终究是非响应式的。

    如果评估后非要使用这种方式,可以试试 vue-reactive-provide

    HTML 内容

    Vue 中的 jsx 无法支持普通组件 template 中 v-html 的写法,对应的元素属性是 domPropsInnerHTML,如:

    <strong class={type} domPropsInnerHTML={formatValue(item, type)} />

    而在 render 写法中,又将这个单词拆分了一下,写法为:

    h('p', {
          domProps: {
                innerHTML: '<h2>hello</h2>'
          }
    })

    无论如何写起来都确实费劲了不少,但值得庆幸的是总比 React 中的 dangerouslySetInnerHTML 好记一些。

    样式

    如果你采用了纯 .js/.ts 的组件,可能唯一的麻烦就是无法再享受 .vue 组件中 scoped 的样式了;参考 React 的情况,无非是以下几种方法解决:

    • import 外部样式并采用 BEM 等命名约定

    • 在 vue-loader 选项中开启 CSS Modules 并在组件中应用 styleMod.foo 的形式

    • 模块内动态构建 style 数组或对象,赋值给属性

    • 采用工具方法动态构建样式 class:

    const _insertCSS = css => {
        let $head = document.head || document.getElementsByTagName('head')[0];
        const style = document.createElement('style');
        style.setAttribute('type', 'text/css');
        if (style.styleSheet) {
            style.styleSheet.cssText = css;
        } else {
            style.appendChild(document.createTextNode(css));
        }
        $head.appendChild(style);
        $head = null;
    };

    TypeScript

    无论是 React 还是 Vue,本身都提供了一些验证 props 类型的手段。但这些方法一来配置上都稍显麻烦,二来对于轻巧的函数式组件都有点过“重”了。

    TypeScript 作为一种强类型的 JavaScript 超集,可以被用来更精确的定义和检查 props 的类型、使用更简便,在 VSCode 或其他支持 Vetur 的开发工具中的自动提示也更友好。

    要将 Vue 函数式组件和 TS 结合起来的话,正如 interface RenderContext<Props> 定义的那样,对于外部输入的 props,可以使用一个自定义的 TypeScript 接口声明其结构,如:

    interface IProps {
     year: string;
     quarters: Array<'Q1' | 'Q2' | 'Q3' | 'Q4'>;
     note: {
      content: string;
      auther: stiring;
     }
    }

    而后指定该接口为 RenderContext 的首个泛型:

    import Vue, { CreateElement, RenderContext } from 'vue';
     
    ...
     
    export default Vue.extend({
      functional: true,
      render: (h: CreateElement, context: RenderContext<IProps>) => {
         console.log(context.props.year);
       //...
      }
    });

    结合 composition-api

    与 React Hooks 类似的设计目的很相似的是,Vue Composition API 也在一定程度上为函数式组件带来了响应式特征、onMounted 等生命周期式的概念和管理副作用的方法。

    这里只探讨 composition-api 特有的一种写法 -- 在 setup() 入口函数中返回 render 函数:

    比如定义一个 counter.js:

    import { h, ref } from "@vue/composition-api";
     
    export default {
      model: {
        prop: "value",
        event: "zouni"
      },
      props: {
        value: {
          type: Number,
          default: 0
        }
      },
      setup(props, { emit }) {
        const counter = ref(props.value);
        const increment = () => {
          emit("zouni", ++counter.value);
        };
     
        return () =>
          h("div", null, [h("button", { on: { click: increment } }, ["plus"])]);
      }
    };

    在容器页面中:

    <el-input v-model="cValue" />
    <counter v-model="cValue" />

    Vue.js函数式组件是什么

    如果要再结合 TypeScript 来用,改动只有:

    • import { defineComponent } from "@vue/composition-api";

    • export default defineComponent<IProps>({ 组件 })

    单元测试

    如果使用了 TypeScript 的强类型加持,组件内外的参数类型就有了较好的保障。

    而对于组件逻辑上,仍需要通过单元测试完成安全脚手架的搭建。同时,由于函数式组件一般相对简单,测试编写起来也不麻烦。

    在实践中,由于 FC 与普通组件的区别,还是有些小问题需要注意:

    re-render

    由于函数式组件只依赖其传入 props 的变化才会触发一次渲染,所以在测试用例中只靠 nextTick() 是无法获得更新后的状态的,需要设法手动触发其重新渲染

    it("批量全选", async () => {
        let result = mockData;
        // 此处实际上模拟了每次靠外部传入的 props 更新组件的过程
        // wrapper.setProps() cannot be called on a functional component
        const update = async () => {
          makeWrapper(
            {
              value: result
            },
            {
              listeners: {
                change: m => (result = m)
              }
            }
          );
          await localVue.nextTick();
        };
        await update();
        expect(wrapper.findAll("input")).toHaveLength(6);
     
        wrapper.find("tr.whole label").trigger("click");
        await update();
        expect(wrapper.findAll("input:checked")).toHaveLength(6);
     
        wrapper.find("tr.whole label").trigger("click");
        await update();
        expect(wrapper.findAll("input:checked")).toHaveLength(0);
     
        wrapper.find("tr.whole label").trigger("click");
        await update();
        wrapper.find("tbody>tr:nth-child(3)>td:nth-child(2)>ul>li:nth-child(4)>label").trigger("click");
        await update();
        expect(wrapper.find("tr.whole label input:checked").exists()).toBeFalsy();
      });

    多个根节点

    函数式组件的一个好处是可以返回一个元素数组,相当于在 render() 中返回了多个根节点(multiple root nodes)。

    这时候如果直接用 shallowMount 等方式在测试中加载组件,会出现报错:

    [Vue warn]: Multiple root nodes returned from render function. Render function should return a single root node.

    解决方式是封装一个包装组件

    import { mount } from '@vue/test-utils'
    import Cell from '@/components/Cell'
     
    const WrappedCell = {
      components: { Cell },
      template: `
        <div>
          <Cell v-bind="$attrs" v-on="$listeners" />
        </div>
      `
    }
     
    const wrapper = mount(WrappedCell, {
      propsData: {
        cellData: {
          category: 'foo',
          description: 'bar'
        }
      }
    });
     
    describe('Cell.vue', () => {
      it('should output two tds with category and description', () => {
        expect(wrapper.findAll('td')).toHaveLength(2);
        expect(wrapper.findAll('td').at(0).text()).toBe('foo');
        expect(wrapper.findAll('td').at(1).text()).toBe('bar');
      });
    });

    fragment 组件

    另一个可用到 FC 的小技巧是,对于一些引用了 vue-fragment (一般也是用来解决多节点问题)的普通组件,在其单元测试中可以封装一个函数式组件 stub 掉 fragment 组件,从而减少依赖、方便测试:

    let wrapper = null;
    const makeWrapper = (props = null, opts = null) => {
      wrapper = mount(Comp, {
        localVue,
        propsData: {
          ...props
        },
        stubs: {
          Fragment: {
            functional: true,
            render(h, { slots }) {
              return h("div", slots().default);
            }
          }
        },
        attachedToDocument: true,
        sync: false,
        ...opts
      });
    };

    Vue 3 中的函数式组件

    这部分内容基本和我们之前在 composition-api 中的实践是一致的,大致提取一下新官网文档中的说法吧:

    真正的函数组件

    在 Vue 3 中,所有的函数式组件都是用普通函数创建的。换句话说,不需要定义 { functional: true } 组件选项

    它们将接收两个参数:propscontextcontext 参数是一个对象,包含组件的 attrsslotsemit property。

    此外,h 现在是全局导入的,而不是在 render 函数中隐式提供:

    import { h } from 'vue'
     
    const DynamicHeading = (props, context) => {
      return h(`h${props.level}`, context.attrs, context.slots)
    }
     
    DynamicHeading.props = ['level']
     
    export default DynamicHeading

    单文件组件

    在 3.x 中,有状态组件和函数式组件之间的性能差异已经大大减少,并且在大多数用例中是微不足道的。因此,在单文件组件上使用 functional 的开发者的迁移路径是删除该 attribute,并props 的所有引用重命名为 $props,以及将 attrs 重命名为 $attrs:

    <template>
      <component
        v-bind:is="`h${$props.level}`"
        v-bind="$attrs"
      />
    </template>
     
    <script>
    export default {
      props: ['level']
    }
    </script>

    主要的区别在于:

    1. <template> 中移除 functional attribute

    2. listeners 现在作为 $attrs 的一部分传递,可以将其删除

    以上是“Vue.js函数式组件是什么”这篇文章的所有内容,感谢各位的阅读!相信大家都有了一定的了解,希望分享的内容对大家有所帮助,如果还想学习更多知识,欢迎关注亿速云行业资讯频道!

    向AI问一下细节

    免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

    AI