设计模式(1):只执行一次的函数

概述

最近最近做项目的时候总会思考一些大的应用设计模式相关的问题,我把自己的思考记录下来,供以后开发时参考,相信对其他人也有用。

只执行一次的函数

我们经常会遇到这种情况,就是希望某个函数只执行一次,以后就不执行了。一般情况下,我们会这么写:

<script>
export default {
  data() {
    return {
      runOnce: true,
    };
  },
  methods: {
    func() {
      console.log('hello world', this);
    },
    funcRunOnce() {
      if (this.runOnce) {
        this.func();
        this.runOnce = false;
      }
    },
  },
};
</script>

但是这样并不优雅,不仅污染了data,还用2个方法进行实现,实在难看。

用闭包改进

于是我们考虑用闭包,把data里面的runOnce这个变量放到闭包里面去,这样就不会污染data了。代码如下:

<script>
export default {
  methods: {
    func() {
      console.log('hello world', this);
    },
    funcRunOnce(params) {
      let runOnce = true;
      return () => {
        if (runOnce) {
          this.func();
          runOnce = false;
        }
      }();
    },
  },
};
</script>

但是这么写显然是错了,因为每次调用funcRunOnce都会构造一次闭包,里面的runOnce这个变量根本不会共享。所以继续改写如下:

// 方法1
<script>
export default {
  created() {
    this.funcRunOnce = this.runOnce(this.func);
  },
  methods: {
    func() {
      console.log('hello world', this);
    },
    runOnce(func) {
      let runOnce = true;
      return (params) => {
        if (runOnce) {
          func(params);
          runOnce = false;
        }
      };
    },
  },
};
</script>

// 方法2
<script>
export default {
  methods: {
    func() {
      console.log('hello world', this);
    },
    runOnce(func) {
      let runOnce = true;
      return (params) => {
        if (runOnce) {
          func(params);
          runOnce = false;
        }
      };
    },
    funcRunOnce: this.runOnce(this.func),
  },
};
</script>

使用utils

可以看到,上面的方法仍然很不优雅,要么用一个created和2个方法实现,要么用三个方法实现。而都用了一个公共的方法runOnce。所以我们考虑把runOnce放到utils.js里面去。

// utils.js
export function runOnce(func) {
  let runOnce = true;
  return (params) => {
    if (runOnce) {
      func(params);
      runOnce = false;
    }
  };
}

//example.vue
import { runOnce } from '@/utils';
<script>
export default {
  methods: {
    funcRunOnce: runOnce(() => {
      console.log('hello world', this);
    }),
  },
};
</script>

上面的写法看起来非常简洁,但是实际上是不行的,因为this的指向错了。由于runOnce返回的函数并不是vue实例的方法,所以里面的this指向的是undefined

注意:即使看起来我们好像在funcRunOnce方法中用箭头函数捕获了外面实例的this,但是实际上它捕获的并不是外面的实例的this,而是runOnce返回的函数里面的this。

捕获this

能用箭头函数的地方我们都用了,但是为什么我们还是捕获不了this呢?如此一来是不是完成不了这个任务了?

并不是,方法还是有的,方法是不用箭头函数捕获this。代码如下:

// utils.js
export function runOnce(func) {
  let runOnce = true;
  return function(params) {
    if (runOnce) {
      func.apply(this, params);
      runOnce = false;
    }
  };
}

//example.vue
import { runOnce } from '@/utils';
<script>
export default {
  methods: {
    funcRunOnce: runOnce(function h() {
      console.log('hello world', this);
    }),
  },
};
</script>

通过查看代码可以看出,2个地方的箭头函数都被改写成了function,并且还用到了apply函数来强制施加this。

理由很简单,由于runOnce函数里面没有用箭头函数,所以它返回的函数是属于vue实例的,所以它返回的函数的this,是指向vue实例的;又因为funcRunOnce里面没有用箭头函数,所以我们可以用apply把这个this强制附加到func里面去!

同理我们还可以写出第一次不执行,后续才执行的函数:

// utils.js
// 第一次不执行,后续再执行
export function notRunOnce(func) {
  let once = false;
  return function(params) {
    if (once) {
      func.apply(this, params);
    }
    once = true;
  };
}

学到了什么

  1. 在vue里面可以用赋值的形式初始化方法,或者在created里面初始化方法。
  2. 箭头函数虽然能捕获this,但不是万能的;有时候我们需要用function和apply结合来捕获this。


设计模式(2): 响应store中数据的变化

概述

最近最近做项目的时候总会思考一些大的应用设计模式相关的问题,我把自己的思考记录下来,供以后开发时参考,相信对其他人也有用。

store里面响应数据变化

通常情况下,我们会把数据存在store里面,并且,有时我们也需要跟踪store里面的数据变化,并作出响应。例子如下:

export default {
  computed: {
    categories: state => state.categories.categories,
  },
  watch: {
    categories() {
      this.fetchCardData();
    },
  },
  methods: {
    fetchCardData() {
      // 请求卡片数据
    },
  },
}

如上所示,当store里面的categories改变的时候,我们会自动调用api去请求数据

不响应store里面的数据变化

上面的例子里面,每次当categories改变的时候,fetchCardData方法都会被调用。有些时候,这并不是我们想要的,我们想要的是,当xxxx的时候,categories会改变,fetchCardData方法会跟着被调用;当xxxx的时候,categories会改变,fetchCardData方法又不会跟着被调用,怎么办呢?

方法是创造一个标记,但是如何优雅的创造标记呢?我有一个方法如下所示:

// store.js
const state = {
  categories: [],
  categoriesChanges: 0,
};

const actions = {
  updateCategories({ commit }, value) {
    // 如果带有shouldNotChange,则表示不要刷新页面
    if (value.shouldNotChange) {
      commit(types.UPDATE_CATEGORIES, value.data);
    } else {
      commit(types.UPDATE_CATEGORIES, value);
      commit(types.UPDATE_CATEGORIES_CHANGES);
    }
  },
};

const mutations = {
  [types.UPDATE_CATEGORIES](state, value) {
    state.categories = value;
  },
  [types.UPDATE_CATEGORIES_CHANGES](state) {
    state.categoriesChanges += 1;
  },
};

// component.js
export default {
  computed: {
    categories: state => state.categories.categories,
    categoriesChanges: state => state.categories.categoriesChanges,
  },
  watch: {
    categoriesChanges() {
      this.fetchCardData();
    },
  },
  methods: {
    fetchCardData() {
      // 利用this.categories的部分数据来请求卡片数据
    },
  },
}

// business.js
this.$store.dispatch('updateCategories', value); // 会自动调用fetchCardData方法

const payload = {
  shouldNotChange: true,
  data: [...value],
};
this.$store.dispatch('updateCategories', payload); // 不会自动调用fetchCardData方法

这样,我们发出同一个action,却能达到2种不同的效果,非常方便。


设计模式(3): 二次封装与高阶组件 

概述

最近最近做项目的时候总会思考一些大的应用设计模式相关的问题,我把自己的思考记录下来,供以后开发时参考,相信对其他人也有用。

二次封装组件

PM的需求无奇不有,所以很多时候,我们使用的组件满足不了PM的需求,怎么办呢?比如,组件需要传入一个数组,但是我们必须传2个变量;或者我们需要在组件focus的时候调用一个方法,但是组件并没有暴露focus事件等等。虽然都是些很简单的需求,但是组件就是没有暴露实现这些需求的方法。咋办?

方法是对组件进行二次封装

二次封装主要运用了vue的如下属性:

  • vm.attrs:包含了父作用域中不作为prop被识别(且获取)的特性绑定(class和style除外)。当一个组件没有声明任何prop时,这里会包含所有父作用域的绑定(class和style除外),并且可以通过v−bind="attrs:包含了父作用域中不作为prop被识别(且获取)的特性绑定(class和style除外)。当一个组件没有声明任何prop时,这里会包含所有父作用域的绑定(class和style除外),并且可以通过v−bind="attrs" 传入内部组件。

  • vm.$props: 当前组件接收到的 props 对象。

  • vm.listeners:包含了父作用域中的(不含.native修饰器的)v−on事件监听器。它可以通过v−on="listeners:包含了父作用域中的(不含.native修饰器的)v−on事件监听器。它可以通过v−on="listeners" 传入内部组件。

props可以拿到传给当前组件的所有props,props可以拿到传给当前组件的所有props,attrs可以拿到传给组件的所有非props的属性,$listeners可以拿到所有传给组件的事件监听器

例子

举个例子,比如说el-upload需要传一个数组,但是我们只能传2个变量;并且,我们需要在el-upload上传success的时候做点其它的事。封装的代码如下:

export default {
  name: 'YmUpload',
  props: {
    name: {
      type: String,
      default: '',
    },
    url: {
      type: String,
      default: '',
    },
    onSuccess: {
      type: Function,
      default: () => 1,
    },
  },
  data() {
    return {
      fileList: [],
    };
  },
  watch: {
    url() {
      this.init();
    },
  },
  computed: {
    uploadAttr() {
      return {
        ...this.$attrs,
        fileList: this.fileList,
        onSuccess: this.handleSuccess,
      };
    },
  },
  created() {
    this.init();
  },
  methods: {
    init() {
      // 组件初始化
      const payload = {
        name: this.name || this.url,
        url: this.url,
      };
      this.fileList = [payload];
    },
    handleSuccess(res, file, fileList) {
      // 做点其它的事
    },
  },
};


设计模式(4): 给组件实现单独的store 

概述

最近最近做项目的时候总会思考一些大的应用设计模式相关的问题,我把自己的思考记录下来,供以后开发时参考,相信对其他人也有用。

组件自身的store

我们在开发组件的时候,时常都有这种需求,就是希望给组件一个独立的store,这个store可能被用来储存数据,共享数据,还可以被用来对数据做一些处理,抽离核心代码等。

store的数据不共享

如果组件自身的store是每个实例独自拥有的并且不共享的话,我们可以直接用一个类来实现。

// store.js
export default class Store {
  constructor(data, config) {
    this.config = config;
    this.init(data);
  }

  init(data) {
    // 对数据做处理
  }

  // 其它方法
}

然后我们在组件中实例化这个store,然后挂载到data属性里面去:

<script>
import Store from './store';

export default {
  data() {
    return {
      store: [],
    };
  },
  methods: {
    initStore() {
      // 生成 options 和 config
      this.store = new Store(options, config);
    },
  },
};
</script>

store的数据需要共享 

如果store的数据需要共享,我们建议用动态挂载vuex的store的方法,示例如下: 

// store.js
const state = {
  data: [],
};

const getters = {};

const actions = {};

const mutations = {
  setData(state, value) {
    this.state.data = [...value];
  },
};

export default {
  state,
  getters,
  actions,
  mutations,
};

然后我们在注册这个组件的时候动态挂载这个store:

import Store from './store';

export default {
  install(Vue, options) {
    Vue.store.registerModule('xxx', store);
  },
};

最后我们就可以在组件中使用这个store的数据啦~~~ 


 设计模式(5): vue 不监听绑定的变量

概述

最近最近做项目的时候总会思考一些大的应用设计模式相关的问题,我把自己的思考记录下来,供以后开发时参考,相信对其他人也有用。

绑定变量

一般情况下,如果我们需要在组件中使用某个变量,会这么使用:

data() {
  return {
    myData: [],
  };
}

如果这个变量是外部变量,例如从外部文件引入的话,就会这么使用:

import { provinces } from '@/util/consts';

export default {
  data() {
    return {
      myData: provices,
    };
  },
}

问题 

但是如果这个变量是一个嵌套层级很深,数据量很大的对象的话,如果按照上面那样使用,vue 就会去遍历这个变量的所有属性,来监听这个变量的变化。非常的消耗性能,一个典型的例子是:

export default {
  data() {
    return {
      bannerBg: null,
    };
  },
  mounted() {
    this.loadScript('/js/three.min.js', () => {
      this.loadScript('/js/vanta.net.min.js', () => {
        this.bannerBg = window.VANTA.NET({
          el: '#bannerBg',
          color: 0x2197F3,
          backgroundColor: 0x071E31,
        });
      });
    });
  },
  beforeDestroy() {
    this.bannerBg.destroy();
  },
  methods: {
    loadScript(path, callback) {
      const script = document.createElement('script');
      script.src = path;
      script.language = 'JavaScript';
      script.onload = () => callback();
      document.body.appendChild(script);
    },
  },
}

上面的例子中,我们为了避免内存泄漏,在 beforeDestroy 生命周期里面进行回收,而为了获取回收的变量,我们把它绑定给了 this.bannerBg。

但是事实是,我们并不需要监听 this.bannerBg 这个变量,而这么绑定的结果是,这个 vue 组件在 mounted 的时候需要遍历 this.bannerBg 来增加 vue 的监听属性,非常消耗性能

解决方案

所以,我们建议不把 bannerBg 放到 data() 里面去监听,而是**直接绑定给 this **就行了。优化后的代码如下:

export default {
  mounted() {
    this.loadScript('/js/three.min.js', () => {
      this.loadScript('/js/vanta.net.min.js', () => {
        this.bannerBg = window.VANTA.NET({
          el: '#bannerBg',
          color: 0x2197F3,
          backgroundColor: 0x071E31,
        });
      });
    });
  },
  beforeDestroy() {
    this.bannerBg.destroy();
  },
  methods: {
    loadScript(path, callback) {
      const script = document.createElement('script');
      script.src = path;
      script.language = 'JavaScript';
      script.onload = () => callback();
      document.body.appendChild(script);
    },
  },
}

如果这个变量不是过程中生成的,而是初始化的时候生成的,我们建议在 data() 方法里面这么做:

import { provinces } from '@/util/consts';

export default {
  data() {
    this.myData = provices;

    return {
      // 移到上面去了
      // myData: provices,
    };
  },
}


设计模式(6): 数据抽象与业务封装 

概述

最近最近做项目的时候总会思考一些大的应用设计模式相关的问题,我把自己的思考记录下来,供以后开发时参考,相信对其他人也有用。

情景描述

我们在做项目的时候,经常会碰到各种各样的业务情景,然后为了实现这些需求,就不断地在 vue 单文件组件里面加代码来实现,最终业务越来越多单文件组件越来越大,非常难以维护。

解决方案

我们都知道,vue 是通过数据来处理视图的,所以很多业务可以抽象成只处理数据,然后这些业务可以再抽象成 class 来进行业务封装。

event-bus

举个例子来说,vuex 或者 redux 这些状态管理的库,就是用的这个思想,把数据层脱离出去,带来的好处是简化了组件之间的数据流动。它们的源码有些复杂,我们以 event-bus 来举例说明。

首先,我们可以自己实现一个 bus 类,这个类能够储存数据,还能够进行事件的分发与监听

import Vue from 'vue';
import Bus from 'xxxx';

Vue.prototype.$bus = new Bus();

然后,分别在组件 A 和 B 里面,我们可以监听事件和分发事件。 

// 组件A -- 监听事件
created() {
  this.$bus.on('xxxx', this.xxx);
},
beforeDestroy() {
  this.$bus.off('xxxx', this.xxx);
},

// 组件B -- 分发事件
methods: {
  xxxx() {
    this.$bus.emit('xxxx', this.xxx);
  }
}

这样,即使处于不同层级,组件 A 和 B 也能流畅的进行数据交互。

抽象方法

我们抽象一下实现方法,我们先把业务抽象为数据和对数据的操作,然后在组件之外实现一个 class,最后用这个 class 进行保存数据和业务处理

上面这个例子把这个 class 放在了 Vue 实例上面,可能没有那么明显,下面举一个把它放在单文件组件里面的例子。

cascader

这一段参考了 element-cascader 的实现。

比如说,我们要自己实现一个 cascader,要怎么做?

我们上面提到过,我们对 cascader 的操作其实就是对数据的操作,所以我们可以把整个数据抽象出来,然后给它加上选中的业务功能:

import { capitalize } from '@/utils/util';

export default class Node {
  constructor(data, parentNode) {
    this.parent = parentNode || null;

    this.initState(data);
    this.initChildren(data);
  }

  initState(data) {
    // 加上本身的属性
    for (let key in data) {
      if (key !== 'children') {
        this[key] = data[key];
      }
    }

    // 自定义属性
    this.isChecked = false;
    this.indeterminate = false;

    // 用于自动取消
    this.isCheckedCached = false;
    this.indeterminateCached = false;
  }

  initChildren(data) {
    this.children = (data.children || []).map(child => new Node(child, this));
  }

  setCheckState(isChecked) {
    const totalNum = this.children.length;
    const checkedNum = this.children.reduce((c, p) => {
      const num = p.isChecked ? 1 : (p.indeterminate ? 0.5 : 0);
      return c + num;
    }, 0);

    this.isChecked = isChecked;
    this.indeterminate = checkedNum !== totalNum && checkedNum > 0;
  }

  doCheck(isChecked) {
    this.broadcast('check', isChecked);
    this.setCheckState(isChecked);
    this.emit('check', isChecked);
  }

  broadcast(event, ...args) {
    const handlerName = `onParent${capitalize(event)}`;

    this.children.forEach(child => {
      if (child) {
        child.broadcast(event, ...args);
        child[handlerName] && child[handlerName](...args);
      }
    });
  }

  emit(event, ...args) {
    const { parent } = this;
    const handlerName = `onChild${capitalize(event)}`;

    if (parent) {
      parent[handlerName] && parent[handlerName](...args);
      parent.emit(event, ...args);
    }
  }

  onParentCheck(isChecked) {
    if (!this.disabled) {
      this.setCheckState(isChecked);
    }
  }

  onChildCheck() {
    const validChildren = this.children.filter(child => !child.disabled);
    const isChecked = validChildren.length
      ? validChildren.every(child => child.isChecked)
      : false;

    this.setCheckState(isChecked);
  }
}

上面实现的 class 封装了如下业务:

  1. 通过 initState 加入了各种自定义的状态,这个状态有了业务:选中状态,半选中状态和未选中状态
  2. 通过 setCheckState 实现了 点击 的业务。
  3. 通过 broadcast 和 emit 实现了 父子组件联动 的业务。

当然,实际情形可能比这个更加复杂,我们只需要在上面的代码中加入各种状态和处理方法即可。

更进一步

上面封装的底层的业务,再高一层,我们可能有 搜索、自动选中 等业务,这个时候要怎么办呢?

方法是在 Node 类和单文件组件之间再封装一层,来实现这些业务,示例代码如下:

export default class Store {
  constructor(data) {
    this.nodes = data.map(nodeData => new Node(nodeData));
  }

  // 自动选中
  autoSelect(query, label) {

  }

  // 搜索
  search(searchString) {

  }
}

然后我们可以在单文件组件里面直接使用它

data() {
  return {
    store: null;
  };
},
watch: {
  data(newVal) {
    this.store = new Store(newVal);
  }
},

 推荐阅读:

学习Web前端 自学宝典

【uni-app】uniapp项目优化方式及建议

前端跨域设置 withCredentials: true

Logo

前往低代码交流专区

更多推荐