【建议追更】以模块化的思想来搭建中后台项目

2,430 阅读11分钟

前言

管理系统我们几乎都写过,我第一个Vue项目就是做的后台管理系统, 从开始认为很难到做成了之后觉得很简单,到现在众多优秀的开源项目可以直接拿过来用,我就想着做一个电子书共享系统,在做的过程中尽可能的用模块化的思想来完成每个部分,也算是检验一下自己的成长吧。

项目初始化

该项目是基于vue-element-admin开发的中后台管理系统,整个管理系统用的都是他们搭建好的作为项目框架

git clone https://github.com/PanJiaChen/vue-element-admin.git xxx
cd xxx
npm i
npm run dev

启动登录成功之后就可以继续对代码进行简化了,删除一些不必要的文件。

  • 删除 src/views 下的源码,保留:
    • dashboard:首页
    • error-page:异常页面
    • login:登录
    • redirect:重定向
  • 对 src/router/index 进行相应修改:删除了哪些组件就把动态加载路由配置也删掉
  • 删除 src/router/modules 文件夹
  • 删除 src/vendor 文件夹
  • 删除不必要的文件和文件夹

注⚠:componetnts文件夹先不删,等后面项目上线再去删掉用不到的组件

项目配置

如果不想配置可以直接down我的仓库下来,每一步我都提交了历史版本

通过 src/settings.js 进行全局配置:

  • title:站点标题,进入某个页面后,格式为:
页面标题 - 站点标题
  • showSettings:是否显示右侧悬浮配置按钮
  • tagsView:是否显示页面标签功能条
  • fixedHeader:是否将头部布局固定
  • sidebarLogo:菜单栏中是否显示LOGO
  • errorLog:默认显示错误日志的环境

源码调试

如果需要进行源码调试,需要修改 vue.config.js(环境vue-cli4)

在开发环境常用的配置项:

  • eval:打包快,但它映射的是已转码的代码而不是原始代码(没有来自加载器的源映射),因此无法正确显示行号
  • eval-source-map:最初它很慢,但是重建速度很快,并且可以生成真实文件。行号已正确映射
  • source-map:能够直接在浏览器中看到整个src的源代码,非常利于调式,但生产环境一定不要用

vue.config.js

productionSourceMap: process.env.NODE_ENV === 'development',(修改)

configureWebpack: {
devtool: process.env.NODE_ENV === 'development' ? 'source-map' : undefined, (修改)
   
    name: name,
    resolve: {
      alias: {
        '@': resolve('src')
      }
    }
  },

修改上面两处就ok

效果图💗

这样调式是不是说就爽多了??

项目结构

文件大意

build [打包配置文件]
mock [mock数据:这里暂时不要去删掉这个文件夹]
node_modules [第三方包]
public [根文件]
src [源码:一般配置@指向src]
editorconfig [编辑器配置文件]
.env.devlopment [开发环境配置文件]
.env.production [生产环境配置文件]
.env.test [测试环境配置文件]
.eslintignore [eslint忽略校验配置文件]
.eslintrc.js [eslint校验配置文件]
.gitignore [git提交忽略配置文件]
babel.config.js [babel配置文件]
jsoncifg.json  [js文件个性化配置文件:比如vsocde取消对mode_modules扫描/配置允许跳转'@/xxx'这种路径]
package.json [第三方包文件信息]
postcss.config.js [对css扩展的文件: 添加css兼容前缀等等]
REAMDE.md [项目文档]
vue.config.js [vue-cli配置文件]

注⚠:上面的配置文件也并不是都需要去配置,但这些项目中算是比较常见的配置文件,有些不必要的配置文件可自行删除,建议保留

项目结构[源码]

src 源码目录
    |-- api 所有 api 接口
    |-- assets 静态资源,images, icons, styles 等
    |-- components 公用组件
    |-- directives 自定义指令
    |-- filters 过滤器,全局工具
    |-- icons 图标组件
    |-- layout:布局组件
    |-- router 路由,统一管理
    |-- store vuex, 统一管理
    |-- styles:自定义样式
    |-- utils:通用工具方法
    |-- views 视图目录
    | |-- role role 模块名
    | |-- |-- role-list.vue role 列表页面
    |  |-- |-- role-add.vue           role新建页面
    |  |-- |-- role-update.vue         role更新页面
    |  |-- |-- components           role模块通用组件文件夹
    |-- permission.js 登录认证和路由跳转
    |-- settings.js:全局配置

项目常用库

常用UI框架

  • element-ui 饿了么开发的一套配合框架使用的ui框架
  • Ant Design 蚂蚁金服一套企业级ui框架
  • Vant 有赞技术团队开发的轻量、可靠的移动端 Vue 组件库
  • Cube-UI 滴滴团队移动端组件库
  • NutUI 京东团队:一套京东风格的移动端组件库

babel-plugin-component 插件按需加载配置插件(按需加载适用于上面的所有UI框架)

常用JS库

注⚠:还有很多js库, 但其实上面这些很多js库提供的功能ui库都已集成,而且我们也不需要每个库都去学,我们只要会玩gitHub就可以了,项目需要的时候看下文档你就能很轻易上手,所以我们也并不是需要什么都去学,当你项目有这个需求的时候再去了解完全可以,学习这些库、框架并不是一件很难的事。

封装自己的Storage

在梳理完整个项目框架的时候发现作者并没有封装一个通过的stroage,我们这里封装一个通用的Stroage对当前项目需要存储数据进行一个统一管理,将所有数据都放在'book'模块下

整体的思路就是将所有跟这个项目有关的sotarge封装在一个模块下,这样不会显得很乱。

像上图这种看着就会很乱。

  • 直接获取整个storage
  • 获取/设置指定模块的key
  • 清除指定/所有的模块
/**
 * @info 封装Storage
 */

const GLOBAL_MODULE_NAME = 'book';
let cacheStorage = {}


class Storage {
  constructor(type) {
    this.type = type;
  }

  // 获取整个模块的storage
  getStorage() {
    return JSON.parse(window[this.type].getItem(GLOBAL_MODULE_NAME)) || {}
  }

  // 设置
  setItem(key, value, moduleName) {
    if (moduleName) {
      let val = this.getItem(moduleName)
      val[key] = value;
      this.setItem(moduleName, val)
    } else {
      let val = this.getStorage()
      val[key] = value
      window[this.type].setItem(GLOBAL_MODULE_NAME, JSON.stringify(val))
    }
  }

  // 获取
  getItem(key, moduleName) {
    if (JSON.stringify(cacheStorage) === '{}') {
      cacheStorage = this.getStorage()
    }

    if (moduleName) {
      let val = cacheStorage[moduleName]
      if (val) return val[key]
    }
    return cacheStorage[key]
  }

  // 删除
  removeItem(key, moduleName) {
    let val = this.getStorage();
    if (moduleName) {
      delete val[moduleName][key]
    } else {
      delete val[key]
    }
    window[this.type].setItem(GLOBAL_MODULE_NAME, JSON.stringify(val))
  }
}

export default Storage

接下来在main.js中使用

Vue.prototype.sessionStorage = new Storage('sessionStorage')
Vue.prototype.localStorage = new Storage('localStorage')

login页面测试一下

let userInfo = {
	name: "ks"
};
this.localStorage.setItem('userInfo', userInfo)
this.localStorage.setItem('age', 18, 'userInfo')

登录模块的改造

效果图💗

在线卑微,如果觉得这篇文章对你有帮助的话欢迎大家点个赞👻

常见场景二次封装组件

在日常项目开发中我们不太会写重复场景的代码,就拿登录来讲吧:登录模块肯定必须有用户名、密码。可能还会有其他的一些表单项,那我们可能就会封装一个通用的表单组件,下次直接用引入这个组件,写下配置项就可以直接展示出效果了。

简单例子⚡

如果我们某个表单需要九个表单项,这个时候就能很好的体现出组件封装的好处了。

不好的

<el-input
  type="text"
  v-model="Value1"
  maxlength="130"
  minlength="120"
  :placeholder="用户名"
  :clearable="false"
  :disabled="false"
  :required="true"
  @focus="handleMyFocus"
  @blur="handleMyBlur"
  @input="handleModelInput"
  @clear='handleMyClear'
>
</el-input>

<el-input
  type="text"
  v-model="Value2"
  maxlength="130"
  minlength="120"
  :placeholder="密码"
  :clearable="true"
  :disabled="true"
  :required="true"
  @focus="handleMyFocus"
  @blur="handleMyBlur"
  @input="handleModelInput"
  @clear='handleMyClear'
>
</el-input>

.... x7

好的

template

<template>
  <div class="my-input">
    <el-input
      :type="type"
      v-model="curValue"
      :maxlength="maxlength"
      :minlength="minlength"
      :placeholder="fillPlaceHolder"
      :clearable="clearable"
      :disabled="disabled"
      :autofocus="autofocus"
      :required="required"
      @focus="handleMyFocus"
      @blur="handleMyBlur"
      @input="handleModelInput"
      @clear='handleMyClear'
    >
    </el-input>
  </div>
</template>

script

export default {
  name: 'MyInput',
  props: {
    type: {
      validator(val) {
        return (
          ['text', 'number', 'tel', 'password', 'email', 'url'].indexOf(val) !==-1
        );
      },
    },
    value: {
      required: true,
      type: [Number, String],
    },
  ... 参数验证....
  }
  data() {
    return {
      curValue: this.value,
      focus: false,
      fillPlaceHolder: '',
    };
  },
  methods: {
    handleMyFocus(event) {
      this.focus = true;
      this.$emit('focus', event)
      if (this.placeholder && this.placeholder !== '') {
        this.fillPlaceHolder = this.placeholder;
      }
    },
   ... 像上面的方式一样像外面抛出事件
};
</script>

使用

<div v-for="(item, index) in inputList" :key="index">
  <label>....</label>
 <my-input 
  	type="email"
  	:value="item.val"
	/>
</div>

<script>
export default {
  data() {
  	return {
     inputList: [
  			{type: 'text', val: ''},
  			{type: 'eamil', val: '', ....}
			]                                     
    }
  }
}
</script>

解析dynamic-form⚡

先从配置项入手

<dynamic-form
  labelWidth="0px"
  :formConfig="formConfig"
  v-model="loginForm"
  ref="loginForm"
  :showBtn="false"
>
	<slot name="slot-box">
		好的公共组件一定要极具可扩展性, 插槽是一个很好的解决方案
	<slot>
<dynamic-form>

dynamic-form

先大致的看下组件内部的结构

template部分

<el-form :model="value" :ref="refName" :label-width="labelWidth" ...>
  <el-row :gutter="space" :span="24" class="form-item">
    <el-col
			 v-show="formItemHidden(item.hidden)"
        :md="item.span || itemSpanDefault"
        :xs="xsSpan"
        :offset="item.offset || 0"
    >
			<dynamic-form-item ......>
       </dynamic-form-item>    
		</el-col>
   <el-row :gutter="space">
    	<el-col :span="24">
        <div class="slot-box">
          <slot></slot>
        </div>
      </el-col>               
  </el-row>
  <el-row :gutter="space">
      <el-col :span="24">
        <el-form-item v-if="showBtn && !disabled" class="form-bottom-box">
          <el-button type="primary" @click="submitForm">提交</el-button>
          <el-button @click="cancelForm">取消</el-button>
        </el-form-item>
      </el-col>
      <!-- 改造部分 -->
      <el-form-item v-else class="form-bottom-box">
        <slot name="formBottom"><slot>
      </el-form-item>
    </el-row>                  
</el-form>

上面是大体的结构,整体看下来,大致了解到:

  • el-row/el-col: 作为每个表单项的外层,做了个简单的项响应式
  • dynamic-form-item:说明这个表单内部引入了自己封装的表单项(后面再来看)
  • slot-box:组件内部提供了插槽
  • 最后的表单按钮配置项只是简单做了个是否有按钮的配置不是很理想(我们稍微改造了下,如果没有按钮一般是查看表单的状态,可能表单下面会有提示语之类的)

script部分

我们进行分类看,先看死的(props、data、filters、components...),再开看活的(watch、computed、钩子函数...)。

先看死的是为了让你先了解下组件提供的数据,好在分析方法的时候大致了解组件有的数据及数据类型:如props、data,另外注意一下组件内有没有mixin混入的代码。

import { deepClone } from "@/utils"; // 用来深克隆对象

props/data

props: {
  refName: { // 可以给组件内部使用的el-form自定义ref(引用)名称
    type: String,
    default: "dynamicForm"
  },
  formConfig: { // 表单配置项(重要)
    type: Object,
    required: true
  },
  disabled: Boolean, // 是否禁用
  value: { // 数据(重要)
    type: Object,
    required: true
  },
  labelWidth: { // 表单域标签的宽度
    type: String,
    default: "80px"
  },
  showBtn: {
    type: Boolean, // 是否显示按钮
    default: true
  },
  space: {
    type: Number, // 表单
    default: 20
  },
  columnMinWidth: {
    type: String
  }
},
data() {
 	return {
   	xsSpan: 24,
    itemSpanDefault: 24,
    options: [], // 提取后的配置项
    cascadeMap: new Map(),
    hasInitCascadeProps: [],
    allDisabled: false,                  
 	};
}

上面那些配置项比较重要的配置项就是这两个:formConfig、value都是对象类型且必传

provide/watch

provide() {
   return {
     formThis: this
   };
 },
 watch: {
   formConfig: {
     handler() {
       this.initConfig();
     },
     deep: true, // 深度监听,formConfig对象下的每个成员都将响应式
     immediate: true // 刚上来就出发一下监听方法hanlder
   }
 },

provide这个有些小伙伴可能会感到陌生,我仔细说明下。

它属性于组件通信一种方式:允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效

意思是就是provide可以允许你向你子孙组件注入数据进行,让你的后代们都可以共享你这个数据。

举个例子⚡

假设有三个组件: A.vue 、B.vue、C.vue,B 是 A 的子组件、C是B的子组件、那C就是A的子孙组件

// A.vue
export default {
  provide: {
    formThis: this // 直接把Vue实例注入进去
  }
  data() {
  	return {
    	obj: {name: '北歌'}
    }
  }
}

inject获取祖先注入的依赖,提供两种方式,一般用第二种方式

// B.vue
export default {
  inject: ['formThis'],
  mounted () {
    console.log(this.formThis);  // 祖先组件的实例对象,父组件data里面的数据你都能访问到了
  }
}

// C.vue
export default {
  inject: {
    formThis: {default: {}} // 祖先组件的实例对象,如果没有就默认是个空对象防止程序出错
  },, 
  mounted () {
    console.log(this.formThis.obj.name);  // => '北歌'
  }
}

methods

大致的看下提供了几个方法

  • uploadSuccess 上传相关
  • itemClass 定义样式相关
  • initConfig 初始化配置项
  • handleInput 操作表单项触发
  • changeSelect 更改select触发
  • handlerCascade 看名字看不出来啥,后面用到了再看内部实现
  • initCascadeOptions
  • setDefaultValue 设置默认值
  • submitForm 提交表单
  • resetForm 重置表单
  • cancelForm 取消表单
  • handleSubmit 操作按钮
  • formItemHidden 表单项隐藏

好,现在我们大致了解到了组件内部能够提供的一些功能,现在开始看看怎么实现。

源码第一步

先找到源码中“活”的东西,一定要记住:看源码一定不是从头看到尾,把源码所有涉及到的点全都看一遍,这种方式是不推荐的,太浪费时间了,很多时候我们只要仔细阅读我们想要学习的某个实现功能点就行了

比如这个表单组件一些边缘点:

  • deepClone克隆方法
  • dynamic-form-item组件内的 formUpload 上传组件

这些都暂时不是我们想要知道的点,但是dynamic-form-item内部的实现我会仔细讲解

大致下来发现dynamic-form这个组件是没有钩子函数的,那唯一活的就是前面说过的watch侦听的formConfig这个是活的,那我们就拿它入手。

watch
watch: {
  formConfig: {
    handler() {
      this.initConfig(); // 调用了这个方法
    },
    deep: true,
    immediate: true
  }
},

配置项

单独取个标题没啥意思,就是为了分析源码时,对着配置项数据好分析源码,也方便跳转

formConfig: {
  formItemList: [
    {
      key: "username",
      type: "input",
      icon: "user",
      placeholder: "请输入用户名",
      rules: [
        { required: true, trigger: "blur", message: "请输入正确用户名" },
        { required: true, trigger: "blur", validator: validatePassword }
      ]
    }
  ]

initConfig方法
initConfig() {
  if (this.formConfig.allDisabled) { // 没有配置
    this.allDisabled = true;
  }
  this.formConfig.formItemList.forEach((item, index) => {
    if (item.hasOwnProperty("cascade")) { // 没有配置
      const ob = {
        index: index,
        url: item.url,
        key: item.key,
        multiple: item.multiple
      };
      this.cascadeMap.set(item.cascade, ob);
    }
  });
  this.options = deepClone(this.formConfig.formItemList); 
  this.setDefaultValue();
},

从上面这个方法了解到可以配置全局禁用表单项,表单项可以配置cascade, 这个我们没用到,暂时不看。

setDefaultValue
setDefaultValue() {
  const formData = { ...this.value }; // value -> v-model绑定的数据
  // 设置默认值
  this.options.forEach(item => { // options就是配置项 -> formConfig
    const { key, value } = item;
    if (formData[key] === undefined || formData[key] === null) {
      formData[key] = value; // 没有设置key,就把值当做key
    }
    if (formData[key]) { // 有key了之后就开始初始化了
      this.initCascadeOptions(formData[key], key); // 先传值,再传key
    }
  });
  this.$emit("input", { ...formData }); 
},

从上面这个方法了解到组件内部抛出了一个input事件,我们可以获取到所有的数据 === loginForm

initCascadeOptions
initCascadeOptions(val, key) {
  // 不进这个判断,从我们没有配置‘cascade’属性开始,cascadeMap说没有set成员
  if (this.cascadeMap.has(key)) { 
    const obj = this.cascadeMap.get(key);
    if (this.hasInitCascadeProps.includes(obj.key)) return;
    if (val) {
      const object = deepClone(this.formConfig.formItemList[obj.index]);
      Object.keys(object.params).forEach(key => {
        if (!object.params[key]) {
          object.params[key] = val;
        }
      });
      this.$set(this.options, obj.index, object);
      this.hasInitCascadeProps.push(obj.key);
    }
  }
},

方法到此执行结束,可以开始构建结构,渲染页面。

<el-form ....>
  <el-row ...>
    <el-col
      v-for="item in options"
      :key="item.key"
      v-show="formItemHidden(item.hidden)"
      :md="item.span || itemSpanDefault"
      :xs="xsSpan"
      :offset="item.offset || 0"
    >
    	<dynamic-form-item
        class="item"
        :allDisabled="allDisabled"  false
        :ref="item.key" input
        :item="item" 当前配置项:是一个对象类型,绑定对象,说明里面肯定会监听
        :value="value[item.key]" loginForm['username']
        :disabled="disabled" 可以配置单个表单的禁用
        :style="{'min-width': columnMinWidth }" 可以给表单设置label文字的宽度
        @input="handleInput($event, item.key)" 
        @changeSelect="changeSelect"
        @uploadSuccess="uploadSuccess" 这个几个方法毋庸置疑,肯定是继续向上抛事件
      ></dynamic-form-item>                             
    </el-col>
  </el-row>
</el-form>

死数据就可以先不看,还是先看“活”的,渲染到el-col,触发了formItemHidden方法,这个其实不用看就知道,表单项可以配置hidden属性来控制是否显示和隐藏

再分析dynamic-form-item这个组件前,先来分析组件几个亮点

  • ref="item.key":可以对每个表单进行配置引用, 名字就是键名
  • :item="item" 把每个表单项绑定进去,组件内部又可以基于配置项进行相关限定

分析源码我们知道了组件:

  • 可以配置单个表单的禁用
  • 可以单独给某个表单设置labelWidth
  • 可以监听:changeSelect、uploadSuccess、handleInput

解析dynamic-form-item⚡

前面可以说都是在为这个组件铺路,现在算是迎来了源码的核心部分

template部分

<el-forn-item
  :class="{ 'is-look': disabled}"
 :label="item.label"
 :prop="item.key"
 :rules="item.rules"
>
  <span v-if="item.icon" class="svg-container">
    <svg-icon :icon-class="item.icon" />
  </span>
  <el-input
   v-if="
     ['input', 'text', 'password', 'email', 'textarea', 'number'].includes(
       item.type
     ) 
   "
   v-bind="$attrs"
   :type="item.subtype || item.type"
   :class="{'all-disabled': allDisabled}"
   v-on="$listeners"
   resize="none"
   :autosize="disabled ? true : item.autoSize || { minRows: 4, maxRows: 6 }"
   :placeholder="!isDisabled ? item.placeholder : ''"
   :disabled="isDisabled"
   @focus="handleFocusEvent(item.key)"
  >
  </el-input>
  <el-radio-group  v-else-if="item.type === 'radio'"></el-radio-group>
  <el-checkbox-grou v-else-if="item.type === 'checkbox'"></el-checkbox-group>
  <el-select v-else-if="item.type === 'select'"></el-select >
  <el-tree-select v-else-if="item.type === 'select'"></tree-select>
  <el-switch  v-else-if="item.type === 'switch'"></el-switch>
  <el-time-picker  v-else-if="item.type === 'time'"></el-time-picker>
  <el-date-picker  v-else-if="item.type === 'date'"></el-date-picker>
  <form-upload  v-else-if="item.type === 'file'"></form-upload>
  <dynamic-data-box v-else-if="item.type === 'databox'"></dynamic-data-box>
  <template  v-else-if="item.type === 'slot'">
 		<slot :name="item.key" :data="$attrs.value"></slot>
  </template>
</el-forn-item>

整体看下来组件支持很多表单类型,不过内部肯定是做了额外处理,引用了其他二次封装组件,可以说这个组件能够覆盖大部分的使用表单的业务场景。

  • 单选/多选
  • 下拉/树形下拉
  • 开关控件
  • 时间/日期
  • 文件上传
  • 动态表单
  • 最后提供了表单项的插槽

这里我们没有用到太多的类型,所以我就解析下input表单,简单看下上面的源码知道

  • 如果表单禁用了那就会有个禁用的样式
  • 允许表单配置单独的校验规则,可以不写在el-form的rules里面
  • 允许配置icon
  • input表单支持所有type类型

亮点部分⭐

<el-input
    v-bind="$attrs" // 将该组件的所有属性绑定进去
    :class="{ 'all-disabled': allDisabled }" // 全局禁用后叠加样式
    v-on="$listeners" // 将组件所有的事件都直接抛给父组件监听
    :disabled="isDisabled" // 可设单独禁止项
    @focus="handleFocusEvent(item.key)" 监听原生事件
  >
</el-input>

$attrs/$listeners

这里又用到另外一种通信方式:$attrs/$listeners,也是用来跨组件之间的通信。这里仔细给大家讲解下

  • $attrs:包含了父作用域中不被 prop 所识别 (且获取) 的特性绑定 (class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件。通常配合 interitAttrs 选项一起使用。
  • $listeners:包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件

例子

场景是A组件引用了B组件,B组件又引用了C组件,C组件里面所有的抛出的事件,B组件都可以通过$listeners交给A组件监听,且A组件(父组件)里面所有的属性B组件(子组件)都可以$attrs获取到,但如果你已经用props接过父组件传递过来的数据,$attrs这个对象里面就不会有这个属性了。

A组件

<template>
 <div class="A">
   我是A
   <B 
    :foo="foo"
    :coo="coo"
    :eee="eee"
    @upHot="upHot"
    @blurHandle="blurHanlde"
   >
   </B>
 </div>
</template>
<script>
 import B from './B'
 export default {
   name:'A',
   data() {
     return {
      foo:"北歌",
      coo:"前端自学驿站",
		  eee:"加微信itbeige: 回复加群,加入这个全栈项目交流群"
    }
  },
 components:{B},
 methods:{
   upHot(){
      console.log("如果觉得文章不错,请点个赞噢!")
   },
   blurHanlde() {
     console.log('谢谢各位支持!');
   }
 }
}
</script>

B组件

<template>
 <div class="B">
  <div class="title">我是B</div>
 <p>foo:{{foo}}</p>
 <p>attrs:{{$attrs}}</p>
 <C v-bind="$attrs" v-on="$listeners"></C>
 </div>
</template>
<script>
import C from './C';
export default {
 name:'B',
 inheritAttrs:false,
 components: {C},
 props:["foo"],
 data() {
   return {
     BAttr: '123'
   }
 },
 methods: {
   blurHandle() {
     console.log('B组件监听到了');
   }
 },
 mounted() {
   console.log(this.$attrs);
 }
}
</script>

C组件

<template>
  <div class="C">
    <div class="title">我是C</div>
    <p>coo:{{ coo }}</p>
    <button @click="TopUp">我要上热门</button>
    <input @blur="myBlur"/>
  </div>
</template>
<script>
export default {
  name: "childDomChild",
  props: ["coo"],
  methods: {
    TopUp() {
      this.$emit("upHot");
      console.log("恭喜你上热门了!");
    },
    myBlur() {
      this.$emit("blurHandle");
		  console.log(this.$attrs) // {eee: "加微信itbeige: 回复加群,加入这个全栈项目交流群"}
    }
  }
};
</script>

效果图💗

从上面的效果我们知道:

  • A组件因为B组件v-on=$listener将所有C组件的事件抛给父组件,所以A监听到了C组件所有的事件
  • B组件通过$attrs获取到了父组件所有属性(被props接受过的除外)
  • C组件又通过B组件v-bind:$attrs将attrs对象不被B组件用props接受过的属性传递给C

注意⚠:

  • B组件已经通过$listener将所有C组件抛给的事件交给父组件处理了,但是他还是可以监听C组件抛出的事件的

修改B组件

 <C v-bind="$attrs" v-on="$listeners" @blurHandle="blurHandle"></C>

methods: {
   blurHandle() {
     console.log('B组件监听到了');
   }
 },

再补充一个点:A组件使用B组件的时候,获取B组件传递参数的同时还需要传递自己的参数,这个场景应该是挺多的了

实例: A组件使用B组件,B组件通过抛出自定义事件向父组件传参,A组件监听自定义事件同时,传递自己的参数。

A.vue

<template>
	<B
  	@UpClick="clickHandle($event, 1})"
  />
</template>

methods: {
   clickHandle(ev, myVal) {
   	 console.log(ev, myVal)
   }
 },

B.vue

<button @click="TopUp">我要上热门</button>
methods: {
   blurHandle(ev) {
   	 let params = {
   	 		ev,
   	 		data: {testVal: 1} // B组件想父组件传递的参数
   	 }	
     this.$emit('UpClick', params)
   }
 },

我们继续来解析dynamic-form-item这个组件的源码部分

script部分

我们进行分类看,先看死的(props、inject、data、filters、components...),再开看活的(watch、computed、钩子函数...)。

import { initData } from "@/api/data";
import TreeSelect from "@/components/tree-select/index.vue";
import FormUpload from "@/components/form-upload"
import { login } from '@/api/user';

components: { TreeSelect, FormUpload },

上面引入的的这些都是对表单的一些扩展,不过出了login这个方法,其余的我们应该都用不到。

先把使用dynamic-form-item组件传递的属性项copy下来。

<dynamic-form-item
  class="item"
  :allDisabled="allDisabled"  false
  :ref="item.key" input
  :item="item" 当前配置项:是一个对象类型,绑定对象,说明里面肯定会监听
  :value="value[item.key]" loginForm['username']
  :disabled="disabled" 可以配置单个表单的禁用
  :style="{'min-width': columnMinWidth }" 可以给表单设置label文字的最小宽宽度
  @input="handleInput($event, item.key)" 
  @changeSelect="changeSelect"
  @uploadSuccess="uploadSuccess" 这个几个方法毋庸置疑,肯定是继续向上抛事件
>
</dynamic-form-item>         

props/inject/data

props: {
  item: {
    type: Object,
    required: true
  },
  allDisabled: Boolean,
  disabled: Boolean
},
inject: {
  formThis: { default: {} } // 从祖先组件中获取注入的依赖
},
data() {
  return {
    attachmentData: [], // 这个我们用不到
    asyncOptions: []
  };
},

对照上面传递的属性来看,父组件传递过来的子组件用props接受了

  • item
  • allDisabled
  • disabled

$attrs属性对象就只剩下

  • ref
  • value

注意⚠:前面 说过style/class这类属性$attrs是不会存在的。

created/watch/computed

先看钩子函数created

created() {
	if (!this.item.hasOwnProperty("cascade")) this.getAsyncOptions();
},

很明显我们是没有给表单项目配置这个属性的,方法执行。

看到这里相信大家也知道了也开始疑惑cascade这个配置项目是干什么的:这个等我们解析到后面再来来看。

getAsyncOptions方法
/**
 * 根据url获取option
 */
getAsyncOptions() {
  const { optionsUrl, dicType, params } = this.item;
  let data = params;
  if (dicType) {
    data = Object.assign({ dicType: dicType }, data);
  }
  if (optionsUrl) {
    initData(optionsUrl, data)
      .then(res => {
        if (res.code === 200) {
          this.asyncOptions = res.data.content || res.data;
        }
      })
      .catch(err => {
        this.$message.error(err.message);
      });
  }
},

上面的这些属性我们表单项都没有配置,这里给大家看下item里面的属性

不过看上面代码我们知道了,原来表单项还可以通过传递url来动态获取数据,就像是element-ui这种效果一样

走到这代码就断了,看下watcht和computed里面的东西

computed: {
  isRange() { // 范围 这个我们没有设定
    return this.subtype === "timerange";
  },
  isDisabled() { // 这个就是表单项禁止
    return this.item.disabled || this.disabled;
  }
}

当我们看到watch的时候,就应该要知道,当前属性就一定是响应式的。

 watch: {
  "item.params": { 
    handler(val, oldVal) {
      if (val === undefined && oldVal === undefined) { // 值为空的情况不做处理
        return;
      }
      if (!this.isObjectEqual(val, oldVal)) { // 看名字应该是判断值是否为对象的方法
        if (this.item.hasOwnProperty("cascade")) {
          this.getAsyncCascadeOptions(val);
        }
      }
    },
    deep: true,
    immediate: true
  }
},

这个可以从上面 getAsyncOptions方法可以知道params是配置表单获取服务器数据的一些参数,可以带大家来了解一下这个方法的内部实现

下面解析下这两个方法

  • isObjectEqual
  • getAsyncCascadeOptions

走到这里现在相信大家到知道cascade这个配置项目是干嘛的吧,配置这个Input表单项是否是动态表单,后面我们随着getAsyncCascadeOptions这个方法来一起看下内部的实现

isObjectEqual
  • obj1: item.params的新值
  • obj2: item.params的旧值
isObjectEqual(obj1, obj2) {
  const o1 = obj1 instanceof Object;
  const o2 = obj2 instanceof Object;
  if (!o1 || !o2) {
    // 如果不是对象 直接判断数据是否相等
    return obj1 === obj2;
  }
  // 判断对象的可枚举属性组成的数组长度
  if (Object.keys(obj1).length !== Object.keys(obj2).length) {
    return false;
  }
  for (const attr in obj1) {
    const a1 =
      Object.prototype.toString.call(obj1[attr]) === "[object Object]";
    const a2 =
      Object.prototype.toString.call(obj2[attr]) === "[object Object]";
    const arr1 =
      Object.prototype.toString.call(obj1[attr]) === "[object Array]";
    if (a1 && a2) {
      // 如果是对象继续判断
      return this.isObjectEqual(obj1[attr], obj2[attr]);
    } else if (arr1) {
      // 如果是对象 判断
      if (obj1[attr].toString() !== obj2[attr].toString()) {
        return false;
      }
    } else if (obj1[attr] !== obj2[attr]) {
      // 不是对象的就判断数值是否相等
      return false;
    }
  }
  return true;
}

乍一看有点吓人😮,好在都写了注释,我们跟着注释来解析下这个方法

先看条件判断部分

const o1 = obj1 instanceof Object; 
const o2 = obj2 instanceof Object;
if (!o1 || !o2) { 
  // 如果不是对象 直接判断数据是否相等
  return obj1 === obj2;
}
// 判断对象的可枚举属性组成的数组长度
if (Object.keys(obj1).length !== Object.keys(obj2).length) {
  return false;
}

从上面注释了解到也就是做了个对params属性值动态变化后做了个简单的判断,方法的实现也有些漏洞

if (!o1 && !o2) { 
  // 要两者都不是对象再来比较值,你如果有一方是对象,再怎么去比较值也不可能一样
  return obj1 === obj2;
}
// 没有考虑到原型污染的风险
if (Reflect.ownKeys(obj1).length !== Reflect.ownKeys(obj2).length) {
  return false;
}

循环判断对象值的逻辑可以抽离成方法

let isType = (type) => (obj) => Object.prototype.toString.call(obj) === `[object ${type.slice(0, 1).toUpperCase() + type.slice(1)}]`
let isObject = isType('Object')
let isArray = isType('Array')

function compareObjVal(obj1, obj2) {
  const o1 = isObject(obj1)
  const o2 = isObject(obj2)

  if (!o1 && !o2) { 
    // 要两者都不是对象再来比较值,你如果有一方是对象,再怎么去比较值也不可能一样
    return obj1 === obj2;
  }

  // 没有考虑到原型污染的风险
  if (Reflect.ownKeys(obj1).length !== Reflect.ownKeys(obj2).length) {
    return false;
  }
  
  for (const attr in obj1) {
    if (!obj1.hasOwnProperty(attr)) return;
    const a1 = isObject(obj1[attr]) 
    const a2 = isObject(obj2[attr])
    const arr1 = isArray(obj1)
    
    // 如果是对象继续判断
    if (a1 && a2) return compareObjVal(obj1[attr], obj2[attr]);
                      
    //  如果是数组判断
    if (arr1) return obj1[attr].toString() === obj2[attr].toString()
                      
    // 不是对象的就判断数值是否相等
    if (obj1[attr] !== obj2[attr]) return false;

    return true;
  }
}

重写之后可以自测一下

let a = {
  user: {
    name: 'Beige',
    msg: '欢迎大家加入前端自学驿站互相交流群'
  },
  savoir: [1, 2, 3, 4]
};

let b = {
  user: {
    name: '微信号',
    age: 'itbeige'
  },
 savoir: [1, 2, 3, 4]
}

let c = {
  user: {
    name: 'Beige',
    msg: '欢迎大家加入前端自学驿站互相交流群'
  },
  savoir: [1, 2, 3, 4]
};

compareObjVal(a, b) // false
compareObjVal(a, C) // true
getAsyncCascadeOptions
getAsyncCascadeOptions(params) {
const { optionsUrl } = this.item;
  if (optionsUrl) {
    initData(optionsUrl, params)
      .then(res => {
        if (res.code === 200) {
          this.asyncOptions = res.data.content || res.data;
        }
      })
      .catch(err => {
        this.$message.error(err.message);
      });
  }
}

从这个方法我们就了解到了,如果要配置动态input,需要传递

  • cascade
  • optionsUrl
  • params
  • dicType(可选)

到这,dynamic-form-item这个组件就看的差不多了,对于扩展的form-upload, tree-select..这些二次封装组件,感兴趣的小伙伴可以去down我的仓库源码下来自行研究。

最后梳理下通过阅读别人的源码我们学到的东西

  • 通过配置项的方式封装常见场景的模块
  • provide/inject 应用场景
  • $attrs/$listener应用场景,这种方式完全可以实现双向绑定的效果
  • 监听子组件参数同时传递自己参数解决方案

感谢大家能够读都到这里,读源码是是件很需要静下心来才能做的一件事,最后送给大家一句我在某文档看到的一句话:遇事耐心,不急不躁,虽然这不是成功的唯一要素,但它是你技术路上长远走下去的基础。

对了,欢迎大家加入这个项目的交流群,我们一起成长! weChat: itbeige

写在最后

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下

我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。

往期文章

【前端体系】从一道面试题谈谈对EventLoop的理解 (更新了四道进阶题的解析)

【前端体系】从地基开始打造一座万丈高楼

【前端体系】正则在开发中的应用场景可不只是规则校验

「函数式编程的实用场景 | 掘金技术征文-双节特别篇

【建议收藏】css晦涩难懂的点都在这啦