子君

子君 查看完整档案

西安编辑哈佛大学  |  计算机 编辑前端有的玩  |  前端工程师 编辑 github.com/snowzijun 编辑
编辑

微信公众号: 前端有得玩
微信账号:snowzijun
github仓库: https://github.com/snowzijun
寄语:不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。

个人动态

子君 发布了文章 · 9月21日

面试遇坎,每日一题我精选了这些题目的答案

薪资太低欲辞职,面试做题心甚难。

屡屡面试屡遇坎,每日一题快来看。

每日工作之余,我会将自己整理的一些前端面试题笔试题整理成每日一题,然后在公众号中推送给大家,每天仅需几分钟做一道题,经过日积月累,在换工作的时候一定能让你拿到一个比较好的offer。今天这篇文章是我将近期每日一题中比较好的题目及粉丝们分享的一些答案进行的整理,分享给更多的掘友,希望可以帮助到你。同时关注公众号【前端有的玩】,每天早上八点四十分,准时推送每日一题。

题目一

题目

现在有小编每个月老婆给的零花钱清单,但是因为某些原因,有些月份没有零花钱,如下数据所示


// 一月,二月, 五月的零花钱

{1:200, 2:140, 5:400}

请将上面的数据格式转换为[200, 140, null, null, 400, null, null, null, null, null, null, null],其中数组的长度为12,对应十二个月,请完善下面代码


const obj = { 1: 200, 2: 140, 5: 400 };

function translate(obj) {

// 请在此处添加代码

}

// 输出 [200, 140, null, null, 400, null, null, null, null, null, null, null]

console.log(translate(obj));

答案

这道题答案可以有许多中,以下罗列了几个群友贡献的答案,为您提供参考

答案一

const obj = { 1: 200, 2: 140, 5: 400 };

function translate(obj) {

return Array.from({ length: 12 }).map((_, index) => obj[index + 1] || null);

}

// 输出 [200, 140, null, null, 400, null, null, null, null, null, null, null]

console.log(translate(obj));
答案二

const obj = { 1: 200, 2: 140, 5: 400 };

function translate(obj) {

return Object.assign(Array(13).fill(null), obj).slice(1);

}

// 输出 [200, 140, null, null, 400, null, null, null, null, null, null, null]

console.log(translate(obj));
答案三

const obj = { 1: 200, 2: 140, 5: 400 };

function translate(obj) {

// 请在此处添加代码

let result = Array(12).fill(null)

Object.entries(obj).forEach(([key, value]) => {

result[key - 1] = value;

});

return result;

}

// 输出 [200, 140, null, null, 400, null, null, null, null, null, null, null]

console.log(translate(obj));

题目二

题目

请输出1400之间所有数字中包含的1的个数,比如数字1中包含了一个1, 数字11中包含了两个1,数字20中不包含1,数字121中共包含了131


function getCount() {

}

// 输出 180

console.log(getCount())

答案

答案一

这个答案比较经典,性能也算是很不错的了


const sum1s = num => {

let numstr

if (!num) return 0

if (typeof num === 'string') numstr = num

else numstr = String(num)

if (Number(numstr) === 0) return 0

const curr =

numstr[0] > 1

? 10 ** (numstr.length - 1) +

numstr[0] * (numstr.length - 1) * 10 ** (numstr.length - 2)

: sum1s(10 ** (numstr.length - 1) - 1) + 1

return curr + sum1s(numstr.substr(1))

}

// 输出 180

console.log(sum1s(400))
答案二

这个用到了正则,不过对于长字符串正则可能性能会有点点差


function countOne(num){

// num为正整数,方法有点儿暴力

return Array.from({length:num},(v,i)=>i+1).join('').replace(/[^1]/g,'').length

}

console.log(countOne(400))
答案三

下面这个答案算是中规中矩的答案了,将每一个数字转换为字符串然后统计1的个数


function getCount() {

let count = 0

for(let i=1;i<400;i++) {

count = count + `${i}`.split('1').length - 1

}

return count

}

// 输出 180

console.log(getCount())

题目三

垂帘画阁画帘垂,谁系怀思怀系谁?影弄花枝花弄影,丝牵柳线柳牵丝。这是一首回文诗,即每一句诗正向反向读都是一样的。下面这道题是一道回文数字,即数字正向反向读都是一样的,比如11,1221,2112等等。

题目

请打印出1 - 10000 之间的所有回文数字。其中1~9因为只有一位数字,所以不算回文数字。

答案

答案一

const palindrome = length => {

const res = []

const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

const add = (current, length) => {

if (length <= 1) return

digits.forEach(digit => {

res.push(digit + current + digit)

add(digit + current + digit, length - 2)

})

}

digits.forEach(num => {

add(num, length - 1)

res.push(num + num)

add(num + num, length - 2)

})

return res.filter(num => !num.startsWith('0'))

}

// 总共189个

console.log(palindrome(4))
答案二

function palindrome (max) {

return Array(max + 1).fill('').reduce((a, c, i) => {

if (i > 10) {

const arr = Array.from(`${i}`)

const [x, y] = [`${i}`, arr.reverse().join('')]

x === y && a.push(i)

}

return a

}, [])

}

// 总共189个

console.log(palindrome(10000))
答案三

const result = [...Array(10000).keys()].filter((x) => x> 10 && x === Number(x.toString().split('').reverse().join('')) )

console.log(result)

题目四

题目

请实现下面代码中的函数fn,使其可以输出指定id对应的所有父id及其自身id


const data = [

{

id: 1,

name: '222',

children: [{

id: 2,

name: '34',

children: [{

id: 112,

name: '334',

}, {

id: 113,

name: '354',

}

]

}]

}

]

function fn(id) {

}

// 输出 [1, 2, 112]

console.log(fn(112))

答案

答案一

const data = [

{

id: 1,

name: '222',

children: [{

id: 2,

name: '34',

children: [{

id: 112,

name: '334',

}, {

id: 113,

name: '354',

}

]

}]

}

]

function fn(id) {

const res = []

const find = _ => {

if (!_) return

return _.find(item => (item.id === id || find(item.children)) && res.push(item.id))

}

find(data)

return res.reverse()

}

console.log(fn(112))
答案二

const fn = (id, ancestors = [], current = data) => {

for (let i = 0; i < current.length; i++) {

if (current[i].id === id) return ancestors.concat(id)

if (current[i].children && current[i].children.length) {

const ret = fn(id, ancestors.concat(current[i].id), current[i].children)

if (ret) return ret

}

}

}

console.log(fn(112))
答案三

function fn(id) {

const arr = []

const getIds = (ids) => {

for (const v of ids) {

arr.push(v.id)

if (v.id === id) {

return

} else if (v.children) {

getIds(v.children)

} else {

arr.pop()

}

}

}

getIds(data)

return arr

}

console.log(fn(112))

题目五

题目

请实现函数,将entry转换为output的数据格式


const entry = {

'a.b.c.dd': 'abcdd',

'a.d.xx': 'adxx',

'a.e': 'ae'

}

// 要求转换成如下对象

const output = {

a: {

b: {

c: {

dd: 'abcdd'

}

},

d: {

xx: 'adxx'

},

e: 'ae'

}

答案

答案一

function transform(obj) {

const res = {}

for (let [keys, value] of Object.entries(obj)) {

keys

.split('.')

.reduce((prev, cur, idx, arr) =>

prev[cur] = prev[cur] || (arr[idx + 1] ? {} : value)

, res)

}

return res

}
答案二

const transform = (input: { [P in string]: string }): Object => {

const ret = {}

Object.entries(input).forEach(([keys, val]) => {

let root = ret

keys.split('.').forEach((key, ind, arr) => {

if (ind === arr.length - 1) root[key] = val

else {

root[key] = root[key] || {}

root = root[key]

}

})

})

return ret

}
答案三

const entry = {

'a.b.c.dd': 'abcdd',

'a.d.xx': 'adxx',

'a.e': 'ae',

}

const convert = (data) => {

let res = {}

const entries = Object.entries(data)

for (let i = 0; i < entries.length; i++) {

let temp = res

let [key, value] = entries[i]

const everyOne = key.split('.')

for (let j = 0; j < everyOne.length; j++) {

if (j === everyOne.length - 1) {

temp[everyOne[j]] = value

}

temp[everyOne[j]] = temp[everyOne[j]] || {}

temp = temp[everyOne[j]]

}

}

return res

}

console.log(convert(entry))

总结

这次整理的这些每日一题都是一些编程题,其中有些还是我们日常开发中可能会遇到的问题,通过做这些题也可以检验一下自己对这些实用编程技巧的掌握程度。每日一题来源于公众号【前端有的玩】,工作日每天早上八点四十分准时推送,每日一题,每天成长一点点。

结语

不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。 ——文森特・梵高
查看原文

赞 10 收藏 7 评论 0

子君 发布了文章 · 9月7日

使用Vue3.0,我收获了哪些知识点(一)

前端发展百花放,一技未熟百技出。
茫然不知何下手,关注小编胜百书。

近期工作感觉很忙,都没有多少时间去写文章,今天这篇文章主要是将自己前期学习Vue3.0时候整理的一些笔记内容进行了汇总,通过对本文的阅读,你将可以自己完成Vue3.0环境搭建,同时还会对Vue3.0的一些新的特性进行了解,方便自己进行Vue3.0的学习。本文首发于公众号【前端有的玩】,关注===会了,还有更多面试题等你来刷哦。

本文所有的示例均使用ant design vue2.0实现,关于ant design vue2.0请参考 https://2x.antdv.com/docs/vue/introduce-cn/

初始化环境

在前面的文章中,我们通过vite搭建了一个开发环境,但是实际上现在vite并没有完善到支撑一个完整项目的地步,所以本文我们依然选择使用vue-cli脚手架进行环境搭建。

小编使用的vue-cli版本是4.5.4,如果您的版本比较旧可以通过npm update @vue/cli来升级脚手架版本,如果没有安装可以通过npm install @vue/cli -g进行安装

使用脚手架新建项目

  1. 在工作空间打开终端(cmd),然后通过vue create my-vue3-test 命令初始化项目
  2. 在第一步先选择Manually select features,进行手动选择功能
  3. 然后通过Space和上下键依次选择

    Choose Vue version
    Babel
    TypeScript
    Router
    Vuex
    CSS Pre-processors
    Linter/Formatter

    然后回车

    1. 然后提示选择Vue版本,选择3.x(Preview)
    2. Use class-style component syntax?选择n,即输入n然后回车
    3. 然后提示Use Babel alongside TypeScript,输入y`
    4. Use history mode for router输入n
    5. 然后css预处理器选择Less
    6. eslint选择ESLint + Prettier
    7. 然后是Lint on saveIn dedicater config files
    8. 最后一路回车即可完成项目搭建

启动项目

新建完项目之后,进入到项目中cd my-vue3-test,然后执行 yarn serve即可启动项目

启动之后即可通过访问 http://localhost:8080/ 访问项目

配置ant design vue

在当前Vue3.0正式版还未发布之际,国内比较出名的前端UI库中率先将Vue3.0集成到自家的UI库中的,PC端主要是ant-design-vue,移动端主要是vant, 本文所有示例代码都会基于ant-design-vue来进行,首先我们先安装ant-design-vue

  1. 安装依赖

    yarn add ant-design-vue@2.0.0-beta.6
    yarn add babel-plugin-import -D
  2. 配置ant-design-vue按需加载

    进入项目根目录,然后打开babel.config.js文件,将里面的内容修改为

    module.exports = {
      presets: ["@vue/cli-plugin-babel/preset"],
      plugins: [
        // 按需加载
        [
          "import",
          // style 为 true 加载 less文件
          { libraryName: "ant-design-vue", libraryDirectory: "es", style: "css" }
        ]
      ]
    };
  3. 尝试使用vue3 + antdv来添加一个小页面, 我们直接将views/Home.vue文件里面的代码替换为
<template>
  <a-form layout="inline" :model="state.form">
    <a-form-item>
      <a-input v-model:value="state.form.user" placeholder="Username">
        <template v-slot:prefix
          ><UserOutlined style="color:rgba(0,0,0,.25)"
        /></template>
      </a-input>
    </a-form-item>
    <a-form-item>
      <a-input
        v-model:value="state.form.password"
        type="password"
        placeholder="Password"
      >
        <template v-slot:prefix
          ><LockOutlined style="color:rgba(0,0,0,.25)"
        /></template>
      </a-input>
    </a-form-item>
    <a-form-item>
      <a-button
        type="primary"
        :disabled="state.form.user === '' || state.form.password === ''"
        @click="handleSubmit"
      >
        登录
      </a-button>
    </a-form-item>
  </a-form>
</template>
<script>
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
import { Form, Input, Button } from "ant-design-vue";
import { reactive } from "vue";

export default {
  components: {
    UserOutlined,
    LockOutlined,
    [Form.name]: Form,
    [Form.Item.name]: Form.Item,
    [Input.name]: Input,
    [Button.name]: Button
  },
  setup() {
    const state = reactive({
      form: {
        user: "",
        password: ""
      }
    });

    function handleSubmit() {
      console.log(state.form);
    }

    return {
      state,
      handleSubmit
    };
  }
};
</script>

然后重启一下项目,就可以发现已经可以正常使用ant-design-vue了。

Vue3.0新体验之setup

对于Vue3.0的问世,最吸引大家注意力的便是Vue3.0Composition API,对于Componsition API,可以说是两极分化特别严重,一部分人特别喜欢这个新的设计与开发方式,而另一部分人则感觉使用Composition API很容易写出来意大利面式的代码(可能这部分人不知道兰州拉面吧)。到底Composition API是好是坏,小编不做评论,反正我只是一个搬砖的。而本小节介绍的setup就是Composition API的入口。

setup介绍

setupVue3.0提供的一个新的属性,可以在setup中使用Composition API,在上面的示例代码中我们已经使用到了setup,在上文代码中我们在setup中通过reactive初始化了一个响应式数据,然后通过return返回了一个对象,对象中包含了声明的响应式数据和一个方法,而这些数据就可以直接使用到了template中了,就像上文代码中的那样。关于reactive,我将会在下一小节为你带来说明。

setup 的参数说明

setup函数有两个参数,分别是propscontext

  1. props

    propssetup函数的第一个参数,是组件外部传入进来的属性,与vue2.0props基本是一致的,比如下面代码

    export default {
      props: {
        value: {
          type: String,
          default: ""
        }
      },
      setup(props) {
        console.log(props.value)
      }
    }

    但是需要注意的是,在setup中,props是不能使用解构的,即不能将上面的代码改写成

    setup({value}) {
        console.log(value)
     }

    虽然template中使用的是setup返回的对象,但是对于props,我们不需要在setup中返回,而是直接可以在template使用,比如上面的value,可以直接在template写成

    <custom-component :value="value"></custom-component>
  2. context

    contextsetup函数的第二个参数,context是一个对象,里面包含了三个属性,分别是

    • attrs

      attrsVue2.0this.$attrs是一样的,即外部传入的未在props中定义的属性。对于attrsprops一样,我们不能对attrs使用es6的解构,必须使用attrs.name的写法

    • slots

      slots对应的是组件的插槽,与Vue2.0this.$slots是对应的,与propsattrs一样,slots也是不能解构的。

    • emit

      emit对应的是Vue2.0this.$emit, 即对外暴露事件。

setup 返回值

setup函数一般会返回一个对象,这个对象里面包含了组件模板里面要使用到的data与一些函数或者事件,但是setup也可以返回一个函数,这个函数对应的就是Vue2.0render函数,可以在这个函数里面使用JSX,对于Vue3.0中使用JSX,小编将在后面的系列文章中为您带来更多说明。

最后需要注意的是,不要在setup中使用this,在setup中的this和你真正要用到的this是不同的,通过propscontext基本是可以满足我们的开发需求的。

了解Composition API,先从reactiveref开始

在使用Vue2.0的时候,我们一般声明组件的属性都会像下面的代码一样

export default {
  data() {
    return {
      name: '子君',
      sex: '男'
    }
  }
}

然后就可以在需要用到的地方比如computed,watch,methods,template等地方使用,但是这样存在一个比较明显的问题,即我声明data的地方与使用data的地方在代码结构中可能相距很远,有一种君住长江头,我住长江尾,日日思君不见君,共饮一江水的感觉。而Composition API的诞生的一个很重要的原因就是解决这个问题。在尤大大在关于Composition API的动机中是这样描述解决的问题的:

  1. 随着功能的增长,复杂组件的代码变得越来越难以阅读和理解。这种情况在开发人员阅读他人编写的代码时尤为常见。根本原因是 Vue 现有的 API 迫使我们通过选项组织代码,但是有的时候通过逻辑关系组织代码更有意义。
  2. 目前缺少一种简洁且低成本的机制来提取和重用多个组件之间的逻辑。

现在我们先了解一下Compositon API中的reactiveref

介绍reactive

Vue2.6中, 出现了一个新的api,Vue.observer,通过这个api可以创建一个响应式的对象,而reactive就和Vue.ovserver的功能基本是一致的。首先我们先来看一个例子

<template>
  <!--在模板中通过state.name使用setup中返回的数据-->
  <div>{{ state.name }}</div>
</template>
<script>
import { reactive } from "vue";
export default {
  setup() {
    // 通过reactive声明一个可响应式的对象
    const state = reactive({
      name: "子君"
    });
    // 5秒后将子君修改为 前端有的玩
    setTimeout(() => {
      state.name = "前端有的玩";
    }, 1000 * 5);
    // 将state添加到一个对象中然后返回
    return {
      state
    };
  }
};
</script>

上面的例子就是reactive的一个基本的用法,我们通过上面的代码可以看到reactiveVue.observer声明可响应式对象的方法是很像的,但是他们之间还是存在一些差别的。我们在使用vue2.0的时候,最常见的一个问题就是经常会遇到一些数据明明修改了值,但是界面却并没有刷新,这时候就需要使用Vue.set来解决,这个问题是因为Vue2.0使用的Object.defineProperty无法监听到某些场景比如新增属性,但是到了Vue3.0中通过Proxy将这个问题解决了,所以我们可以直接在reactive声明的对象上面添加新的属性,一起看看下面的例子

<template>
  <div>
    <div>姓名:{{ state.name }}</div>
    <div>公众号:{{ state.gzh }}</div>
  </div>
</template>
<script>
import { reactive } from "vue";
export default {
  setup() {
    const state = reactive({
      name: "子君"
    });
    // 5秒后新增属性gzh 前端有的玩
    setTimeout(() => {
      state.gzh = "前端有的玩";
    }, 1000 * 5);
    return {
      state
    };
  }
};
</script>

上面的例子虽然在state中并没有声明gzh属性,但是在5s后我们可以直接给state添加gzh属性,这时候并不需要使用Vue.set来解决新增属性无法响应的问题。

在上面的代码中,reactive通过传入一个对象然后返回了一个state,需要注意的是state与传入的对象是不用的,reactive对原始的对象并没有进行修改,而是返回了一个全新的对象,返回的对象是Proxy的实例。需要注意的是在项目中尽量去使用reactive返回的响应式对象,而不是原始对象。

const obj = {}
const state = reactive(obj)
// 输出false
console.log(obj === state)

介绍ref

假如现在我们需要在一个函数里面声明用户的信息,那么我们可能会有两种不一样的写法

// 写法1
let name = '子君'
let gzh = '前端有的玩'
// 写法2
let userInfo = {
  name: '子君',
  gzh: '前端有的玩'
}

上面两种不同的声明方式,我们使用的时候也是不同的,对于写法1我们直接使用变量就可以了,而对于写法2,我们需要写成userInfo.name的方式。我们可以发现userInfo的写法与reactive是比较相似的,而Vue3.0也提供了另一种写法,就像写法1一样,即ref。先来看一个例子。

<template>
  <div>
    <div>姓名:{{ name }}</div>
  </div>
</template>
<script>
import { ref } from "vue";
export default {
  setup() {
    const name = ref("子君");
    console.log('姓名',name.value)
    // 5秒后修改name为 前端有的玩
    setTimeout(() => {
      name.value = "前端有的玩";
    }, 1000 * 5);
    return {
      name
    };
  }
};
</script>

通过上面的代码,可以对比出来reactiveref的区别

  1. reactive传入的是一个对象,返回的是一个响应式对象,而ref传入的是一个基本数据类型(其实引用类型也可以),返回的是传入值的响应式值
  2. reactive获取或修改属性可以直接通过state.prop来操作,而ref返回值需要通过name.value的方式来修改或者读取数据。但是需要注意的是,在template中并不需要通过.value来获取值,这是因为template中已经做了解套。

Vue3.0优雅的使用v-model

v-model并不是vue3.0新推出的新特性,在Vue2.0中我们已经大量的到了v-model,但是V3V2还是有很大的区别的。本节我们将主要为大家带来如何在Vue3.0中使用v-model,Vue3.0中的v-model提供了哪些惊喜以及如何在Vue3.0中自定义v-model

Vue2.0Vue3.0中使用v-model

Vue2.0中如何实现双向数据绑定呢?常用的方式又两种,一种是v-model,另一种是.sync,为什么会有两种呢?这是因为一个组件只能用于一个v-model,但是有的组件需要有多个可以双向响应的数据,所以就出现了.sync。在Vue3.0中为了实现统一,实现了让一个组件可以拥有多个v-model,同时删除掉了.sync。如下面的代码,分别是Vue2.0Vue3.0使用v-model的区别。

  1. Vue2.0中使用v-model

    <template>
      <a-input v-model="value" placeholder="Basic usage" />
    </template>
    <script>
    export default {
      data() {
        return {
          value: '',
        };
      },
    };
    </script>
  2. Vue3.0中使用v-model

    <template>
      <!--在vue3.0中,v-model后面需要跟一个modelValue,即要双向绑定的属性名-->
      <a-input v-model:value="value" placeholder="Basic usage" />
    </template>
    <script>
    export default {
      // 在Vue3.0中也可以继续使用`Vue2.0`的写法
      data() {
        return {
          value: '',
        };
      },
    };
    </script>

    vue3.0中,v-model后面需要跟一个modelValue,即要双向绑定的属性名,Vue3.0就是通过给不同的v-model指定不同的modelValue来实现多个v-model。对于v-model的原理,下文将通过自定义v-model来说明。

自定义v-model

使用Vue2.0自定义一个v-model示例
  1. 组件代码
<template>
  <div class="custom-input">
    <input :value="value" @input="$_handleChange" />
  </div>
</template>
<script>
export default {
  props: {
    value: {
      type: String,
      default: ''
    }
  },
  methods: {
    $_handleChange(e) {
      this.$emit('input', e.target.value)
    }
  }
}
</script>
  1. 在代码中使用组件

    <template>
        <custom-input v-model="value"></custom-input>
    </template>
    <script>
        export default {
        data() {
          return {
            value: ''
          }
        }
      }
    </script>

    Vue2.0中我们通过为组件设置名为value属性同时触发名为input的事件来实现的v-model,当然也可以通过model来修改属性名和事件名,可以看我以前的文章中有详解。

使用Vue3.0自定义一个v-model示例
  1. 组件代码

    <template>
      <div class="custom-input">
        <input :value="value" @input="_handleChangeValue" />
      </div>
    </template>
    <script>
    export default {
      props: {
        value: {
          type: String,
          default: ""
        }
      },
      name: "CustomInput",
      setup(props, { emit }) {
        function _handleChangeValue(e) {
          // vue3.0 是通过emit事件名为 update:modelValue来更新v-model的
          emit("update:value", e.target.value);
        }
        return {
          _handleChangeValue
        };
      }
    };
    </script>
    
    1. 在代码中使用组件

      <template>
        <!--在使用v-model需要指定modelValue-->
        <custom-input v-model:value="state.inputValue"></custom-input>
      </template>
      <script>
      import { reactive } from "vue";
      import CustomInput from "../components/custom-input";
      export default {
        name: "Home",
        components: {
          CustomInput
        },
        setup() {
          const state = reactive({
            inputValue: ""
          });
          return {
            state
          };
        }
      };
      </script>

到了Vue3.0中,因为一个组件支持多个v-model,所以v-model的实现方式有了新的改变。首先我们不需要使用固定的属性名和事件名了,在上例中因为是input输入框,属性名我们依然使用的是value,但是也可以是其他任何的比如name,data,val等等,而在值发生变化后对外暴露的事件名变成了update:value,即update:属性名。而在调用组件的地方也就使用了v-model:属性名来区分不同的v-model

总结

在本文中我们主要讲解了开发环境的搭建,setup,reactive,ref,v-model等的介绍,同时通过对比Vue3.0Vue2.0的不同,让大家对Vue3.0有了一定的了解,在下文中我们将为大家带来更多的介绍,比如技术属性,watch,生命周期等等,敬请期待。本文首发于公众号【前端有的玩】,学习Vue,面试刷题,尽在【前端有的玩】,`乘兴裸辞心甚爽,面试工作屡遭难。
幸得每日一题伴,点击关注莫偷懒。`,下周一新文推送,不见不散。

结语

不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。 ——文森特・梵高
查看原文

赞 24 收藏 18 评论 9

子君 发布了文章 · 8月31日

当裸辞遇到面试难,这些面试题你需要了解一下

乘兴裸辞心甚爽,面试工作屡遭难。
幸得每日一题伴,点击关注莫偷懒。

又要到金九银十的跳槽季了,为了让更多的小伙伴可以在面试的时候取的更好的offer,所以自上月起我每天都会在自己的公众号【前端有的玩】里面推送一到两道面试题,方便找工作的小伙伴每日都会有新的收获。本文就是小编将前期的一些比较经典的每日一题进行了梳理,欢迎大家一起来看看。本文内容首发于公众号【前端有的玩】,关注 === 学会。

类数组面试题

什么是类数组,类数组就是 拥有length属性,且其他属性(索引)为非负整数的对象,且不具备数组所用于的方法。比如我们常用的document.querySelector返回的NodeLists就是一个类数组。这道题就是和类数组相关的内容.

题目

请说出以下代码输出的内容,需要区分nodejs,chrome以及chrome去掉splice之后的输出内容

var obj = {
    '2': 3,
    '3': 4,
    'length': 2,
    'splice': Array.prototype.splice,
    'push': Array.prototype.push
}
obj.push(1)
obj.push(2)
console.log(obj)

答案

这道题一共问了三种情况下面的输出,下面依次说明答案

  1. node下面输出

    { '2': 1,
      '3': 2,
      length: 4,
      splice: [Function: splice],
      push: [Function: push] }
  2. chrome下面输出

    [empty × 2, 1, 2, splice: ƒ, push: ƒ]
  3. chrome去掉splice下面输出

    {2: 1, 3: 2, length: 4, push: ƒ}

通过上面输出的内容,可以看出相同的代码,不同情况输出的内容是有所不同的,下面进行详细分解。

题解

在解答题目之前,我们再看看这段代码

const arr = new Array(2)
// 输出  2 [empty * 2]
console.log(arr.length, arr)
arr.push(1)
// 输出  3 [empty * 2, 1]
console.log(arr.length, arr)

可以看到push方法会将数组的length + 1, 然后将值放在索引为length - 1的位置,比如上面的代码,因为在初始化数组的时候,已经将数组长度指定为了2, 所以在push之后length就变成了3,然后arr[3 - 1] = 1

MDN上面对push的方法的解释是:

push 方法具有通用性。该方法和 call()apply() 一起使用时,可应用在类似数组的对象上。push 方法根据 length 属性来决定从哪里开始插入给定的值。如果 length 不能被转成一个数值,则插入的元素索引为 0,包括 length 不存在时。当 length 不存在时,将会创建它。

根据MDN解释,push既可以使用到数组中,也可以使用到类数组中。而根据前文中对类数组的解释,可以看到题目中的obj就是一个标准的类数组,那就可以在obj上面使用数组的push方法。

再看obj.push(1), 因为obj.length = 2, 所以会将length + 1就变成了3, 这时候 索引值时obj[3 - 1] = 1obj[2] = 1, 同理 obj.push(2) 也一样的。因为在obj中已经有了属性(索引)23,所以在push的时候会覆盖掉23上面的默认值。

所以在nodejs中就会输出

{ '2': 1,
  '3': 2,
  length: 4,
  splice: [Function: splice],
  push: [Function: push] }

但是在chrome控制台中输出

[empty × 2, 1, 2, splice: ƒ, push: ƒ]

很奇怪,为什么会输出这样呢?这一块有一个很特殊的陷阱,就是chrome控制台是如何判断打印的内容是数组还是其他对象呢?对于这个,chrome就是通过判断对象上面是否有splicelength这两个属性来判断的,所以如果你将splice去掉之后,就会输出以下内容

{2: 1, 3: 2, length: 4, push: ƒ}

你也可以试试下面的代码:

console.log({splice:function(){},length:1})
console.log({slice:function(){},length:1})

逻辑面试题之小鼠喝毒药

小编当年毕业的时候面试就遇到过好几次逻辑类的面试题,这道题就是一道逻辑类的面试题,一起来看看。

题目

有16瓶水,其中只有一瓶水有毒,小白鼠喝一滴之后一小时会死,请问最少用多少只小白鼠,在1小时内一定可以找出有毒的水?

答案与题解

答案是至少需要4只小鼠,怎么理解呢?我们可以用二进制去推理一下:

假设有4只小鼠,分别是甲乙丙丁,使用二进制来表示小鼠喝药的顺序,1代表喝药,0代表不喝药

甲: 1111 1111 0000 0000

乙: 1111 0000 1111 0000

丙: 1100 1100 1100 1100

丁: 1010 1010 1010 1010

那么我们就可以这样去判断:

  1. 甲乙丙丁都死了,说明第一瓶有毒
  2. 甲乙丙死了,说明第二瓶有毒
  3. 甲乙丁死了,说明第三瓶有毒
  4. 甲乙死了,说明第四瓶有毒
  5. 甲丙丁死了,说明第五瓶有毒
  6. 。。。 依次类推

其实对于这道题,可以使用2n次方来判断,比如有32瓶水,那么就是25次方,所以就需要5只小鼠。

arguments 面试题

ES6中,我们如果一个函数参数个数不确定,我们一般会使用扩展运算符即function(...rest){},得到一个参数数组rest,但是在ES6之前,我们是不能使用扩展运算符的,这时候就需要考虑使用arguments

题目

请说出以下程序输出的内容(chrome输出内容)

let obj = {
  arg: 18,
  foo: function(func) {
    func()
    arguments[0]()
  }
}

var age = 10
function fn() {
  console.log(this.age)
}

obj.foo(fn)

答案

本题的答案是:

// 第一个输出 10
func()
// 第一个输出 undefined
arguments[0]()

有点出乎意料了吗?

先来解释一下第一个,为什么不是输出18呢,虽然func()是在foo函数里面调用的,但是并没有显式指明作用域,这时候会使用默认作用域window,而对于浏览器来说,在全局通过var声明的变量会自动挂载到window上面,所以var age = 10相当于window.age = 10, 而第一个func()里面的this.age相当于window.age

第二个可能许多人有点蒙,为啥是undefined,先看一下下面的代码

const arr = [function() {console.log(this[1])}, '我是子君']
// 输出 我是子君
console.log(arr[0]())

我们通过arr[0]获取到函数,这时候函数的作用域就是这个数组,所以再调用的时候,this就是arr, 所以this[1]就是数组第二项。

这时候回过头来看arguments,这个其实是一个类数组,里面存的是函数传入的参数,第一项就是传入的函数,和上面例子一样,arguments[0]的作用域就是arguments,而arguments上面并没有age属性,所以是undefined

this指向问题

this指向问题一直是比较混乱的,在箭头函数出现之前,this的指向与代码在哪里定义并没有关系,而是取决于是被谁执行的,正因为此,所以许多开发人员经常会搞不清楚this到底是谁。下面的两道题都是和this指向相关的问题。

题目一(青铜)

请说出以下代码输出的内容

let num = 1;
let obj = {
    num: 2,
    add: function() {
        this.num = 3;
        (function() {
            console.log(this.num);
            this.num = 4;
        })();
        console.log(this.num);
    },
    sub: function() {
        console.log(this.num)
    }
}
obj.add();
console.log(obj.num);
console.log(num);
const sub = obj.sub;
sub();

题目二(黄金)

请说出以下代码输出的内容

var num = 10
const obj = {num: 20}
obj.fn = (function (num) {
  this.num = num * 3
  num++
  return function (n) {
    this.num += n
    num++
    console.log(num)
  }
})(obj.num)
var fn = obj.fn
fn(5)
obj.fn(10)
console.log(num, obj.num)

答案

题目一

输出结果: 1,3,3,4,4, 你答对了吗?下面我们来看看代码解析

var num = 1;
let obj = {
    num: 2,
    add: function() {
        this.num = 3;
          // 这里的立即指向函数,因为我们没有手动去指定它的this指向,所以都会指向window
        (function() {
            // 所有这个 this.num 就等于 window.num
            console.log(this.num);
            this.num = 4;
        })();
        console.log(this.num);
    },
    sub: function() {
        console.log(this.num)
    }
}
// 下面逐行说明打印的内容

/**
 * 在通过obj.add 调用add 函数时,函数的this指向的是obj,这时候第一个this.num=3
 * 相当于 obj.num = 3 但是里面的立即指向函数this依然是window,
 * 所以 立即执行函数里面console.log(this.num)输出1,同时 window.num = 4
 *立即执行函数之后,再输出`this.num`,这时候`this`是`obj`,所以输出3
 */
obj.add() // 输出 1 3

// 通过上面`obj.add`的执行,obj.name 已经变成了3
console.log(obj.num) // 输出3
// 这个num是 window.num
console.log(num) // 输出4
// 如果将obj.sub 赋值给一个新的变量,那么这个函数的作用域将会变成新变量的作用域
const sub = obj.sub
// 作用域变成了window window.num 是 4
sub() // 输出4
题目二

输出结果为: 22236530, 你答对了吗? 下面我们解析一下

var num = 10
const obj = {num: 20}
obj.fn = (function (num) {
  this.num = num * 3
  num++
  return function (n) {
    this.num += n
    num++
    console.log(num)
  }
})(obj.num)
var fn = obj.fn
fn(5)
obj.fn(10)
console.log(num, obj.num)

我们把上面的代码分为以下几步进行分析

  1. 先看第三行代码,是一个赋值操作,我们知道赋值操作是从右向左的,而=号右边是一个立即执行函数,所以会优先执行立即执行函数,立即执行函数没有手动指定this,这时候this = window,而立即函数的参数num是传进来的obj.num,所以num参数默认值是 20
  2. 第四行相当于window.num = 20 * 3
  3. 第五行为传入的参数加一,所以 num = 20 + 1
  4. 第六行return了一个函数,而这个函数就是obj.fn的值, 但是因为return的函数引用了立即执行函数里面的num,所以形成了闭包。这时候

    obj.fn = function(n) {
      this.num += n
      // 这个num是立即执行函数里面的num
      num++
      console.log(num)
    }
  5. var fn = obj.fn, 将obj.fn赋值给新的变量,而这个变量的作用域是window
  6. 在调用fn(5)的时候, 在第二步,window.num的值已经变成了60, 然后因为这时候fnthiswindow, this.num += n相当于window.num += n, 即window.num = 65
  7. num++, 因为闭包的原因,第三步num21,所以这一步 num变成了22, 同时输出22
  8. 然后obj.fn(10),这时候fnthisobj,obj.num默认值是20, this.num += n相当于 obj.num += 10
  9. 和第七步一样, num + 1 输出 23
  10. console.log(num, obj.num)相当于 console.log(window.num, obj.num),从上面几步可知, window.num = 65, obj.num = 30
扩展题

如果将上面两道题的 var改成 let, 又会输出什么结果呢?

数据类型转换问题

虽然在日常开发中,我们隐氏类型转换用的比较少(不一定),但是这个还是面试常问问题,掌握还是要掌握的,一起来看看这道题目吧.

题目(王炸/青铜,我也不知道)

请说出以下代码输出的内容

console.log([] + [])
console.log({} + [])
console.log([] == ![])
console.log(true + false)

答案

一起来看看答案吧

  1. 第一行代码
// 输出 "" 空字符串
console.log([] + [])

这行代码输出的是空字符串"", 包装类型在运算的时候,会先调用valueOf方法,如果valueOf返回的还是包装类型,那么再调用toString方法

// 还是 数组
const val = [].valueOf()
// 数组 toString 默认会将数组各项使用逗号 "," 隔开, 比如 [1,2,3].toSting 变成了"1,2,3",空数组 toString 就是空字符串
const val1 = val.toString() // val1 是空字符串

所以上面的代码相当于

console.log("" + "")
  1. 第二行代码

    // 输出 "[object Object]"
    console.log({} + [])

    和第一题道理一样,对象 {}隐氏转换成了[object Object],然后与""相加

  2. 第三行代码

    // 输出 true
    console.log([] == ![])

    对于===, 会严格比较两者的值,但是对于==就不一样了

    1. 比如 null == undefined
    2. 如果非numbernumber比较,会将其转换为number
    3. 如果比较的双方中由一方是boolean,那么会先将boolean转换为number

所以对于上面的代码,看下面一步一步分析

// 这个输出 false
console.log(![])
// 套用上面第三条 将 false 转换为 数值
// 这个输出 0
console.log(Number(false))
// 包装类型与 基本类型 == 先将包装类型通过 valueOf toString 转换为基本类型 
// 输出 ""
console.log([].toString())
// 套用第2条, 将空字符串转换为数值、
// 输出 0
console.log(Number(""))
// 所以
console.log(0 == 0)
  1. 第四行代码

    // 输出 1
    console.log(true + false)

    两个基本类型相加,如果其中一方是字符,则将其他的转换为字符相加,否则将类型转换为Number,然后相加, Number(true)1, Number(false)0, 所以结果是 1

总结

面试造火箭,工作拧螺丝。虽然我只想拧螺丝,但是我却需要通过造火箭来找到拧螺丝的工作,每日一题,每天都有新的面试题目,欢迎关注公众号【前端有的玩】,拉你进入前端技术交流群,每日一题等着你来一起答题。

结语

不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。 ——文森特・梵高
查看原文

赞 20 收藏 16 评论 1

子君 发布了文章 · 8月24日

学习Vue3.0,先从搭建环境开始

Bug源测试,上线来几个。愿君多修改,今夜眼难合。

这是小编关于Vue3.0系列文章的第二篇,本文将带您从零搭建一个基于Vue3.0viteVue3.0开发环境,通过本文的学习,你将学习到以下内容:

  1. 使用vite初始化Vue3.0项目
  2. 配置ts
  3. 配置vue-router
  4. 配置vuex
  5. 使用Vue3.0开发一个TodoList示例
您可以通过微信搜索【前端有的玩】关注我的公众号加我微信好友,手摸手和你一起学习Vue3.0

使用vite初始化项目

vite 介绍

vite是尤大大在今年新鼓捣出来的一个工具,尤大大对vite的描述是这样的: Vite is an opinionated web dev build tool that serves your code via native ES Module imports during dev and bundles it with Rollup for production. 翻译成中文就是:Vite 是一个由原生 ES Module 驱动的 Web 开发构建工具。在开发环境下基于浏览器原生 ES imports 开发,在生产环境下基于 Rollup 打包。

上面这段话提到了一个关键字ES Module,这个是什么呢?详细的介绍大家可以访问 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules 进行查看。此处我们长话短说。在最早的时候,还没有前端工程化,然后我们写javascript都是写到一个文件,然后通过script标签去引用,后来随着前端发展越来越壮大,js之间依赖越来越复杂,这时候就需要有一种可以将JavaScript 程序拆分为可按需导入的单独模块的机制来维护这个依赖,随之就诞生了AMD,CMD等等,而ES Module就是浏览器支持的原生模块依赖的功能。

为什么要用vite

为什么尤大大要推出vite,在我们使用webpack的时候,每次开发时候启动项目都需要几十秒甚至超过一分钟,比较慢,而且热更新也比较慢,而vite的主要特点就是快,官网对于vite的特点是这样描述的

  1. 快速的冷启动
  2. 即时的模块热更新
  3. 真正的按需编译

到底有多快呢,我们先新建一个项目试试

初始化vite项目

  1. 初始化项目, 在工作空间打开终端窗口,对于window用户即cmd,然后执行下面命令

    yarn create vite-app my-vue3

    执行之后就会输出以下内容,可以看到新建项目特别快,仅仅用了1.63s

  2. 初始化完项目,通过cd my-vue3进行到项目里面,然后再执行yarn安装依赖(此处建议使用淘宝镜像,比较快)
  3. 依赖安装完需要通过yarn dev启动项目

    是不是瞬间体验到了秒启项目的感觉,启动之后就可以通过http://localhost:3000来访问项目了

查看项目结构

使用vscode打开项目之后,可以查看到新建的项目结构与vue-cli4创建的项目结构基本一样,都是我们很熟悉的App.vuemain.js

查看main.js文件内容

打开main.js

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

发现创建Vue的方式变了,原来是通过new Vue的方法来初始化Vue,在Vue3.0中,修改为了通过createApp的方式,关于Vue3.0的更多使用方式,我们将在后面的系列文章中逐渐为您带来讲解。

配置typescript

typescript现在已经成为了前端必备技能之一,大量的项目也开始基于typescript进行开发。在使用Vue2.0的时候,因为Vue2.0没有对typescript进行支持,所以使用ts开发功能显示有些别扭。但到了Vue3,其自身源码便是基于ts开发的,所以对ts天生有着很好的支持。使用vite配置typescript很简单,只需要进行以下几步操作.

  1. 安装 typescript

    yarn add typescript -D
  2. 初始化tsconfig.json

    # 然后在控制台执行下面命令
    npx tsc --init
  3. main.js修改为main.ts,同时将index.html里面的引用也修改为main.ts, 通过还需要修改App.vueHelloWorld.vue文件,修改方式如下

    <!--将 <script> 修改为 <script lang="ts">-->
    <script lang="ts">
    import HelloWorld from './components/HelloWorld.vue'
    
    export default {
      name: 'App',
      components: {
        HelloWorld
      }
    }
    </script>
    

    修改完之后,重启就可以访问项目了。虽然这样配置是可以了,但是打开main.ts会发现import App from App.vue会报错: Cannot find module './App.vue' or its corresponding type declarations.,这是因为现在ts还没有识别vue文件,需要进行下面的配置:

    1. 在项目根目录添加shim.d.ts文件
    2. 添加以下内容

      declare module "*.vue" {
        import { Component } from "vue";
        const component: Component;
        export default component;
      }

接下来你就可以开开心心的在组件中使用ts

配置 vue-router

Vue2.0中我们路由一般会选择使用vue-router,在Vue3.0依然可以使用vue-router,不过和Vue3.0一样当前vue-router的版本也是beta版本,在本文撰写的时候,版本是4.0.0-beta7

安装vue-router

因为当前vue-router针对vue3.0的版本还是beta版本,所以不能直接通过yarn add vue-router进行安装,而是需要带上版本号

yarn add vue-router@4.0.0-beta.7

配置vue-router

在项目src目录下面新建router目录,然后添加index.ts文件,在文件中添加以下内容

import {createRouter, createWebHashHistory} from 'vue-router'

// 在 Vue-router新版本中,需要使用createRouter来创建路由
export default createRouter({
  // 指定路由的模式,此处使用的是hash模式
  history: createWebHashHistory(),
  // 路由地址
  routes: []
})

与新的Vue3.0初始化方式发生变化一样,vue-router的初始化方式也发生了变化,变成了通过createRouter来初始化路由。

router引入到main.ts

修改main.ts文件内容如下

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import router from './router/index'

const  app = createApp(App)
// 通过use 将 路由插件安装到 app 中
app.use(router)
app.mount('#app')

配置 vuex

vue-router一样,新的vuex当前也处于beta版本,当前版本是4.0.0-beta.4

安装vuex

yarn add vuex@4.0.0-beta.4

配置vuex

在项目src目录下面新建store目录,并添加index.ts文件,文件中添加以下内容

import { createStore } from 'vuex'

interface State {
  userName: string
}

export default createStore({
  state(): State {
    return {
      userName: "子君",
    };
  },
});

引入到main.ts

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import router from './router/index'
import store from './store/index'

const  app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')

开发TodoList

通过上面的一系列操作,我们的开发环境就已经配置完成了,接下来我们就通过新的开发环境先开发一个TodoList,来验证一下是否正常。

添加todolist页面

  1. 首先我们先在src目录下面新建一个views目录,然后在其中新建文件todo-list.vue,并为文件添加以下内容

    
    <template>
      <div class="todo-list">
        <div>
          <label>新增待办</label>
           <input v-model="state.todo" @keyup.enter="handleAddTodo">
        </div>
        <div>
          <h3>待办列表({{todos.length}})</h3>
          <ul>
            <li v-for="item in todos" :key="item.id" @click="handleChangeStatus(item, true)">
              <input type="checkbox">
              <label>{{item.text}}</label>
            </li>
          </ul>
        </div>
        <div><h3>已办列表({{dones.length}})</h3></div>
        <ul>
          <li v-for="item in dones" :key="item.id" @click="handleChangeStatus(item, false)">
              <input type="checkbox" checked>
              <label>{{item.text}}</label>
            </li>
        </ul>
      </div>
    </template>
    <script lang="ts">
     // 在vue2中 data 在vue3中使用 reactive代替
    import { reactive, computed } from 'vue'
    import { useRouter } from 'vue-router'
    export default {
      // setup相当于vue2.0的 beforeCreate和 created,是vue3新增的一个属性,所有的操作都在此属性中完成
      setup(props, context) {
        // 通过reactive 可以初始化一个可响应的数据,与Vue2.0中的Vue.observer很相似
        const state = reactive({
          todoList: [{
            id: 1,
            done: false,
            text: '吃饭'
          },{
            id: 2,
            done: false,
            text: '睡觉'
          },{
            id: 3,
            done: false,
            text: '打豆豆'
          }],
          todo: ''
        })
        // 使用计算属性生成待办列表
        const todos = computed(() => {
          return state.todoList.filter(item => !item.done)
        })
    
        // 使用计算属性生成已办列表
        const dones = computed(() => {
          return state.todoList.filter(item => item.done)
        })
    
        // 修改待办状态
        const handleChangeStatus = (item ,status) => {
          item.done = status
        }
        
        // 新增待办
        const handleAddTodo = () => {
          if(!state.todo) {
            alert('请输入待办事项')
            return
          }
          state.todoList.push({
            text: state.todo,
            id: Date.now(),
            done: false
          })
          state.todo = ''
        }
    
            // 在Vue3.0中,所有的数据和方法都通过在setup 中 return 出去,然后在template中使用
        return {
          state,
          todos,
          dones,
          handleChangeStatus,
          handleAddTodo
        }
      }
    }
    </script>
    <style scoped>
    .todo-list{
      text-align: center;
    }
    
    .todo-list ul li {
      list-style: none;
    }
    </style>

    调整路由

    1. 首先将App.vue文件内容修改为

      <template>
        <router-view></router-view>
      </template>
      
      <script lang="ts">
      
      export default {
        name: 'App'
      }
      </script>
    2. 然后修改 router/index.ts文件,添加新的路由

      import {createRouter, createWebHashHistory} from 'vue-router'
      
      // 在 Vue-router新版本中,需要使用createRouter来创建路由
      export default createRouter({
        // 指定路由的模式,此处使用的是hash模式
        history: createWebHashHistory(),
        // 路由地址
        routes: [{
          path: '/todolist',
          // 必须添加.vue后缀
          component: () => import('../views/todo-list.vue')
        }]
      })

      这时候我们就可以通过http://localhost:3000/#/todolist来访问TodoList了,效果如下图所示

总结

到此,我们Vue3.0的开发环境算是搭建完成了,当然现在还有好多好多要完善的东西,比如我们还需要去调整一下typescript的配置,然后添加eslint等等。同时如何在组件中跳转路由,使用vuex还没有去讲解,不过至少我们已经起步了,更多的内容将会在下一篇文章中讲到。本文首发于公众号【前端有的玩】,欢迎关注加我好友,我们一起探讨Vue3.0

查看原文

赞 58 收藏 38 评论 12

子君 赞了文章 · 8月19日

好孩子的编码习惯

前言

我经常能听到一些对话
狗腿子A:哇 我刚刚去改**项目的代码,看的我有点怀疑人生
狗腿子B: 我现在项目的跟屎山一样
狗腿子C: 我隔壁那哥们每天写代码都特别随性,我有点按耐不住我的刀
.....
image.png
今天跟大家聊聊一些 我眼中 好孩子的编码习惯,而不是代码风格习惯 ,当然还是强烈建议大家代码风格跟psr-12psr-1靠齐。

psr-1基础编码规范 、psr-12编码规范托充
This specification extends, expands and replaces PSR-2, the coding style guide and requires adherence to PSR-1, the basic coding standard.

离职流程.png

推荐一本《代码整洁之道》,这本书我已经书都快翻烂了,墙裂推荐!!!

image.png

不过度的if嵌套判断

案例背景
有个函数需要判断用户是否参与活动
流程图1.png

案例代码

    if (用户 == VIP) {
        if (用户的过期时间 <= 1个月内) {
            if (用户没参加过任务) {
                return true;
            }
        } else {
            return false;
        }
    } else {
        return true
    }
    

面对这种多条件的判断可以试着用拦截法逆向思维
拦截法只要符合条件立马返回结果,不再嵌套的if。可以理解成横向判断变成纵向判断。

舒适感
从上往下看 > 从左往右看

逆向思维 大家上学的时候都了解过,与其漫天去找符合的条件还不如找不符合条件,这样的逻辑代码可以少很多。

    if (用户 != VIP) {
        return true;
    } 
    
    if (用户参加过任务) {
        return false;
    }
    
    if (用户的过期时间 <= 1个月内) {
        return true;    
    }
    
    return false;
    

不过度的try-catch嵌套

我遇到过很多项目都过度嵌套try-catch导致最上层的try-catchcatch了寂寞。
image.png

案例代码

function insertUser($data)
{
    try {
        userIsInValid();
    } catch (Exception $exception) {
    }
}

function userIsInValid()
{
    try {
        //逻辑判断
    } catch (Exception $exception) {
        return true;
    }
    return true;
    
}

这样的代码没有问题,但是如果假设userIsInValid真的发生代码级的错误没法知道那里出问题,虽然不会破坏业务的健壮性。
可能有人说了在Excetion加个日志,但是如果嵌套的try-catch多了,排查日志也是一件很痛苦的事情。

1.尽可能业务最上层包裹异常 除非网络IO请求函数。
2.如果非要异常嵌套 需要定义每个异常的类型。
3.尽可能根据特定的异常进行catch 不建议直接catch Exception。
4.异常和日志是个cp,还是不要忘记了。
image.png

<?php

function insertUser($data)
{
    try {
        userIsInValid();
    } catch (Exception $exception) {
        // 日志
        // 业务处理
    } catch (HttpException $httpException) {
       // 日志
       // 业务处理
    }
}

function userIsInValid()
{
    //
    return true;
}

不要用if-else做错误类型判断

案例代码 (来源某个网民前段时间咨询)

<?php

.....
if ($code === 'NOTENOUGH') {
    packApiData(400014, 'Company have no enough money to pay', [], '企业余额不足');
} elseif ($code === 'AMOUNT_LIMIT') {
    packApiData(400015, 'Amount limit', [], '金额超限或被微信风控拦截');
} elseif ($code === 'OPENID_ERROR') {
    packApiData(400016, 'Appid and Openid does not match', [], 'Openid格式错误或不属于此公众号');
} elseif ($code === 'SEND_FAILED') {
    // 付款错误,要查单来看最终结果
    if ($orderInfo[1]['status'] == 'SUCCESS') {
        // 还是成功给了,扣回余额
        
        packApiData(200, 'success', [$orderInfo[1]]);
    } else {
        packApiData(400017, 'Weixin pay failed', [], '微信支付付款失败');
    }
} elseif ($code === 'SYSTEMERROR') {
    packApiData(400018, 'Weixin pay server error', [], '微信支付服务器错误');
} elseif ($code === 'NAME_MISMATCH') {
    packApiData(400019, 'Real name mismatch', [], '微信用户的真名校验失败');
} elseif ($code === 'FREQ_LIMIT') {
    packApiData(400020, 'Api request frequently', [], '微信支付接口调用过于频繁,请稍候再请求');
} elseif ($code === 'MONEY_LIMIT') {
    packApiData(400021, 'Company have reached total payment limit', [], '已经达到今日付款总额上限');
} elseif ($code === 'V2_ACCOUNT_SIMPLE_BAN') {
    packApiData(400022, 'This payment account has no real name', [], '用户的微信支付账户未实名');
} elseif ($code === 'SENDNUM_LIMIT') {
    packApiData(400023, 'The number of times the user paid today exceeded the limit', [], '该用户今日收款次数超过限制');
}

这样的代码可能写起来特别舒服,但是后期进行业务的增加改写和时间的沉淀,容易变成让人害怕的屎山代码。

image.png

我们用mapping错误码来调整下

function packApiDataByOrderError($code)
{
    $errorCodeMappins = [
        "NOTENOUGH" => [
            "code" => 400014,
            "wx_message" => "Company have no enough money to pay",
            "error_message" => "企业余额不足"
        ],

        "AMOUNT_LIMIT" => [
            "code" => 400015,
            "wx_message" => "Amount limit",
            "error_message" => "金额超限或被微信风控拦截"
        ],

        .....
    ];

    if (array_key_exists($code, $errorCodeMappins)) {
        packApiData(
            $errorCodeMappins[$code]['code'],
            $errorCodeMappins[$code]['wx_message'],
            [],
            $errorCodeMappins[$code]['error_message']
        );
    }

    packApiData(
        999999,
        "undefined message",
        [],
        "未知错误"
    );
}

建议errorCodeMappins不要放在函数内,可以放在类顶部或者专门枚举类。
通过errorCode 可以避免调整主流程代码,能够保证主流程的代码比较精简也能对不同的code进行错误的定义

if ($code == "SEND_FAILED") {
    // 付款错误,要查单来看最终结果
    if ($orderInfo[1]['status'] == 'SUCCESS') {
        // 还是成功给了,扣回余额
        PDOQuery($dbcon, 'UPDATE user SET money=money-? WHERE open_id=?', [$payAmount, $openId], [PDO::PARAM_INT, PDO::PARAM_STR]);
        packApiData(200, 'success', [$orderInfo[1]]);
    } else {
        packApiData(400017, 'Weixin pay failed', [], '微信支付付款失败');
    }
}

packApiDataByOrderError($code);

在合适的场景使用设计模式

上述可能只能针对错误码进行改造,如果万一我们需要不同的错误进行逻辑处理还怎么办。这时候可以考虑用设计模式 (比如用以多态取代条件表达式)

设计模式固好但不要过度使用,不然整个项目更难维护,你要坚信未来的你队友不知道是什么样的生物

image.png

$callbackCodeMappings = [
    "SEND_FAILED" => OrderSendFailed::class,
];

if (array_key_exists($code, $callbackCodeMappings)) {
    $class = new $callbackCodeMappings[$code];
    $class->handle();
}


interface OrderStateImp
{
    public function handle($context);
}

class OrderSendFailed implements  OrderStateImp
{
    public function handle($context)
    {

    }
}

$callbackCodeMappings同样建议配置专门枚举文件内。
给出得代码比较粗糙,其实可以更加健壮性的做一些判断

统一处理浮点运算结果

由于php是弱对象语言,所以面对一堆情况总能出现,这个订单数据怎么不对了,接口有问题。

$int = 0.58; var_dump(intval($int * 100));
output:57

在浮点数里面 58是被视为57.999999999999999999999……9999无限接近58
再intval强制转换乘整型的时候就默认采用截取法取整

所以最好养成一个好习惯每次在计算浮点数的时候用
BC Math

$int = 0.58;
intval(strval($int * 100))

或者使用BC MATH

bcmul(0.58, 100, 0);

image.png

鼓励用全局错误码来控制错误

写接口的我们对以下的json格式特别熟悉

{
    "success": true,
    "error_code": 0,
    "message": "",
    "results": []
}

对以下的代码也已经熟悉

if (***) {
    $this->error(999,"****", []);
}

这样的结果的错误码容易重复没有统一管理,事实上唯一错误码应该有以下帮助。
1.前端可以根据错误码做逻辑处理
2.根据错误码能直接快速定位到错误代码

建议

<?php

namespace App\ErrorCode;

class UserErrorCode
{
    const USER_DISABLE_ERROR = [
        "error_code" => 1050001,
        "message" => "用户已被停用"
    ];
}

$this->error(UserErrorCode::USER_DISABLE_ERROR);

错误码建议

1-2位 - 项目码 | 3-4位 - 模块码 | 5-7位具体业务错误码

可靠的命名规范

不可靠的命名总会让人误导。
比如变量命名为userArrayList 我以为是个数组列表变量,事实上这个特么是个对象列表。

1.做有意义的区分
比如 singleUserItemuserItem有啥区别
比如 getUserListgetUsers有啥区别
image.png

2.可以通过搜索翻译能知道的变量含义
不要把变量贴入搜索翻译会出现七七八八的东西
3.如果真的不知道该怎么翻试试用拼音把别硬凹了
比如之前做百度的一个接口对接
变量命名为hundredDegree而不是baidu
image.png
其他的可以参照《代码简洁之道》

擅用middleware

middleware可以理解成观察者模式,我们开发的接口总会遇到很多同样操作,比如
1.身份检测
2.权限判断
3.请求参数filter调整
4.记录接口信息
5.接口限流
我见过挨个接口去实现、也见过初始化一个ControllerBase的类,实现这些,子类的Controller去继承这些。
其实我们可以抽离成middleware去实现
image.png

好处可以根据不同接口对middleware进行组合选择,而不是对代码进行各特殊化处理.

函数的单一职责

最最最最后也是最重要的,代码的恶心大多数来源于函数的职责不清晰,有全都塞在一起的、东一块西一块的。
其实关于单一职责有很多文章在描述,如何去检验或者去写符合标准的单一职责。
画流程图
如果你能把业务的流程图画的特别清晰,那么你的函数的职责也就定下来了。
image.png

<?php

// 兑换逻辑
function doExchange()
{
    if (checkIsLock()) {
        
    }
    lock();
    if (!checkUserIsExchange()) {
        
    }
    costUserPoint();
    exchangeGoods();
}
// 判断是否悲观锁
function checkIsLock(){}
// 上悲观锁
function lock(){}
// 判断用户是否可以兑换
function checkUserIsExchange(){}
// 扣除积分
function costUserPoint(){}
// 兑换商品
function exchangeGoods(){}

最后

上述为洪光光心中的好孩子的习惯,也有可能是你眼中坏孩子的习惯。如果你认为是坏孩子的习惯或者认为还有其他好孩子的习惯欢迎评论撕逼讨论。
毕竟
image.png

留个彩蛋 看看大家怎么实现
写一个函数returnScoreResult,请根据输入的分数,返回对应的成绩的等级。
1.如果分数小于0或者大于100 返回 【无效分数】
2.如果分数>=0,<60 返回 【不及格】
3.如果分数>=60,<70 返回【及格】
4.如果分数>=70,<80 返回 【一般】
5.如果分数>=80, <90 返回 【良好】
5.如果分数>=90, <100 返回 【优秀】
6.如果分数=100 返回【满分】

查看原文

赞 37 收藏 24 评论 7

子君 发布了文章 · 8月19日

学习Vue3.0,先来了解一下Proxy

产品经理身旁过,需求变更逃不过。
测试姐姐眯眼笑,今晚bug必然多。

据悉Vue3.0的正式版将要在本月(8月)发布,从发布到正式投入到正式项目中,还需要一定的过渡期,但我们不能一直等到Vue3正式投入到项目中的时候才去学习,提前学习,让你更快一步掌握Vue3.0,升职加薪迎娶白富美就靠它了。不过在学习Vue3之前,还需要先了解一下Proxy,它是Vue3.0实现数据双向绑定的基础。

本文是作者关于Vue3.0系列的第一篇文章,后续作者将会每周发布一篇Vue3.0相关,如果喜欢,麻烦给小编一个赞,谢谢

了解代理模式

一个例子

作为一个单身钢铁直男程序员,小王最近逐渐喜欢上了前台小妹,不过呢,他又和前台小妹不熟,所以决定委托与前端小妹比较熟的UI小姐姐帮忙给自己搭桥引线。小王于是请UI小姐姐吃了一顿大餐,然后拿出一封情书委托它转交给前台小妹,情书上写的 我喜欢你,我想和你睡觉,不愧钢铁直男。不过这样写肯定是没戏的,UI小姐姐吃人嘴短,于是帮忙改了情书,改成了我喜欢你,我想和你一起在晨辉的沐浴下起床,然后交给了前台小妹。虽然有没有撮合成功不清楚啊,不过这个故事告诉我们,小王活该单身狗。

其实上面就是一个比较典型的代理模式的例子,小王想给前台小妹送情书,因为不熟所以委托UI小姐姐UI小姐姐相当于代理人,代替小王完成了送情书的事情。

引申

通过上面的例子,我们想想Vue的数据响应原理,比如下面这段代码


const xiaowang = {
  love: '我喜欢你,我想和你睡觉'
}
// 送给小姐姐情书
function sendToMyLove(obj) {
    console.log(obj.love)
    return '流氓,滚'
}
console.log(sendToMyLove(xiaowang))

如果没有UI小姐姐代替送情书,显示结局是悲惨的,想想Vue2.0的双向绑定,通过Object.defineProperty来监听的属性 get,set方法来实现双向绑定,这个Object.defineProperty就相当于UI小姐姐

const xiaowang = {
  loveLetter: '我喜欢你,我想和你睡觉'
}
// UI小姐姐代理
Object.defineProperty(xiaowang,'love', {
  get() {
    return xiaowang.loveLetter.replace('睡觉','一起在晨辉的沐浴下起床')
  }
})

// 送给小姐姐情书
function sendToMyLove(obj) {
    console.log(obj.love)
    return '小伙子还挺有诗情画意的么,不过老娘不喜欢,滚'
}
console.log(sendToMyLove(xiaowang))

虽然依然是一个悲惨的故事,因为送奔驰的成功率可能会更高一些。但是我们可以看到,通过Object.defineproperty可以对对象的已有属性进行拦截,然后做一些额外的操作。

存在的问题

Vue2.0中,数据双向绑定就是通过Object.defineProperty去监听对象的每一个属性,然后在get,set方法中通过发布订阅者模式来实现的数据响应,但是存在一定的缺陷,比如只能监听已存在的属性,对于新增删除属性就无能为力了,同时无法监听数组的变化,所以在Vue3.0中将其换成了功能更强大的Proxy

了解Proxy

ProxyES6新推出的一个特性,可以用它去拦截js操作的方法,从而对这些方法进行代理操作。

用Proxy重写上面的例子

比如我们可以通过Proxy对上面的送情书情节进行重写:

const xiaowang = {
  loveLetter: '我喜欢你,我想和你睡觉'
}
const proxy = new Proxy(xiaowang, {
  get(target,key) {
    if(key === 'loveLetter') {
      return target[key].replace('睡觉','一起在晨辉的沐浴下起床')
    }
  }
})
// 送给小姐姐情书
function sendToMyLove(obj) {
    console.log(obj.loveLetter)
    return '小伙子还挺有诗情画意的么,不过老娘不喜欢,滚'
}
console.log(sendToMyLove(proxy))

再看这样一个场景

请分别使用Object.definePropertyProxy完善下面的代码逻辑.

function observe(obj, callback) {}

const obj = observe(
  {
    name: '子君',
    sex: '男'
  },
  (key, value) => {
    console.log(`属性[${key}]的值被修改为[${value}]`)
  }
)

// 这段代码执行后,输出 属性[name]的值被修改为[妹纸]
obj.name = '妹纸'

// 这段代码执行后,输出 属性[sex]的值被修改为[女]
obj.sex = '女'

看了上面的代码,希望大家可以先自行实现以下,下面我们分别用Object.definePropertyProxy去实现上面的逻辑.

  1. 使用Object.defineProperty
/**
 * 请实现这个函数,使下面的代码逻辑正常运行
 * @param {*} obj 对象
 * @param {*} callback 回调函数
 */
function observe(obj, callback) {
  const newObj = {}
  Object.keys(obj).forEach(key => {
    Object.defineProperty(newObj, key, {
      configurable: true,
      enumerable: true,
      get() {
        return obj[key]
      },
      // 当属性的值被修改时,会调用set,这时候就可以在set里面调用回调函数
      set(newVal) {
        obj[key] = newVal
        callback(key, newVal)
      }
    })
  })
  return newObj
}

const obj = observe(
  {
    name: '子君',
    sex: '男'
  },
  (key, value) => {
    console.log(`属性[${key}]的值被修改为[${value}]`)
  }
)

// 这段代码执行后,输出 属性[name]的值被修改为[妹纸]
obj.name = '妹纸'

// 这段代码执行后,输出 属性[sex]的值被修改为[女]
obj.name = '女'
  1. 使用Proxy
function observe(obj, callback) {
  return new Proxy(obj, {
    get(target, key) {
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      callback(key, value)
    }
  })
}

const obj = observe(
  {
    name: '子君',
    sex: '男'
  },
  (key, value) => {
    console.log(`属性[${key}]的值被修改为[${value}]`)
  }
)

// 这段代码执行后,输出 属性[name]的值被修改为[妹纸]
obj.name = '妹纸'

// 这段代码执行后,输出 属性[sex]的值被修改为[女]
obj.name = '女'

通过上面两种不同实现方式,我们可以大概的了解到Object.definePropertyProxy的用法,但是当给对象添加新的属性的时候,区别就出来了,比如

// 添加公众号字段
obj.gzh = '前端有的玩'

使用Object.defineProperty无法监听到新增属性,但是使用Proxy是可以监听到的。对比上面两段代码可以发现有以下几点不同

  • Object.defineProperty监听的是对象的每一个属性,而Proxy监听的是对象自身
  • 使用Object.defineProperty需要遍历对象的每一个属性,对于性能会有一定的影响
  • Proxy对新增的属性也能监听到,但Object.defineProperty无法监听到。

初识Proxy

概念与语法

MDN中,关于Proxy是这样介绍的: Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。什么意思呢?Proxy就像一个拦截器一样,它可以在读取对象的属性,修改对象的属性,获取对象属性列表,通过for in循环等等操作的时候,去拦截对象上面的默认行为,然后自己去自定义这些行为,比如上面例子中的set,我们通过拦截默认的set,然后在自定义的set里面添加了回调函数的调用

Proxy的语法格式如下

/**
* target: 要兼容的对象,可以是一个对象,数组,函数等等
* handler: 是一个对象,里面包含了可以监听这个对象的行为函数,比如上面例子里面的`get`与`set`
* 同时会返回一个新的对象proxy, 为了能够触发handler里面的函数,必须要使用返回值去进行其他操作,比如修改值
*/
const proxy = new Proxy(target, handler)

在上面的例子里面,我们已经使用到了handler里面提供的getset方法了,接下来我们一一看一下handler里面的方法。

handler 里面的方法列表

handler里面的方法可以有以下这十三个,每一个都对应的一种或多种针对proxy代理对象的操作行为

  1. handler.get

    当通过proxy去读取对象里面的属性的时候,会进入到get钩子函数里面

  2. handler.set

    当通过proxy去为对象设置修改属性的时候,会进入到set钩子函数里面

  3. handler.has

    当使用in判断属性是否在proxy代理对象里面时,会触发has,比如

    const obj = {
      name: '子君'
    }
    console.log('name' in obj)
  4. handler.deleteProperty

    当使用delete去删除对象里面的属性的时候,会进入deleteProperty`钩子函数

  5. handler.apply

    proxy监听的是一个函数的时候,当调用这个函数时,会进入apply钩子函数

  6. handle.ownKeys

    当通过Object.getOwnPropertyNames,Object.getownPropertySymbols,Object.keys,Reflect.ownKeys去获取对象的信息的时候,就会进入ownKeys这个钩子函数

  7. handler.construct

    当使用new操作符的时候,会进入construct这个钩子函数

  8. handler.defineProperty

    当使用Object.defineProperty去修改属性修饰符的时候,会进入这个钩子函数

  9. handler.getPrototypeOf

    当读取对象的原型的时候,会进入这个钩子函数

  10. handler.setPrototypeOf

    当设置对象的原型的时候,会进入这个钩子函数

  11. handler.isExtensible

    当通过Object.isExtensible去判断对象是否可以添加新的属性的时候,进入这个钩子函数

  12. handler.preventExtensions

    当通过Object.preventExtensions去设置对象不可以修改新属性时候,进入这个钩子函数

  13. handler.getOwnPropertyDescriptor

    在获取代理对象某个属性的属性描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时会进入这个钩子函数

Proxy提供了十三种拦截对象操作的方法,本文主要挑选其中一部分在Vue3中比较重要的进行说明,其余的建议可以直接阅读MDN关于Proxy的介绍。

详细介绍

get

当通过proxy去读取对象里面的属性的时候,会进入到get钩子函数里面

当我们从一个proxy代理上面读取属性的时候,就会触发get钩子函数,get函数的结构如下

/**
 * target: 目标对象,即通过proxy代理的对象
 * key: 要访问的属性名称
 * receiver: receiver相当于是我们要读取的属性的this,一般情况
 *           下他就是proxy对象本身,关于receiver的作用,后文将具体讲解
 */
handle.get(target,key, receiver)
示例

我们在工作中经常会有封装axios的需求,在封装过程中,也需要对请求异常进行封装,比如不同的状态码返回的异常信息是不同的,如下是一部分状态码及其提示信息:

// 状态码提示信息
const errorMessage = {
  400: '错误请求',
  401: '系统未授权,请重新登录',
  403: '拒绝访问',
  404: '请求失败,未找到该资源'
}

// 使用方式
const code = 404
const message = errorMessage[code]
console.log(message)

但这存在一个问题,状态码很多,我们不可能每一个状态码都去枚举出来,所以对于一些异常状态码,我们希望可以进行统一提示,如提示为系统异常,请联系管理员,这时候就可以使用Proxy对错误信息进行代理处理

// 状态码提示信息
const errorMessage = {
  400: '错误请求',
  401: '系统未授权,请重新登录',
  403: '拒绝访问',
  404: '请求失败,未找到该资源'
}

const proxy = new Proxy(errorMessage, {
  get(target,key) {
    const value = target[key]
    return value || '系统异常,请联系管理员'
  }
})

// 输出 错误请求
console.log(proxy[400])
// 输出 系统异常,请联系管理员
console.log(proxy[500])

set

当为对象里面的属性赋值的时候,会触发set

当给对象里面的属性赋值的时候,会触发set,set函数的结构如下

/**
 * target: 目标对象,即通过proxy代理的对象
 * key: 要赋值的属性名称
 * value: 目标属性要赋的新值
 * receiver: 与 get的receiver 基本一致
 */
handle.set(target,key,value, receiver)
示例

某系统需要录入一系列数值用于数据统计,但是在录入数值的时候,可能录入的存在一部分异常值,对于这些异常值需要在录入的时候进行处理, 比如大于100的值,转换为100, 小于0的值,转换为0, 这时候就可以使用proxyset,在赋值的时候,对数据进行处理

const numbers = []
const proxy = new Proxy(numbers, {
  set(target,key,value) {
    if(value < 0) {
      value = 0
    }else if(value > 100) {
      value = 100
    }
    target[key] = value
    // 对于set 来说,如果操作成功必须返回true, 否则会被视为失败
    return true
  }
})

proxy.push(1)
proxy.push(101)
proxy.push(-10)
// 输出 [1, 100, 0]
console.log(numbers)
对比Vue2.0

在使用Vue2.0的时候,如果给对象添加新属性的时候,往往需要调用$set, 这是因为Object.defineProperty只能监听已存在的属性,而新增的属性无法监听,而通过$set相当于手动给对象新增了属性,然后再触发数据响应。但是对于Vue3.0来说,因为使用了Proxy, 在他的set钩子函数中是可以监听到新增属性的,所以就不再需要使用$set

const obj = {
  name: '子君'
}
const proxy = new Proxy(obj, {
  set(target,key,value) {
    if(!target.hasOwnProperty(key)) {
      console.log(`新增了属性${key},值为${value}`)
    }
    target[key] = value
    return true
  }
})
// 新增 公众号 属性
// 输出 新增了属性gzh,值为前端有的玩
proxy.gzh = '前端有的玩'

has

当使用in判断属性是否在proxy代理对象里面时,会触发has
/**
 * target: 目标对象,即通过proxy代理的对象
 * key: 要判断的key是否在target中
 */
 handle.has(target,key)
示例

一般情况下我们在js中声明私有属性的时候,会将属性的名字以_开头,对于这些私有属性,是不需要外部调用,所以如果可以隐藏掉是最好的,这时候就可以通过has在判断某个属性是否在对象时,如果以_开头,则返回false

const obj =  {
  publicMethod() {},
  _privateMethod(){}
}
const proxy = new Proxy(obj, {
  has(target, key) {
    if(key.startsWith('_')) {
      return false
    }
    return Reflect.get(target,key)
  }
})

// 输出 false
console.log('_privateMethod' in proxy)

// 输出 true
console.log('publicMethod' in proxy)

deleteProperty

当使用delete去删除对象里面的属性的时候,会进入deleteProperty`拦截器
/**
 * target: 目标对象,即通过proxy代理的对象
 * key: 要删除的属性
 */
 handle.deleteProperty(target,key)
示例

现在有一个用户信息的对象,对于某些用户信息,只允许查看,但不能删除或者修改,对此使用Proxy可以对不能删除或者修改的属性进行拦截并抛出异常,如下

const userInfo = {
  name: '子君',
  gzh: '前端有的玩',
  sex: '男',
  age: 22
}
// 只能删除用户名和公众号
const readonlyKeys = ['name', 'gzh']
const proxy = new Proxy(userInfo, {
  set(target,key,value) {
    if(readonlyKeys.includes(key)) {
      throw new Error(`属性${key}不能被修改`)
    }
    target[key] = value
    return true
  },
   deleteProperty(target,key) {
    if(readonlyKeys.includes(key)) {
      throw new Error(`属性${key}不能被删除`)
      return
    }
    delete target[key]
    return true
  }
})
// 报错 
delete proxy.name
对比Vue2.0

其实与$set解决的问题类似,Vue2.0是无法监听到属性被删除的,所以提供了$delete用于删除属性,但是对于Proxy,是可以监听删除操作的,所以就不需要再使用$delete

其他操作

在上文中,我们提到了Proxyhandler提供了十三个函数,在上面我们列举了最常用的三个,其实每一个的用法都是基本一致的,比如ownKeys,当通过Object.getOwnPropertyNames,Object.getownPropertySymbols,Object.keys,Reflect.ownKeys去获取对象的信息的时候,就会进入ownKeys这个钩子函数,使用这个我们就可以对一些我们不像暴露的属性进行保护,比如一般会约定_开头的为私有属性,所以在使用Object.keys去获取对象的所有key的时候,就可以把所有_开头的属性屏蔽掉。关于剩余的那些属性,建议大家多去看看MDN中的介绍。

Reflect

在上面,我们获取属性的值或者修改属性的值都是通过直接操作target来实现的,但实际上ES6已经为我们提供了在Proxy内部调用对象的默认行为的API,即Reflect。比如下面的代码

const obj = {}
const proxy = new Proxy(obj, {
  get(target,key,receiver) {
    return Reflect.get(target,key,receiver)
  }
})

大家可能看到上面的代码与直接使用target[key]的方式没什么区别,但实际上Reflect的出现是为了让Object上面的操作更加规范,比如我们要判断某一个prop是否在一个对象中,通常会使用到in,即

const obj = {name: '子君'}
console.log('name' in obj)

但上面的操作是一种命令式的语法,通过Reflect可以将其转变为函数式的语法,显得更加规范

Reflect.has(obj,'name')

除了has,get之外,其实Reflect上面总共提供了十三个静态方法,这十三个静态方法与Proxyhandler上面的十三个方法是一一对应的,通过将ProxyReflect相结合,就可以对对象上面的默认操作进行拦截处理,当然这也就属于函数元编程的范畴了。

总结

有的同学可能会有疑惑,我不会ProxyReflect就学不了Vue3.0了吗?其实懂不懂这个是不影响学习Vue3.0的,但是如果想深入 去理解Vue3.0,还是很有必要了解这些的。比如经常会有人在使用Vue2的时候问,为什么我数组通过索引修改值之后,界面没有变呢?当你了解到Object.defineProperty的使用方式与限制之后,就会恍然大悟,原来如此。本文之后,小编将为大家带来Vue3.0系列文章,欢迎关注,一起学习。同时本文首发于公众号【前端有的玩】,用玩的姿势学前端,就在【前端有的玩】

查看原文

赞 36 收藏 22 评论 0

子君 收藏了文章 · 8月4日

了解JS压缩图片,这一篇就够了

image

前言

公司的移动端业务需要在用户上传图片是由前端压缩图片大小,再上传到服务器,这样可以减少移动端上行流量,减少用户上传等待时长,优化用户体验。

插播一下,本文案例已整理成插件,已上传 npm ,可通过 npm install js-image-compressor -D 安装使用,可以从 github 下载。

JavaScript 操作压缩图片原理不难,已有成熟 API,然而在实际输出压缩后结果却总有意外,有些图片竟会越压缩越大,加之终端(手机)类型众多,有些手机压缩图片甚至变黑。

压缩小龙女,哈哈哈😂

所以本文将试图解决如下问题:

  • 弄清 Image 对象、data URLCanvasFile(Blob)之间的转化关系;
  • 图片压缩关键技巧;
  • 超大图片压缩黑屏问题。

转化关系

在实际应用中有可能使用的情境:大多时候我们直接读取用户上传的 File 对象,读写到画布(canvas)上,利用 CanvasAPI 进行压缩,完成压缩之后再转成 File(Blob) 对象,上传到远程图片服务器;不妨有时候我们也需要将一个 base64 字符串压缩之后再变为 base64 字符串传入到远程数据库或者再转成 File(Blob) 对象。一般的,它们有如下转化关系:

js-image-compressor-flow-chat

具体实现

下面将按照转化关系图中的转化方法一一实现。

file2DataUrl(file, callback)

用户通过页面标签 <input type="file" /> 上传的本地图片直接转化 data URL 字符串形式。可以使用 FileReader 文件读取构造函数。FileReader 对象允许 Web 应用程序异步读取存储在计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。该实例方法 readAsDataURL 读取文件内容并转化成 base64 字符串。在读取完后,在实例属性 result 上可获取文件内容。

function file2DataUrl(file, callback) {
  var reader = new FileReader();
  reader.onload = function () {
    callback(reader.result);
  };
  reader.readAsDataURL(file);
}

Data URL 由四个部分组成:前缀(data:)、指示数据类型的 MIME 类型、如果非文本则为可选的 base64 标记、数据本身:

data:<mediatype>,<data>

比如一张 png 格式图片,转化为 base64 字符串形式:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAgAElEQVR4XuxdB5g

file2Image(file, callback)

若想将用户通过本地上传的图片放入缓存并 img 标签显示出来,除了可以利用以上方法转化成的 base64 字符串作为图片 src,还可以直接用 URL 对象,引用保存在 FileBlob 中数据的 URL。使用对象 URL 的好处是可以不必把文件内容读取到 JavaScript 中 而直接使用文件内容。为此,只要在需要文件内容的地方提供对象 URL 即可。

function file2Image(file, callback) {
  var image = new Image();
  var URL = window.webkitURL || window.URL;
  if (URL) {
    var url = URL.createObjectURL(file);
    image.onload = function() {
      callback(image);
      URL.revokeObjectURL(url);
    };
    image.src = url;
  } else {
    inputFile2DataUrl(file, function(dataUrl) {
      image.onload = function() {
        callback(image);
      }
      image.src = dataUrl;
    });
  }
}

注意:要创建对象 URL,可以使用 window.URL.createObjectURL() 方法,并传入 FileBlob 对象。如果不再需要相应数据,最好释放它占用的内容。但只要有代码在引用对象 URL,内存就不会释放。要手工释放内存,可以把对象 URL 传给 URL.revokeObjectURL()

url2Image(url, callback)

通过图片链接(url)获取图片 Image 对象,由于图片加载是异步的,因此放到回调函数 callback 回传获取到的 Image 对象。

function url2Image(url, callback) {
  var image = new Image();
  image.src = url;
  image.onload = function() {
    callback(image);
  }
}

image2Canvas(image)

利用 drawImage() 方法将 Image 对象绘画在 Canvas 对象上。

drawImage 有三种语法形式:

void ctx.drawImage(image, dx, dy);
void ctx.drawImage(image, dx, dy, dWidth, dHeight);
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

参数:

  • image 绘制到上下文的元素;
  • sx 绘制选择框左上角以 Image 为基准 X 轴坐标;
  • sy 绘制选择框左上角以 Image 为基准 Y 轴坐标;
  • sWidth 绘制选择框宽度;
  • sHeight 绘制选择框宽度;
  • dxImage 的左上角在目标 canvasX 轴坐标;
  • dyImage 的左上角在目标 canvasY 轴坐标;
  • dWidthImage 在目标 canvas 上绘制的宽度;
  • dHeightImage 在目标 canvas 上绘制的高度;

canvas-draw-image

function image2Canvas(image) {
  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  canvas.width = image.naturalWidth;
  canvas.height = image.naturalHeight;
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  return canvas;
}

canvas2DataUrl(canvas, quality, type)

HTMLCanvasElement 对象有 toDataURL(type, encoderOptions) 方法,返回一个包含图片展示的 data URL 。同时可以指定输出格式和质量。

参数分别为:

  • type 图片格式,默认为 image/png
  • encoderOptions在指定图片格式为 image/jpegimage/webp 的情况下,可以从 01 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92,其他参数会被忽略。
function canvas2DataUrl(canvas, quality, type) {
  return canvas.toDataURL(type || 'image/jpeg', quality || 0.8);
}

dataUrl2Image(dataUrl, callback)

图片链接也可以是 base64 字符串,直接赋值给 Image 对象 src 即可。

function dataUrl2Image(dataUrl, callback) {
  var image = new Image();
  image.onload = function() {
    callback(image);
  };
  image.src = dataUrl;
}

dataUrl2Blob(dataUrl, type)

data URL 字符串转化为 Blob 对象。主要思路是:先将 data URL 数据(data) 部分提取出来,用 atob 对经过 base64 编码的字符串进行解码,再转化成 Unicode 编码,存储在Uint8Array(8位无符号整型数组,每个元素是一个字节) 类型数组,最终转化成 Blob 对象。

function dataUrl2Blob(dataUrl, type) {
  var data = dataUrl.split(',')[1];
  var mimePattern = /^data:(.*?)(;base64)?,/;
  var mime = dataUrl.match(mimePattern)[1];
  var binStr = atob(data);
  var arr = new Uint8Array(len);

  for (var i = 0; i < len; i++) {
    arr[i] = binStr.charCodeAt(i);
  }
  return new Blob([arr], {type: type || mime});
}

canvas2Blob(canvas, callback, quality, type)

HTMLCanvasElementtoBlob(callback, [type], [encoderOptions]) 方法创造 Blob 对象,用以展示 canvas 上的图片;这个图片文件可以被缓存或保存到本地,由用户代理端自行决定。第二个参数指定图片格式,如不特别指明,图片的类型默认为 image/png,分辨率为 96dpi。第三个参数用于针对image/jpeg 格式的图片进行输出图片的质量设置。

function canvas2Blob(canvas, callback, quality, type){
  canvas.toBlob(function(blob) {
    callback(blob);
  }, type || 'image/jpeg', quality || 0.8);
}

为兼容低版本浏览器,作为 toBlobpolyfill 方案,可以用上面 data URL 生成 Blob 方法 dataUrl2Blob 作为HTMLCanvasElement 原型方法。

if (!HTMLCanvasElement.prototype.toBlob) {
 Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
  value: function (callback, type, quality) {
    let dataUrl = this.toDataURL(type, quality);
    callback(dataUrl2Blob(dataUrl));
  }
 });
}

blob2DataUrl(blob, callback)

Blob 对象转化成 data URL 数据,由于 FileReader 的实例 readAsDataURL 方法不仅支持读取文件,还支持读取 Blob 对象数据,这里复用上面 file2DataUrl 方法即可:

function blob2DataUrl(blob, callback) {
  file2DataUrl(blob, callback);
}

blob2Image(blob, callback)

Blob 对象转化成 Image 对象,可通过 URL 对象引用文件,也支持引用 Blob 这样的类文件对象,同样,这里复用上面 file2Image 方法即可:

function blob2Image(blob, callback) {
  file2Image(blob, callback);
}

upload(url, file, callback)

上传图片(已压缩),可以使用 FormData 传入文件对象,通过 XHR 直接把文件上传到服务器。

function upload(url, file, callback) {
  var xhr = new XMLHttpRequest();
  var fd = new FormData();
  fd.append('file', file);
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
      // 上传成功
      callback && callback(xhr.responseText);
    } else {
      throw new Error(xhr);
    }
  }
  xhr.open('POST', url, true);
  xhr.send(fd);
}

也可以使用 FileReader 读取文件内容,转化成二进制上传

function upload(url, file) {
  var reader = new FileReader();
  var xhr = new XMLHttpRequest();

  xhr.open('POST', url, true);
  xhr.overrideMimeType('text/plain; charset=x-user-defined-binary');

  reader.onload = function() {
    xhr.send(reader.result);
  };
  reader.readAsBinaryString(file);
}

实现简易图片压缩

在熟悉以上各种图片转化方法的具体实现,将它们封装在一个公用对象 util 里,再结合压缩转化流程图,这里我们可以简单实现图片压缩了:
首先将上传图片转化成 Image 对象,再将写入到 Canvas 画布,最后由 Canvas 对象 API 对图片的大小和尺寸输出调整,实现压缩目的。

/**
 * 简易图片压缩方法
 * @param {Object} options 相关参数
 */
(function (win) {
  var REGEXP_IMAGE_TYPE = /^image\//;
  var util = {};
  var defaultOptions = {
    file: null,
    quality: 0.8
  };
  var isFunc = function (fn) { return typeof fn === 'function'; };
  var isImageType = function (value) { return REGEXP_IMAGE_TYPE.test(value); };

  /**
   * 简易图片压缩构造函数
   * @param {Object} options 相关参数
   */
  function SimpleImageCompressor(options) {
    options = Object.assign({}, defaultOptions, options);
    this.options = options;
    this.file = options.file;
    this.init();
  }

  var _proto = SimpleImageCompressor.prototype;
  win.SimpleImageCompressor = SimpleImageCompressor;

  /**
   * 初始化
   */
  _proto.init = function init() {
    var _this = this;
    var file = this.file;
    var options = this.options;

    if (!file || !isImageType(file.type)) {
      console.error('请上传图片文件!');
      return;
    }

    if (!isImageType(options.mimeType)) {
      options.mimeType = file.type;
    }

    util.file2Image(file, function (img) {
      var canvas = util.image2Canvas(img);
      file.width = img.naturalWidth;
      file.height = img.naturalHeight;
      _this.beforeCompress(file, canvas);

      util.canvas2Blob(canvas, function (blob) {
        blob.width = canvas.width;
        blob.height = canvas.height;
        options.success && options.success(blob);
      }, options.quality, options.mimeType)
    })
  }

  /**
   * 压缩之前,读取图片之后钩子函数
   */
  _proto.beforeCompress = function beforeCompress() {
    if (isFunc(this.options.beforeCompress)) {
      this.options.beforeCompress(this.file);
    }
  }

  // 省略 `util` 公用方法定义
  // ...

  // 将 `util` 公用方法添加到实例的静态属性上
  for (key in util) {
    if (util.hasOwnProperty(key)) {
      SimpleImageCompressor[key] = util[key];
    }
  }
})(window)

这个简易图片压缩方法调用和入参:

var fileEle = document.getElementById('file');

fileEle.addEventListener('change', function () {
  file = this.files[0];

  var options = {
    file: file,
    quality: 0.6,
    mimeType: 'image/jpeg',
    // 压缩前回调
    beforeCompress: function (result) {
      console.log('压缩之前图片尺寸大小: ', result.size);
      console.log('mime 类型: ', result.type);
      // 将上传图片在页面预览
      // SimpleImageCompressor.file2DataUrl(result, function (url) {
      //   document.getElementById('origin').src = url;
      // })
    },
    // 压缩成功回调
    success: function (result) {
      console.log('压缩之后图片尺寸大小: ', result.size);
      console.log('mime 类型: ', result.type);
      console.log('压缩率: ', (result.size / file.size * 100).toFixed(2) + '%');

      // 生成压缩后图片在页面展示
      // SimpleImageCompressor.file2DataUrl(result, function (url) {
      //   document.getElementById('output').src = url;
      // })

      // 上传到远程服务器
      // SimpleImageCompressor.upload('/upload.png', result);
    }
  };

  new SimpleImageCompressor(options);
}, false);

如果看到这里的客官不嫌弃这个 demo 太简单可以戳这里试试水。如果你有足够的耐心多传几种类型图片就会发现还存在如下问题:

  • 压缩输出图片寸尺固定为原始图片尺寸大小,而实际可能需要控制输出图片尺寸,同时达到尺寸也被压缩目的;
  • png 格式图片同格式压缩,压缩率不高,还有可能出现“不减反增”现象;
  • 有些情况,其他格式转化成 png 格式也会出现“不减反增”现象;
  • 大尺寸 png 格式图片在一些手机上,压缩后出现“黑屏”现象;

越压缩越膨胀😂

改进版图片压缩

俗话说“罗马不是一天建成的”,通过上述实验,我们发现了很多不足,下面将逐条问题分析,寻求解决方案。

压缩输出图片寸尺固定为原始图片尺寸大小,而实际可能需要控制输出图片尺寸,同时达到尺寸也被压缩目的;

为了避免压缩图片变形,一般采用等比缩放,首先要计算出原始图片宽高比 aspectRatio,用户设置的高乘以 aspectRatio,得出等比缩放后的宽,若比用户设置宽的小,则用户设置的高为为基准缩放,否则以宽为基准缩放。

var aspectRatio = naturalWidth / naturalHeight;
var width = Math.max(options.width, 0) || naturalWidth;
var height = Math.max(options.height, 0) || naturalHeight;
if (height * aspectRatio > width) {
  height = width / aspectRatio;
} else {
  width = height * aspectRatio;
}

输出图片的尺寸确定了,接下来就是按这个尺寸创建一个 Canvas 画布,将图片画上去。这里可以将上面提到的 image2Canvas 方法稍微做一下改造:

function image2Canvas(image, destWidth, destHeight) {
  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  canvas.width = destWidth || image.naturalWidth;
  canvas.height = destHeight || image.naturalHeight;
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  return canvas;
}
png 格式图片同格式压缩,压缩率不高,还有可能出现“不减反增”现象

一般的,不建议将 png 格式图片压缩成自身格式,这样压缩率不理想,有时反而会造成自身质量变得更大。

因为我们在“具体实现”中两个有关压缩关键 API

  • toBlob(callback, [type], [encoderOptions]) 参数 encoderOptions 用于针对image/jpeg 格式的图片进行输出图片的质量设置;
  • toDataURL(type, encoderOptions 参数encoderOptions 在指定图片格式为 image/jpegimage/webp 的情况下,可以从 01 的区间内选择图片的质量。

均未对 png 格式图片有压缩效果。

有个折衷的方案,我们可以设置一个阈值,如果 png 图片的质量小于这个值,就还是压缩输出 png 格式,这样最差的输出结果不至于质量太大,在此基础上,如果压缩后图片大小 “不减反增”,我们就兜底处理输出源图片给用户。当图片质量大于某个值时,我们压缩成 jpeg 格式。

// `png` 格式图片大小超过 `convertSize`, 转化成 `jpeg` 格式
if (file.size > options.convertSize && options.mimeType === 'image/png') {
  options.mimeType = 'image/jpeg';
}
// 省略一些代码
// ...
// 用户期待的输出宽高没有大于源图片的宽高情况下,输出文件大小大于源文件,返回源文件
if (result.size > file.size && !(options.width > naturalWidth || options.height > naturalHeight)) {
  result = file;
}
大尺寸 png 格式图片在一些手机上,压缩后出现“黑屏”现象;

由于各大浏览器对 Canvas 最大尺寸支持不同

浏览器最大宽高最大面积
Chrome32,767 pixels268,435,456 pixels(e.g.16,384 x 16,384)
Firefox32,767 pixels472,907,776 pixels(e.g.22,528 x 20,992)
IE8,192 pixelsN/A
IE Mobile4,096 pixelsN/A

如果图片尺寸过大,在创建同尺寸画布,再画上图片,就会出现异常情况,即生成的画布没有图片像素,而画布本身默认给的背景色为黑色,这样就导致图片“黑屏”情况。

这里可以通过控制输出图片最大宽高防止生成画布越界,并且用透明色覆盖默认黑色背景解决解决“黑屏”问题:

// ...
// 限制最小和最大宽高
var maxWidth = Math.max(options.maxWidth, 0) || Infinity;
var maxHeight = Math.max(options.maxHeight, 0) || Infinity;
var minWidth = Math.max(options.minWidth, 0) || 0;
var minHeight = Math.max(options.minHeight, 0) || 0;

if (maxWidth < Infinity && maxHeight < Infinity) {
  if (maxHeight * aspectRatio > maxWidth) {
    maxHeight = maxWidth / aspectRatio;
  } else {
    maxWidth = maxHeight * aspectRatio;
  }
} else if (maxWidth < Infinity) {
  maxHeight = maxWidth / aspectRatio;
} else if (maxHeight < Infinity) {
  maxWidth = maxHeight * aspectRatio;
}

if (minWidth > 0 && minHeight > 0) {
  if (minHeight * aspectRatio > minWidth) {
    minHeight = minWidth / aspectRatio;
  } else {
    minWidth = minHeight * aspectRatio;
  }
} else if (minWidth > 0) {
  minHeight = minWidth / aspectRatio;
} else if (minHeight > 0) {
  minWidth = minHeight * aspectRatio;
}

width = Math.floor(Math.min(Math.max(width, minWidth), maxWidth));
height = Math.floor(Math.min(Math.max(height, minHeight), maxHeight));

// ...
// 覆盖默认填充颜色 (#000)
var fillStyle = 'transparent';
context.fillStyle = fillStyle;

到这里,上述的意外问题被我们一一解决了,如需体验改进版的图片压缩 demo 的小伙伴可以戳这里

总结

我们梳理了通过页面标签 <input type="file" /> 上传本地图片到图片被压缩整个过程,也覆盖到了在实际使用中还存在的一些意外情况,提供了相应的解决方案。将改进版图片压缩整理成插件,已上传 npm ,可通过 npm install js-image-compressor -D 安装使用,可以从 github 下载。整理匆忙,如有问题欢迎大家指正,完~

查看原文

子君 回答了问题 · 8月3日

ELEMENT-UI的v-loading 早异步的时候不生效

谢邀。
我刚看了你的截图,你第一个截图使用的是Promise.then,然后第二个截图使用的是asyn await,要搞清楚为什么第一个loading不生效,第二个生效,就要搞清楚这两者之间的区别。
不论是Promise还是async await,他们都是解决异步回调地狱的方法。说道异步,可能大家第一反应会想到setTimeout,现在我用setTimeout来代替这两者来说明两者的区别。

对于第一张图的Promise, 改成setTimeout就变成了这样

this.roleUserLoading = true
setTimeout(() =>{
  console.log('这里相当于Promise.then')
},1000)
this.roleUserLoading = false

对于第二张图的async await, 改成setTimeout就变成了这样

this.roleUserLoading = true
setTimeout(() =>{
  console.log('这里相当于await')
  this.roleUserLoading = false
},1000)

对比上面的代码,我们会发现this.roleUserLoading一个在setTimeout外面,一个在里面,在外面的因为setTimout是异步的,所以首先会执行

this.roleUserLoading = true
this.roleUserLoading = false

然后在1秒后才执行setTimeout的内容,而上面改变roleUserLoading的值是瞬间完成的,所以看不到loading

现在再说一下async awaitawait后面的代码都会等待await调用的异步执行完之后再执行,就相当于把await后面的所有代码都放到了setTimeout里面了,这时候loading就必须等待setTimeout被调用执行之后才会被关闭。你明白了吗?

那如何在promise上面也可以用loading呢,我们可以像下面代码这样

promise.then(() => {}).finally(() =>{
  this.roleUserLoading = false
})

如果觉得有用,麻烦给我一个赞,谢谢,同时也可以关注我的公众号【前端有的玩】,拉你进前端交流群,一起学习前端技术。

关注 3 回答 2

子君 发布了文章 · 8月3日

Vue中使用装饰器,我是认真的

产品上线事繁多,测试产品催不离。
休问Bug剩多少,眼圈如漆身如泥。

作为一个曾经的Java coder, 当我第一次看到js里面的装饰器(Decorator)的时候,就马上想到了Java中的注解,当然在实际原理和功能上面,Java的注解和js的装饰器还是有很大差别的。本文题目是Vue中使用装饰器,我是认真的,但本文将从装饰器的概念开发聊起,一起来看看吧。

通过本文内容,你将学到以下内容:

  1. 了解什么是装饰器
  2. 在方法使用装饰器
  3. class中使用装饰器
  4. Vue中使用装饰器
本文首发于公众号【前端有的玩】,不想当咸鱼,想要换工作,关注公众号,带你每日一起刷大厂面试题,关注 === 大厂offer

什么是装饰器

装饰器是ES2016提出来的一个提案,当前处于Stage 2阶段,关于装饰器的体验,可以点击 https://github.com/tc39/proposal-decorators查看详情。装饰器是一种与类相关的语法糖,用来包装或者修改类或者类的方法的行为,其实装饰器就是设计模式中装饰者模式的一种实现方式。不过前面说的这些概念太干了,我们用人话来翻译一下,举一个例子。

在日常开发写bug过程中,我们经常会用到防抖和节流,比如像下面这样

class MyClass {
  follow = debounce(function() {
    console.log('我是子君,关注我哦')
  }, 100)
}

const myClass = new MyClass()
// 多次调用只会输出一次
myClass.follow()
myClass.follow()

上面是一个防抖的例子,我们通过debounce函数将另一个函数包起来,实现了防抖的功能,这时候再有另一个需求,比如希望在调用follow函数前后各打印一段日志,这时候我们还可以再开发一个log函数,然后继续将follow包装起来

/**
 * 最外层是防抖,否则log会被调用多次
 */
class MyClass {
  follow = debounce(
    log(function() {
      console.log('我是子君,关注我哦')
    }),
    100
  )
}

上面代码中的debouncelog两个函数,本质上是两个包装函数,通过这两个函数对原函数的包装,使原函数的行为发生了变化,而js中的装饰器的原理就是这样的,我们使用装饰器对上面的代码进行改造

class MyClass {
  @debounce(100)
  @log
  follow() {
    console.log('我是子君,关注我哦')
  }
}

装饰器的形式就是 @ + 函数名,如果有参数的话,后面的括号里面可以传参

在方法上使用装饰器

装饰器可以应用到class上或者class里面的属性上面,但一般情况下,应用到class属性上面的场景会比较多一些,比如像上面我们说的log,debounce等等,都一般会应用到类属性上面,接下来我们一起来具体看一下如何实现一个装饰器,并应用到类上面。在实现装饰器之前,我们需要先了解一下属性描述符

了解一下属性描述符

在我们定义一个对象里面的属性的时候,其实这个属性上面是有许多属性描述符的,这些描述符标明了这个属性能不能修改,能不能枚举,能不能删除等等,同时ECMAScript将这些属性描述符分为两类,分别是数据属性和访问器属性,并且数据属性与访问器属性是不能共存的。

数据属性

数据属性包含一个数据值的位置,在这个位置可以读取和写入值。数据属性包含了四个描述符,分别是

  1. configurable

    表示能不能通过delete删除属性,能否修改属性的其他描述符特性,或者能否将数据属性修改为访问器属性。当我们通过let obj = {name: ''}声明一个对象的时候,这个对象里面所有的属性的configurable描述符的值都是true

  2. enumerable

    表示能不能通过for in或者Object.keys等方式获取到属性,我们一般声明的对象里面这个描述符的值是true,但是对于class类里面的属性来说,这个值是false

  3. writable

    表示能否修改属性的数据值,通过将这个修改为false,可以实现属性只读的效果。

  4. value

    表示当前属性的数据值,读取属性值的时候,从这里读取;写入属性值的时候,会写到这个位置。

访问器属性

访问器属性不包含数据值,他们包含了gettersetter两个函数,同时configurableenumerable是数据属性与访问器属性共有的两个描述符。

  1. getter

    在读取属性的时候调用这个函数,默认这个函数为undefined

  2. setter

    在写入属性值的时候调用这个函数,默认这个函数为undefined

了解了这六个描述符之后,你可能会有几个疑问: 我如何去定义修改这些属性描述符?这些属性描述符与今天的文章主题有什么关系?接下来是揭晓答案的时候了。

使用Object.defineProperty

了解过vue2.0双向绑定原理的同学一定知道,Vue的双向绑定就是通过使用Object.defineProperty去定义数据属性的gettersetter方法来实现的,比如下面有一个对象

let obj = {
  name: '子君',
  officialAccounts: '前端有的玩'
}

我希望这个对象里面的用户名是不能被修改的,用Object.defineProperty该如何定义呢?

Object.defineProperty(obj,'name', {
  // 设置writable 是 false, 这个属性将不能被修改
  writable: false
})
// 修改obj.name
obj.name = "君子"
// 打印依然是子君
console.log(obj.name)

通过Object.defineProperty可以去定义或者修改对象属性的属性描述符,但是因为数据属性与访问器属性是互斥的,所以一次只能修改其中的一类,这一点需要注意。

定义一个防抖装饰器

装饰器本质上依然是一个函数,不过这个函数的参数是固定的,如下是防抖装饰器的代码

/**
*@param wait 延迟时长
*/
function debounce(wait) {
  return function(target, name, descriptor) {
    descriptor.value = debounce(descriptor.value, wait)
  }
}
// 使用方式
class MyClass {
  @debounce(100)
  follow() {
    console.log('我是子君,我的公众号是 【前端有的玩】,关注有惊喜哦')
  }
}

我们逐行去分析一下代码

  1. 首先我们定义了一个 debounce函数,同时有一个参数wait,这个函数对应的就是在下面调用装饰器时使用的@debounce(100)
  2. debounce函数返回了一个新的函数,这个函数即装饰器的核心,这个函数有三个参数,下面逐一分析

    1. target: 这个类属性函数是在谁上面挂载的,如上例对应的是MyClass
    2. name: 这个类属性函数的名称,对应上面的follow
    3. descriptor: 这个就是我们前面说的属性描述符,通过直接descriptor上面的属性,即可实现属性只读,数据重写等功能
  3. 然后第三行 descriptor.value = debounce(descriptor.value, wait), 前面我们已经了解到,属性描述符上面的value对应的是这个属性的值,所以我们通过重写这个属性,将其用debounce函数包装起来,这样在函数调用follow时实际调用的是包装后的函数

通过上面的三步,我们就实现了类属性上面可使用的装饰器,同时将其应用到了类属性上面

class上使用装饰器

装饰器不仅可以应用到类属性上面,还可以直接应用到类上面,比如我希望可以实现一个类似Vue混入那样的功能,给一个类混入一些方法属性,应该如何去做呢?

// 这个是要混入的对象
const methods = {
  logger() {
    console.log('记录日志')
  }
}

// 这个是一个登陆登出类
class Login{
  login() {}
  logout() {}
}

如何将上面的methods混入到Login中,首先我们先实现一个类装饰器

function mixins(obj) {
  return function (target) {
    Object.assign(target.prototype, obj)  
  }
}

// 然后通过装饰器混入
@mixins(methods)
class Login{
  login() {}
  logout() {}
}

这样就实现了类装饰器。对于类装饰器,只有一个参数,即target,对应的就是这个类本身。

了解完装饰器,我们接下来看一下如何在Vue中使用装饰器。

Vue中使用装饰器

使用ts开发Vue的同学一定对vue-property-decorator不会感到陌生,这个插件提供了许多装饰器,方便大家开发的时候使用,当然本文的中点不是这个插件。其实如果我们的项目没有使用ts,也是可以使用装饰器的,怎么用呢?

配置基础环境

除了一些老的项目,我们现在一般新建Vue项目的时候,都会选择使用脚手架vue-cli3/4来新建,这时候新建的项目已经默认支持了装饰器,不需要再配置太多额外的东西,如果你的项目使用了eslint,那么需要给eslint配置以下内容。

  parserOptions: {
    ecmaFeatures:{
      // 支持装饰器
      legacyDecorators: true
    }
  }

使用装饰器

虽然Vue的组件,我们一般书写的时候export出去的是一个对象,但是这个并不影响我们直接在组件中使用装饰器,比如就拿上例中的log举例。

function log() {
  /**
   * @param target 对应 methods 这个对象
   * @param name 对应属性方法的名称
   * @param descriptor 对应属性方法的修饰符
   */
  return function(target, name, descriptor) {
    console.log(target, name, descriptor)
    const fn = descriptor.value
    descriptor.value = function(...rest) {
      console.log(`这是调用方法【${name}】前打印的日志`)
      fn.call(this, ...rest)
      console.log(`这是调用方法【${name}】后打印的日志`)
    }
  }
}

export default {
  created() {
    this.getData()
  },
  methods: {
    @log()
    getData() {
      console.log('获取数据')
    }
  }
}

看了上面的代码,是不是发现在Vue中使用装饰器还是很简单的,和在class的属性上面使用的方式一模一样,但有一点需要注意,在methods里面的方法上面使用装饰器,这时候装饰器的target对应的是methods

除了在methods上面可以使用装饰器之外,你也可以在生命周期钩子函数上面使用装饰器,这时候target对应的是整个组件对象。

一些常用的装饰器

下面小编罗列了几个小编在项目中常用的几个装饰器,方便大家使用

1. 函数节流与防抖

函数节流与防抖应用场景是比较广的,一般使用时候会通过throttledebounce方法对要调用的函数进行包装,现在就可以使用上文说的内容将这两个函数封装成装饰器, 防抖节流使用的是lodash提供的方法,大家也可以自行实现节流防抖函数哦

import { throttle, debounce } from 'lodash'
/**
 * 函数节流装饰器
 * @param {number} wait 节流的毫秒
 * @param {Object} options 节流选项对象
 * [options.leading=true] (boolean): 指定调用在节流开始前。
 * [options.trailing=true] (boolean): 指定调用在节流结束后。
 */
export const throttle =  function(wait, options = {}) {
  return function(target, name, descriptor) {
    descriptor.value = throttle(descriptor.value, wait, options)
  }
}

/**
 * 函数防抖装饰器
 * @param {number} wait 需要延迟的毫秒数。
 * @param {Object} options 选项对象
 * [options.leading=false] (boolean): 指定在延迟开始前调用。
 * [options.maxWait] (number): 设置 func 允许被延迟的最大值。
 * [options.trailing=true] (boolean): 指定在延迟结束后调用。
 */
export const debounce = function(wait, options = {}) {
  return function(target, name, descriptor) {
    descriptor.value = debounce(descriptor.value, wait, options)
  }
}

封装完之后,在组件中使用

import {debounce} from '@/decorator'

export default {
  methods:{
    @debounce(100)
    resize(){}
  }
}

2. loading

在加载数据的时候,为了个用户一个友好的提示,同时防止用户继续操作,一般会在请求前显示一个loading,然后在请求结束之后关掉loading,一般写法如下

export default {
  methods:{
    async getData() {
      const loading = Toast.loading()
      try{
        const data = await loadData()
        // 其他操作
      }catch(error){
        // 异常处理
        Toast.fail('加载失败');
      }finally{
        loading.clear()
      }  
    }
  }
}

我们可以把上面的loading的逻辑使用装饰器重新封装,如下代码

import { Toast } from 'vant'

/**
 * loading 装饰器
 * @param {*} message 提示信息
 * @param {function} errorFn 异常处理逻辑
 */
export const loading =  function(message = '加载中...', errorFn = function() {}) {
  return function(target, name, descriptor) {
    const fn = descriptor.value
    descriptor.value = async function(...rest) {
      const loading = Toast.loading({
        message: message,
        forbidClick: true
      })
      try {
        return await fn.call(this, ...rest)
      } catch (error) {
        // 在调用失败,且用户自定义失败的回调函数时,则执行
        errorFn && errorFn.call(this, error, ...rest)
        console.error(error)
      } finally {
        loading.clear()
      }
    }
  }
}

然后改造上面的组件代码

export default {
  methods:{
    @loading('加载中')
    async getData() {
      try{
        const data = await loadData()
        // 其他操作
      }catch(error){
        // 异常处理
        Toast.fail('加载失败');
      }  
    }
  }
}

3. 确认框

当你点击删除按钮的时候,一般都需要弹出一个提示框让用户确认是否删除,这时候常规写法可能是这样的

import { Dialog } from 'vant'

export default {
  methods: {
    deleteData() {
      Dialog.confirm({
        title: '提示',
        message: '确定要删除数据,此操作不可回退。'
      }).then(() => {
        console.log('在这里做删除操作')
      })
    }
  }
}

我们可以把上面确认的过程提出来做成装饰器,如下代码

import { Dialog } from 'vant'

/**
 * 确认提示框装饰器
 * @param {*} message 提示信息
 * @param {*} title 标题
 * @param {*} cancelFn 取消回调函数
 */
export function confirm(
  message = '确定要删除数据,此操作不可回退。',
  title = '提示',
  cancelFn = function() {}
) {
  return function(target, name, descriptor) {
    const originFn = descriptor.value
    descriptor.value = async function(...rest) {
      try {
        await Dialog.confirm({
          message,
          title: title
        })
        originFn.apply(this, rest)
      } catch (error) {
        cancelFn && cancelFn(error)
      }
    }
  }
}

然后再使用确认框的时候,就可以这样使用了

export default {
  methods: {
    // 可以不传参,使用默认参数
    @confirm()
    deleteData() {
      console.log('在这里做删除操作')
    }
  }
}

是不是瞬间简单多了,当然还可以继续封装很多很多的装饰器,因为文章内容有限,暂时提供这三个。

装饰器组合使用

在上面我们将类属性上面使用装饰器的时候,说道装饰器可以组合使用,在Vue组件上面使用也是一样的,比如我们希望在确认删除之后,调用接口时候出现loading,就可以这样写(一定要注意顺序)

export default {
  methods: {
    @confirm()
    @loading()
    async deleteData() {
      await delete()
    }
  }
}
本节定义的装饰器,均已应用到这个项目中 https://github.com/snowzijun/vue-vant-base, 这是一个基于Vant开发的开箱即用移动端框架,你只需要fork下来,无需做任何配置就可以直接进行业务开发,欢迎使用,喜欢麻烦给一个star

我是子君,今天就写这么多,本文首发于【前端有的玩】,这是一个专注于前端技术,前端面试相关的公众号,同时关注之后即刻拉你加入前端交流群,我们一起聊前端,欢迎关注。

结语

不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。 ——文森特・梵高
查看原文

赞 24 收藏 17 评论 0

子君 发布了文章 · 7月27日

封装element-ui表格,我是这样做的

日日加班至夜半,环视周围无人走;

明晚八点准时走,谁不打卡谁是狗。

使用过element-ui的表格的同学应该都有这样的体会,做一个简单的表格还比较容易,但如果这个表格包含了顶部的按钮,还有分页,甚至再包含了行编辑,那开发工作量就成倍的增加,特别是在开发管理系统的时候,表格一个接一个的去开发, 即浪费时间,还对个人没有什么提升。今天小编带来了自己封装的一个表格,让你用JSON就可以简单的生成表格。

本文主要集中于使用说明与核心代码说明,完整代码请访问 https://github.com/snowzijun/vue-element-table,如果觉得有用,麻烦给小编一个star,你的每一个star都是对小编的支持,当前功能比较简陋,本仓库将持续更新。同时您也可以微信搜索【前端有的玩】公众号,小编拉你进前端技术交流群。

表格需求

一般管理系统对表格会有以下需求

  1. 可以分页(需要有分页条)
  2. 可以多选(表格带复选框)
  3. 顶部需要加一些操作按钮(新增,删除等等)
  4. 表格每行行尾有操作按钮
  5. 表格行可以编辑

如下图为一个示例表格

如果我们直接使用element-ui提供的组件的话,那么开发一个这样的表格就需要使用到以下内容

  1. 需要使用表格的插槽功能,开发每一行的按钮
  2. 需要通过样式调整顶部按钮,表格,分页条的布局样式
  3. 需要监听分页的事件然后去刷新表格数据
  4. 顶部按钮或操作按钮如果需要获取表格数据,需要调用表格提供的api
  5. 对于有行编辑的需求,还需要通过插槽去渲染行编辑的内容,同时要控制行编辑的开关

不仅仅开发表格比较麻烦,而且还要考虑团队协作,如果每个人实现表格的方式存在差别,那么可能后期的维护成本也会变得很高。那怎么办呢?

项目安装

安装插件

在使用element-ui的项目中,可以通过以下命令进行安装

npm install vue-elementui-table -S

在项目中使用

main.js中添加以下代码

import ZjTable from 'vue-element-table'

Vue.use(ZjTable)

然后即可像下文中的使用方式进行使用

表格配置

为了满足团队快速开发的需要,小编对上面提出来的需求进行了封装,然后使用的时候,开发人员只需要配置一些JSON便可以完成以上功能的开发。

基础配置

一个基础的表格包含了数据和列信息,那么如何用封装的表格去配置呢?

<template>
  <zj-table
    :columns="columns"
    :data="data"
    :pagination="false"
  />
</template>
<script>
export default {
  data() {
    return {
      // 表格的列信息, 数组每一项代表一个字段,可以使用element 列属性的所有属性,以下仅为示例
      columns: Object.freeze([
        {
          // 表头显示的文字
          label: '姓名',
          // 对应数据里面的字段
          prop: 'name'
        },
        {
          label: '性别',
          prop: 'sex',
          // 格式化表格,与element-ui 的表格属性相同
          formatter(row, column, cellValue) {
            return cellValue === 1 ? '男' : '女'
          }
        },
        {
          label: '年龄',
          prop: 'age'
        }
      ]),
      data: [
        {
          name: '子君',
          sex: 1,
          age: 18
        }
      ]
    }
  }
}
</script>

通过上面的配置,就可以完成一个基础表格的开发,完整代码见 https://github.com/snowzijun/vue-element-table/blob/master/example/views/demo/base.vue,效果如下图所示

表格默认会显示复选框,也可以通过配置selectable属性来关闭掉

添加分页

简单的表格用封装之后的或未封装的开发工作量区别并不大,我们继续为表格添加上分页

<template>
    <!--
    current-page.sync 表示页码, 添加上 .sync 在页码发生变化时自动同步页码
    page-size.sync 每页条数
    total  总条数
    height="auto" 配置height:auto, 表格高度会根据内容自动调整,如果不指定,表格将保持充满父容器,同时表头会固定,不跟随滚动条滚动
    @page-change 无论pageSize currentPage 哪一个变化,都会触发这个事件
  -->
  <zj-table
    v-loading="loading"
    :columns="columns"
    :data="data"
    :current-page.sync="currentPage"
    :page-size.sync="pageSize"
    :total="total"
    height="auto"
    @page-change="$_handlePageChange"
  />
</template>
<script>
export default {
  data() {
    return {
      columns: Object.freeze([
        // 列字段与上例一样,此处省略
      ]),
      data: [],
      // 当前页码
      currentPage: 1,
      // 每页条数
      pageSize: 10,
      // 总条数
      total: 0,
      // 是否显示loading
      loading: false
    }
  },
  created() {
    this.loadData()
  },
  methods: {
    // 加载表格数据
    loadData() {
      this.loading = true
      setTimeout(() => {
        // 假设总条数是40条
        this.total = 40
        const { currentPage, pageSize } = this
        // 模拟数据请求获取数据
        this.data = new Array(pageSize).fill({}).map((item, index) => {
          return {
            name: `子君${currentPage + (index + 1) * 10}`,
            sex: Math.random() > 0.5 ? 1 : 0,
            age: Math.floor(Math.random() * 100)
          }
        })
        this.loading = false
      }, 1000)
    },
    $_handlePageChange() {
      // 因为上面设置属性指定了.sync,所以这两个属性会自动变化
      console.log(this.pageSize, this.currentPage)
      // 分页发生变化,重新请求数据
      this.loadData()
    }
  }
}
</script>

完整代码请参考 https://github.com/snowzijun/vue-element-table/blob/master/example/views/demo/pagination.vue

通过封装,表格将自带分页功能,通过上面代码,实现效果如下所示,是不是变得简单了一些。接下来我们继续给表格添加按钮

添加顶部按钮

表格上面可能会有新增,删除等等按钮,怎么办呢,接下来我们继续通过配置去添加按钮

<template>
  <zj-table
    :buttons="buttons"
  />
</template>
<script>
export default {
  data() {
    return {
      buttons: Object.freeze([
        {
          // id 必须有而且是在当前按钮数组里面是唯一的
          id: 'add',
          text: '新增',
          type: 'primary',
          icon: 'el-icon-circle-plus',
          click: this.$_handleAdd
        },
        {
          id: 'delete',
          text: '删除',
          // rows 是表格选中的行,如果没有选中行,则禁用删除按钮, disabled可以是一个boolean值或者函数
          disabled: rows => !rows.length,
          click: this.$_handleRemove
        },
        {
          id: 'auth',
          text: '这个按钮根据权限显示',
          // 可以通过返回 true/false来控制按钮是否显示
          before: (/** rows */) => {
            return true
          }
        },
        // 可以配置下拉按钮哦
        {
          id: 'dropdown',
          text: '下拉按钮',
          children: [
            {
              id: 'moveUp',
              text: '上移',
              icon: 'el-icon-arrow-up',
              click: () => {
                console.log('上移')
              }
            },
            {
              id: 'moveDown',
              text: '下移',
              icon: 'el-icon-arrow-down',
              disabled: rows => !rows.length,
              click: () => {
                console.log('下移')
              }
            }
          ]
        }
      ])
    }
  },
  created() {},
  methods: {
    // 新增
    $_handleAdd() {
      this.$alert('点击了新增按钮')
    },
    // 顶部按钮会自动将表格所选的行传出来
    $_handleRemove(rows) {
      const ids = rows.map(({ id }) => id)
      this.$alert(`要删除的行id为${ids.join(',')}`)
    },
    // 关注作者公众号
    $_handleFollowAuthor() {}
  }
}
</script>

表格顶部可以添加普通的按钮,也可以添加下拉按钮,同时还可以通过before来配置按钮是否显示,disabled来配置按钮是否禁用,上面完整代码见 https://github.com/snowzijun/vue-element-table/blob/master/example/views/demo/button.vue

通过上面的代码就可以配置出下面的表格,是不是很简单呢?

表格顶部可以有按钮,行尾也是可以添加按钮的,一起来看看

行操作按钮

一般我们会将一些单行操作的按钮放在行尾,比如编辑,下载等按钮,那如何给行尾配置按钮呢?

<template>
  <zj-table
    :columns="columns"
  />
</template>
<script>
export default {
  data() {
    return {
      columns: Object.freeze([
        {
          // 可以指定列的宽度,与element-ui原生用法一致
          width: 220,
          label: '姓名',
          prop: 'name'
        },
        // 行编辑按钮,在表格末尾出现,自动锁定右侧
        {
          width: 180,
          label: '操作',
          // 通过 actions 指定行尾按钮
          actions: [
            {
              id: 'follow',
              text: '关注作者',
              click: this.$_handleFollowAuthor
            },
            {
              id: 'edit',
              text: '编辑',
              // 可以通过before控制按钮是否显示,比如下面年龄四十岁的才会显示编辑按钮
              before(row) {
                return row.age < 40
              },
              click: this.$_handleEdit
            },
            {
              id: 'delete',
              text: '删除',
              icon: 'el-icon-delete',
              disabled(row) {
                return row.sex === 0
              },
              // 为了拿到this,这里需要用箭头函数
              click: () => {
                this.$alert('女生被禁止删除了')
              }
            }
          ]
        }
      ])
    }
  },
  methods: {
    // 关注作者公众号
    $_handleFollowAuthor() {
            console.log('微信搜索【前端有的玩】,这是对小编最大的支持')
    },
    /**
     * row 这一行的数据
     */
    $_handleEdit(row, column) {
      this.$alert(`点击了姓名为【${row.name}】的行上的按钮`)
    }
  }
}
</script>

行操作按钮会被冻结到表格最右侧,不会跟随滚动条滚动而滚动,上面完整代码见, https://github.com/snowzijun/vue-element-table/blob/master/example/views/demo/button.vue

通过上面的代码就可以完成以下效果

最后再来一起看看行编辑

行编辑

比如上例,我希望点击行尾的编辑按钮的时候,可以直接在行上面编辑用户的姓名与性别,如何配置呢?

<template>
  <zj-table
    ref="table"
    :columns="columns"
    :data="data"
  />
</template>
<script>
export default {
  data() {
    return {
      columns: Object.freeze([
        {
          label: '姓名',
          prop: 'name',
          editable: true,
          field: {
            componentType: 'input',
            rules: [
              {
                required: true,
                message: '请输入姓名'
              }
            ]
          }
        },
        {
          label: '性别',
          prop: 'sex',
          // 格式化表格,与element-ui 的表格属性相同
          formatter(row, column, cellValue) {
            return cellValue === '1' ? '男' : '女'
          },
          editable: true,
          field: {
            componentType: 'select',
            options: [
              {
                label: '男',
                value: '1'
              },
              {
                label: '女',
                value: '0'
              }
            ]
          }
        },
        {
          label: '年龄',
          prop: 'age',
          editable: true,
          field: {
            componentType: 'number'
          }
        },
        {
          label: '操作',
          actions: [
            {
              id: 'edit',
              text: '编辑',
              // 如果当前行启用了编辑,则不显示编辑按钮
              before: row => {
                return !this.editIds.includes(row.id)
              },
              click: this.$_handleEdit
            },
            {
              id: 'save',
              text: '保存',
              // 如果当前行启用了编辑,则显示保存按钮
              before: row => {
                return this.editIds.includes(row.id)
              },
              click: this.$_handleSave
            }
          ]
        }
      ]),
      data: [
        {
          // 行编辑必须指定rowKey字段,默认是id,如果修改为其他字段,需要给表格指定row-key="字段名"
          id: '0',
          name: '子君',
          sex: '1',
          age: 18
        },
        {
          // 行编辑必须指定rowKey字段,默认是id,如果修改为其他字段,需要给表格指定row-key="字段名"
          id: '1',
          name: '子君1',
          sex: '0',
          age: 18
        }
      ],
      editIds: []
    }
  },
  methods: {
    $_handleEdit(row) {
      // 通过调用 startEditRow 可以开启行编辑
      this.$refs.table.startEditRow(row.id)
      // 记录开启了行编辑的id
      this.editIds.push(row.id)
    },
    $_handleSave(row) {
      // 点击保存的时候,通过endEditRow 结束行编辑
      this.$refs.table.endEditRow(row.id, (valid, result, oldRow) => {
        // 如果有表单验证,则valid会返回是否验证成功
        if (valid) {
          console.log('修改之后的数据', result)
          console.log('原始数据', oldRow)
          const index = this.editIds.findIndex(item => item === row.id)
          this.editIds.splice(index, 1)
        } else {
          // 如果校验失败,则返回校验的第一个输入框的异常信息
          console.log(result)
          this.$message.error(result.message)
        }
      })
    }
  }
}
</script>

不需要使用插槽就可以完成行编辑,是不是很开心。上述完整代码见 https://github.com/snowzijun/vue-element-table/blob/master/example/views/demo/row-edit.vue

效果如下图所示:

其他功能

除了上面的功能之外,表格还可以配置其他许多功能,比如

  1. 可以指定字段为链接列,需要给列配置link属性
  2. 可以通过插槽自定义顶部按钮,行操作按钮,行字段等
  3. 可以在按钮区域右侧通过插槽配置其他内容
  4. 其他等等

表格开发说明

通过上面的代码示例,我们已经知道了封装之后的表格可以完成哪些事情,接下来一起来看看表格是如何实现的。完整代码见 https://github.com/snowzijun/vue-element-table/tree/master/src/components/zj-table

表格布局

整个表格是通过JSX来封装的,因为JSX使用起来更加灵活。对于我们封装的表格,我们从竖向可以分为三部分,分别是顶部按钮区,中间表格区,底部分页区,如何去实现三个区域的布局呢,核心代码如下

render(h) {
    // 按钮区域
    const toolbar = this.$_renderToolbar(h)
    // 表格区域
    const table = this.$_renderTable(h)
    // 分页区域
    const page = this.$_renderPage(h)

    return (
      <div class="zj-table" style={{ height: this.tableContainerHeight }}>
        {toolbar}
        {table}
        {page}
      </div>
    )
  }

通过三个render函数分别渲染对应区域,然后将三个区域组合在一起。

渲染表格列

通过前文的讲解,我们可以将表格的列分为以下几种

  1. 常规列
  2. 行编辑列
  3. 操作按钮列
  4. 插槽列
  5. 链接列(文档后续完善)
  6. 嵌套列(文档后续完善)
    $_renderColumns(h, columns) {
      // 整体是否排序
      let sortable = this.sortable ? 'custom' : false
      return columns
        .filter(column => {
          const { hidden } = column
          if (hidden !== undefined) {
            if (typeof hidden === 'function') {
              return hidden({
                columns,
                column
              })
            }
            return hidden
          }
          return true
        })
        .map(column => {
          const {
            useSlot = false,
            // 如果存在操作按钮,则actions为非空数组
            actions = [],
            // 是否可编辑列, 对于可编辑列需要动态启用编辑
            editable = false,
            // 是否有嵌套列
            nests,
            // 是否可点击
            link = false
          } = column
          let newSortable = sortable
          if (column.sortable !== undefined) {
            newSortable = column.sortable ? 'custom' : false
          }
          column = {
            ...column,
            sortable: newSortable
          }
          if (nests && nests.length) {
            // 使用嵌套列
            return this.$_renderNestColumn(h, column)
          } else if (editable) {
            // 使用编辑列
            return this.$_renderEditColumn(h, column)
          } else if (useSlot) {
            // 使用插槽列
            return this.$_renderSlotColumn(h, column)
          } else if (actions && actions.length > 0) {
            // 使用操作列
            column.sortable = false
            return this.$_renderActionColumn(h, column)
          } else if (link) {
            // 使用链接列
            return this.$_renderLinkColumn(h, column)
          } else {
            // 使用默认列
            return this.$_renderDefaultColumn(h, column)
          }
        })
    },

行编辑列

当前表格行编辑支持input,select,datepicker,TimeSelect,InputNumber等组件,具体渲染代码如下所示

// 编辑单元格
    $_renderEditCell(h, field) {
      const components = {
        input: Input,
        select: ZjSelect,
        date: DatePicker,
        time: TimeSelect,
        number: InputNumber
      }
      const componentType = field.componentType
      const component = components[componentType]
      if (component) {
        return this.$_renderField(h, field, component)
      } else if (componentType === 'custom') {
        // 如果自定义,可以通过component指定组件
        return this.$_renderField(h, field, field.component)
      }
      return this.$_renderField(h, field, Input)
    },
    $_renderField(h, field, Component) {
      // 编辑行的id字段
      const { rowId, events = {}, nativeEvents = {} } = field

      const getEvents = events => {
        const newEvents = {}
        Object.keys(events).forEach(key => {
          const event = events[key]
          newEvents[key] = (...rest) => {
            const args = [
              ...rest,
              {
                rowId,
                row: this.editRowsData[rowId],
                value: this.editRowsData[rowId][field.prop]
              }
            ]
            return event(...args)
          }
        })
        return newEvents
      }
      // 事件改写
      const newEvents = getEvents(events)
      const newNativeEvents = getEvents(nativeEvents)
      return (
        <Component
          size="small"
          on={newEvents}
          nativeOn={newNativeEvents}
          v-model={this.editRowsData[rowId][field.prop]}
          {...{
            attrs: field,
            props: field
          }}
        />
      )
    }

总结

这个表格包含了许多功能,文章长度优先,如果觉得有用,可以通过访问 https://github.com/snowzijun/vue-element-table 查看完整代码,本仓库代码及文档小编将持续完善,欢迎star。

结语

不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。 ——文森特・梵高
查看原文

赞 27 收藏 16 评论 2

认证与成就

  • 获得 291 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • vue-vant-base

    这个项目是将一些基础配置比如eslint,移动端适配等进行了封装,开发人员只需fork就可直接开箱即用,不用再做额外的配置

注册于 7月20日
个人主页被 4.3k 人浏览