前端组件化

目录

  1. 前端组件化

  2. Vue传参

  3. 高级组件

前端组件化

在了解模块化、组件化之前,最好先了解一下什么是高内聚,低耦合。它能更好的帮助你理解模块化、组件化。

高内聚,低耦合

高内聚,就是指一个函数尽量只做一件事。低耦合,就是两个模块之间的关联程度低。

仅看文字可能不太好理解,下面来看一个简单的示例。

坏代码

// test.vue
<template>
	<div>
		<button @click="clickBtn('add')">点击add</button>
		<button @click="clickBtn('mul')">点击mul</button>
	</div>
</template>
<script>
export default {
  data() {
    return {
      a: 0,
      b: 0
    }
  },
  methods: {
    add() {
      return this.a + this.b
    },
    mul() {
      const sum = this.add()
      return sum * this.b
    },
    clickBtn(action) {
      this.a = 1 // 模拟a取值
      this.b = 2 // 模拟b取值
      if(action === 'add') {
        return this.add()
      }
      else if(action === 'mul') {
        return this.mul()
      }
    }
  }
}
</script>

好代码

// math.js
export function add(a, b) {
    return a + b
}

export function mul(a, b) {
    return a * b
}
// test.vue
<template>
	<div>
		<button @click="clickBtn('add')">点击add</button>
		<button @click="clickBtn('mul')">点击mul</button>
	</div>
</template>
<script>
import { add, mul } from 'math'
export default {
  data() {
    return {
      a: 0,
      b: 0
    }
  },
  methods: {
    clickBtn(action) {
      this.a = 1 // 模拟a取值
      this.b = 2 // 模拟b取值
      if(action === 'add') {
        return add(this.a, this.b)
      }
      else if(action === 'mul') {
        const sum = add(this.a, this.b)
        return mul(sum, this.b)
      }
    }
  }
}
</script>

上面的 math.js 就是高内聚,低耦合的典型示例。add()mul() 一个函数只做一件事,它们之间也没有直接联系。如果要将这两个函数联系在一起,也只能通过传参和返回值来实现。

真实业务代码

【测算业务代码举例】

模块化、组件化

模块化

模块化,就是把一个个文件看成一个模块,它们之间作用域相互隔离,互不干扰。一个模块就是一个功能,它们可以被多次复用。另外,模块化的设计也体现了分治的思想。即把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

从前端方面来看,单独的 JavaScript 文件、CSS 文件都算是一个模块。

例如一个 math.js 文件,它就是一个数学模块,包含了和数学运算相关的函数:

// math.js
export function add(a, b) {
    return a + b
}

export function mul(a, b) {
    return a * b
}

export function abs() { ... }
...

一个 button.css 文件,包含了按钮相关的样式:

/* 按钮样式 */
button {
    ...
}

组件化

那什么是组件化呢?我们可以认为组件就是页面里的 UI 组件,一个页面可以由很多组件构成。例如一个后台管理系统页面,可能包含了 HeaderSidebarMain 等各种组件。一个组件又包含了 template(html)scriptstyle 三部分,其中 scriptstyle 可以由一个或多个模块组成。

目前三大框架在构建工具(例如 webpack、vite...)的配合下都可以很好的实现组件化。例如 Vue,使用 *.vue 文件就可以把 templatescriptstyle 写在一起,一个 *.vue 文件就是一个组件。

一个页面可以分解成一个个组件,每个组件又可以分解成一个个模块,充分体现了分治的思想。

最理想的情况就是一个页面元素全部由组件构成,这样前端只需要写一些交互逻辑代码。虽然这种情况很难完全实现,但我们要尽量往这个方向上去做,争取实现全面组件化。

问题:如果不使用框架和构建工具,还能实现组件化吗?

Web Components

组件化是前端未来的发展方向,Web Componentsopen in new window 就是浏览器原生支持的组件化标准。使用 Web Components API,浏览器可以在不引入第三方代码的情况下实现组件化。

demo入口open in new window

Vue传参

vue2.x父子组件通信可以用:

  • props

  • $emit / v-on

  • $attrs / $listeners

  • ref

  • .sync

  • v-model

  • $children / $parent

兄弟组件通信可以用:

  • EventBus

  • Vuex

  • $parent

跨层级组件通信可以用:

  • provide/inject

  • EventBus

  • Vuex

  • $attrs / $listeners

  • $root

一、props 传参

子组件定义 props 有三种方式:

// 第一种数组方式
props: [xxx, xxx, xxx]
// 第二种对象方式
props: { xxx: Number, xxx: String}
// 第三种对象嵌套对象方式
props: {
    xxx: {
        //类型不匹配会警告
        type: Number,
        default: 0,
        required: true,
        // 返回值不是 true,会警告
        validator(val) { return val === 10}
    }
}

第三种对象默认支持 4 种属性,并且都是非必填的。可以随意使用

父组件传参的俩种方式

第一种静态属性传参

注意:

  1. 在不定义 props 类型的情况下 props 接受到的均为 String。

  2. 当 props 属性指定为 Boolean 时,并且只有属性 key 没有值 value 时接受到的是 true

<!--props 接受到的均为 String -->
<children xxx="123"></children>

<!-- 有只有属性没有值, 这种情况 props 指定类型是 Boolean 则接收到的是 true -->
<children xxx></children>

第二种动态属性传参

注意:

  1. 需要区分非简写形式传入的值是对象,则会对应 props 中多个值

  2. 会保留传入值的类型

  3. 如果是表达式则获取到的是表达式的计算结果

<!-- prop 接收到 Number 类型的 123-->
<children :xxx="123"></children>

<!-- prop 接收到 Array 类型的 [1, 2, 3]-->
<children v-bind:xxx="[1, 2, 3]"></children>

<!-- prop 会接收到 xxx1 和 xxx2 俩个参数。这种不支持简写形式-->
<children v-bind="{xxx1: 1, xxx2: 2}"></children>

二、attrs和listeners

**$attrs **会获取到 props 中未定义的属性(class 和 style 属性除外),支持响应式。常用的场景有俩种:

  1. 组件嵌套组件时可以使用 $attrs 来支持过多的属性支持。比如 elementUI 的 table 组件。支持的属性十几个,而平常封装的时候用的最多的也就一俩个。

  2. 属性默认是添加在父组件上的,有时候想把多余的属性添加在子组件上(可以结合 inheritAttrs: false 属性,让父属性不接受多余的属性)

**$listeners **定义的事件都在子组件的根元素上,有时候想加到其他元素上。就可以使用 $listerners。它包含了父组件中的事件监听器(除了带有 .native 修饰符的监听器)

三、$emit 通知

Vue 默认有 $on $emit $once $off 几种方法来实现发布订阅模式,这也应用在了组件传参上。在组件上添加的特殊方法 @abc="methods" 就相当于使用了 $on 来监听这个方法。因此组件内可以使用 $emit 来进行通知。

问题**: for 循环的时候如何拿到子组件的传值和 for 中循环的值**

答案有俩种,一是 $event, 二是 闭包。只是需要注意 $event 只能获取到第一个值

<template v-for="item in [1, 2, 3]">
    <children @abc="((val, val2) => getValue(val, item))"></children>
</template>

四、v-model

可以实现将父组件传给子组件的数据为双向绑定,子组件通过 $emit 修改父组件的数据

// Parent.vue
<template>
    <child v-model="value"></child>
</template>
<script>
export default {
    data(){
        return {
            value:1
        }
    }
}

// Child.vue
<template>
    <input :value="value" @input="handlerChange">
</template>
export default {
    props:["value"],
    // 可以修改事件名,默认为 input
    model:{
        event:"updateValue"
    },
    methods:{
        handlerChange(e){
            this.$emit("input", e.target.value)
            // 如果有上面的重命名就是这样
            this.$emit("updateValue", e.target.value)
        }
    }
}
</script>

五、插槽

<template>
    <div>
        <!--默认插槽-->
        <slot></slot>
        <!--另一种默认插槽的写法-->
        <slot name="default"></slot>
        <!--具名插槽-->
        <slot name="footer"></slot>
        <!--传参插槽-->
        <slot v-bind:user="user" name="header"></slot>
    </div>
</template>

<!--使用-->
<children>
    <!--跑到默认插槽中去-->
    <div>123</div>
    <template v-slot:default></template>
    <!--跑到具名插槽 footer 中去-->
    <template v-slot:footer></template>
    <template #footer></template>
    <!--获取子组件的值-->
    <template v-slot:header="slot">{{slot.user}}</template>
    <!--结构插槽值-->
    <template v-slot:header="{user: person}">{{person}}</template>
    <!--老式写法,可以写到具体的标签上面-->
    <template slot="footer" slot-scope="scope"></template>
</children>

六、$refs, $root, $parent, $children

  1. $root 获取根组件

  2. $parent 获取父组件

  3. $children 获取子组件(所有的子组件,不保证顺序)

  4. $refs 组件获取组件实例,元素获取元素

七、provide / inject

注意:注入的值是非响应的

<!--父组件 提供-->
{
    provide() {
        return {
            parent: this
        }
    }
}
<!--子组件 注入-->
{
    // 写法一
    inject: ['parent']
    // 写法二
    inject: { parent: 'parent' }
    // 写法三
    inject: {
        parent: {
            from: 'parent',
            default: 222
        }
    }
}

八、Vuex

这个相当于单独维护的一组数据,就不过多的说了。

扩展内容

vue3组件通信方式

  • props

  • $emit

  • expose / ref

  • $attrs

  • v-model

  • provide / inject

  • Vuex

  • mitt

高级(阶)组件

例子

本文就以平常开发中最常见的需求,也就是异步数据的请求为例,先来个普通玩家的写法:

<template>
    <div v-if="error">failed to load</div>
    <div v-else-if="loading">loading...</div>
    <div v-else>hello {{result.name}}!</div>
</template>

<script>
export default {
  data() {
    return {
        result: {
          name: '',
        },
        loading: false,
        error: false,
    },
  },
  async created() {
      try {
        // 管理loading
        this.loading = true
        // 取数据
        const data = await this.$axios('/api/user')  
        this.data = data
      } catch (e) {
        // 管理error
        this.error = true  
      } finally {
        // 管理loading
        this.loading = false
      }
  },
}
</script>

一般我们都这样写,平常也没感觉有啥问题,但是其实我们每次在写异步请求的时候都要有 loadingerror 状态,都需要有 取数据 的逻辑,并且要管理这些状态。

那么想个办法抽象它?React 社区在 Hook 流行之前,经常用 HOC(high order component) 也就是高阶组件来处理这样的抽象。

高阶组件是什么?

说到这里,我们就要思考一下高阶组件到底是什么概念,其实说到底,高阶组件就是:

一个函数接受一个组件为参数,返回一个包装后的组件

在 React 中

在 React 里,组件是 Class,所以高阶组件有时候会用 装饰器 语法来实现,因为 装饰器 的本质也是接受一个 Class 返回一个新的 Class。在 React 的世界里,高阶组件就是 f(Class) -> 新的Class

在 Vue 中

在 Vue 的世界里,组件是一个对象,所以高阶组件就是一个函数接受一个对象,返回一个新的包装好的对象。

类比到 Vue 的世界里,高阶组件就是 f(object) -> 新的object

智能组件和木偶组件

这是 React 社区里一个很成熟的概念。木偶 组件: 就像一个牵线木偶一样,只根据外部传入的 props 去渲染相应的视图,而不管这个数据是从哪里来的。智能 组件: 一般包在 木偶 组件的外部,通过请求等方式获取到数据,传入给 木偶 组件,控制它的渲染。

一般来说,它们的结构关系是这样的:

<智能组件>
  <木偶组件 />
</智能组件>

它们还有另一个别名,就是 容器组件ui组件

实现

具体到上面这个例子中,我们的思路是这样的,

  1. 高阶组件接受 木偶组件请求的方法 作为参数

  2. mounted 生命周期中请求到数据

  3. 把请求的数据通过 props 传递给 木偶组件

接下来就实现这个思路,HOC 是个函数,本次我们的需求是实现请求管理的 HOC,那么先定义它接受两个参数,我们把这个 HOC 叫做 withPromise。并且 loadingerror 等状态,还有 加载中加载错误 等对应的视图,我们都要在 新返回的包装组件 ,也就是下面的函数中 return 的那个新的对象 中定义好。

const withPromise = (component, request) => {
  return {
    name: "with-promise",
    data() {
      return {
        loading: false,
        error: false,
        result: null,
      };
    },
    async mounted() {
      this.loading = true;
      const result = await request().finally(() => {
        this.loading = false;
      });
      this.result = result;
    },
  };
};

在参数中:

  1. component 也就是需要被包裹的组件对象。

  2. request 也就是请求对应的函数,需要返回一个 Promise

看起来不错了,但是函数里我们好像不能像在 .vue 单文件里去书写 template 那样书写模板了,但是我们又知道模板最终还是被编译成组件对象上的 render 函数,那我们就直接写这个 render 函数。(注意,本例子是因为便于演示才使用的原始语法,脚手架创建的项目可以直接用 jsx 语法。)在这个 render 函数中,我们把传入的 component 也就是木偶组件给包裹起来。这样就形成了 智能组件获取数据 -> 木偶组件消费数据,这样的数据流动了。

const withPromise = (component, request) => {
  return {
    data() { 
	  return {
        loading: false,
        error: false,
        result: null,
      };
    },
    async mounted() {
      this.loading = true;
      const result = await request().finally(() => {
        this.loading = false;
      });
      this.result = result;
    },
    render(h) {
      return h(component, {
        props: {
          result: this.result,
          loading: this.loading,
        },
      });
    },
  };
};

到了这一步,已经是一个勉强可用的雏形了,我们来声明一下 木偶 组件。这其实是 逻辑和视图分离 的一种思路(这里的view可以是任意 .vue 文件)

const view = {
  template: `
    <span>
      <span>{{result?.name}}</span>
    </span>
  `,
  props: ["result"],
};

封装高阶组件src/components/hoc/index.js:

import view from './demo.vue'
import { hoc } from './hoc'

// 假装这是一个 axios 请求函数
const request = (data) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(data)
      // reject(new Error('请求出错了'))
    }, 1000)
  })
}

export default hoc(view, request)

然后在父组件中渲染它:

<div id="app">
  <Hoc />
</div>

<script>
import Hoc from './components/hoc/index'

 new Vue({
    el: 'app',
    components: {
      Hoc
    }
 })
</script>

为了让交互更友好点,再加上 加载中加载失败 视图。

const hoc = (component, request) => {
  return {
    data() { ... },
    async mounted() { ... },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
        },
      };

      const wrapper = h("div", [
        h(component, args),
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
      ]);

      return wrapper;
    },
  };
};

完善

到此为止的高阶组件虽然可以演示,但是并不是完整的,它还缺少一些功能,比如

  1. 要拿到子组件上定义的参数,作为初始化发送请求的参数。

  2. 要监听子组件中请求参数的变化,并且重新发送请求。

  3. 外部组件传递给 hoc 组件的参数现在没有递传下去。

为了实现第一点,我们约定好 view 组件上需要挂载某个特定 key 的字段作为请求参数,比如这里我们约定它叫做 requestParams

为了实现第二点,子组件的请求参数发生变化时,父组件也要响应式的重新发送请求,并且把新数据带给子组件。我们只要在渲染子组件的时候把 $attrs$listeners$scopedSlots 传递下去即可,以这个例子来说:

<my-input value="ssh" @change="onChange" />

组件内部就能拿到这样的结构:

{
  $attrs: {
    value: 'ssh'
  },
  $listeners: {
    change: onChange
  }
}

完整Demo

参考资料

Vue3的8种和Vue2的12种组件通信,值得收藏open in new window

https://www.cnblogs.com/liuyuweb/p/13820427.htmlopen in new window

视频录像

前端组件化open in new window

Vue传参和高级组件open in new window