blog icon indicating copy to clipboard operation
blog copied to clipboard

Vue.js 小窍门

Open lmk123 opened this issue 6 years ago • 0 comments

不要将常量或跟模版渲染无关的数据放在 data

之前在开发一个地图应用的时候,需要从接口获取将近 1MB 的点位数据,然后利用 canvas 渲染在地图上,在实际测试中,地图渲染变得非常慢,但发现慢的原因是因为点位数据被放在了 data 里。

这是因为 Vue 为了能监测到数据的变化,所以深度遍历了 data 里的每个属性,将它们变成了 getter/setter,这个 1MB 大小的点位数据其实并不会被 Vue 的模版消费,只是需要挂载在组件实例上供其它方法访问。

另外,我们的应用里其实总会有一些不会变的常量,例如一些只需要设置一次的配置,这些配置可能会显示在模版中,但之后都不会再变化了。

以上两种场景都可以通过直接给组件实例添加属性的方式,避免被 Vue 处理成 getter/setter:

<template>
  <div>
    在模版里还是能读到:{{ CONFIG }}
  </div>
</template>
<script>
  export default {
    data() {
      this.CONFIG = ... // 直接给实例赋值,不放在 data 里
      return {
        ...
      }
    },
    methods: {
      doSth() {
        // CONFIG 仍然可以在实例的任何地方通过 this 取得
        console.log(this.CONFIG)
      }
    }
  }
</script>

在不修改第三方组件源码的情况下给第三方组件扩充功能

有时候我们用了社区上的第三方组件,想给这个组件添加一些额外的功能,又不想让组件原本的功能被限制,这个时候可以利用下面的方法完全"复制"一个组件(这里以 el-input 为例):

<template>
  <!-- 将参数和事件监听函数全部传给 el-input -->
  <el-input v-bind="$attrs" v-on="$listeners">
    <!-- 将 slot 全部传给 el-input -->
    <slot v-for="slot in Object.keys($slots)" :name="slot" :slot="slot" />

    <!-- 将 scoped slot 全部传给 el-input -->
    <template
      v-for="slot in Object.keys($scopedSlots)"
      :slot="slot"
      slot-scope="scope"
    >
      <slot :name="slot" v-bind="scope" />
    </template>
  </el-input>
</template>

<script>
export default {
  name: 'my-input',
  inheritAttrs: false,
  props: {
    // 添加你自己的组件参数
    ...
  }
}
</script>

这样,你的 my-input 的组件拥有 el-input 的所有功能,同时你可以在此基础上扩充自己的功能。这个组件使用起来跟 el-input 一模一样:

<template>
  <my-input v-model="text">
    <div slot="after">搜索</div>
  </my-input>
</template>

参考链接:How to pass down slots inside wrapper component? - StackOverflow

利用计算属性处理组件的"双向绑定"属性

为了避免子组件 B 不经意间修改了父组件 A 的状态,子组件需要用 this.$emit('input', value) 的方式修改父组件的状态。

但有时候我们会在子组件里包装另一个组件 C,从父组件传过来的属性会直接传递给 C 的 v-model,为了让组件 C 对属性做的改变直接传递给父组件 A,我们可以利用计算属性:

<template>
  <!-- 组件 B 的代码 -->
  <div>
    <!-- 这里用 input 模拟组件 C 的 v-model -->
    <input type="text" v-model="innerValue">
  </div>
</template>

<script>
export default {
  name: 'cpt-b',
  props: ['value'],
  computed: {
    innerValue: {
      get() { return this.value }, // 读取值时使用父组件传过来的值
      set(value) { this.$emit('input', value) } // 组件 C 修改了值时传回给父组件
    }
  }
}
</script>

那么父组件 A 就可以愉快的使用 v-model 将自己的状态传给组件 C 了,并且组件 C 在改了状态后会反应到组件 A 上:

<template>
  <cpt-b v-model="text"></cpt-b>
</template>

<script>
export default {
  name: 'cpt-a',
  data() {
    return {
      text: 'hello vue.js'
    }
  },
  watch: {
    text() {
      console.log(this.text)
    }
  }
}
</script>

这种方法对使用了 .sync 修饰符的属性也适用。

利用事件区分一个状态的改变是来自用户还是组件自己

我们经常会利用 watch 监听一个状态的改变,然后做一些操作(例如加载数据),但有时候状态的改变不是来自用户,而是我们自己,并且这种情况下我们不希望 watch 被执行。

举个例子,我们现在有个组件,这个组件有两个单选框和一个数据展示区域,其中第一个单选框在选择之后会清空第二个单选框的值,而无论哪个单选框的值被改变,都需要重新加载数据。

如果使用 watch,那么我们大概会这样来组织我们的代码:

<template>
  <select v-model="value1">
    <option value="">全部</option>
    <option value="a">a</option>
    <option value="b">b</option>
  </select>

  <select v-model="value2">
    <option value="">全部</option>
    <option value="c">c</option>
    <option value="d">d</option>
  </select>

  {{ data }}
</template>

<script>
export default {
  data() {
    return {
      value1: '',
      value2: '',
      data: '正在获取数据……'
    }
  },
  methods: {
    loadData() {
      ajaxGetData({value1, value2}).then(data => {
        this.data = data
      })
    }
  },
  watch: {
    // 每次改变 value1 的值,都需要清空 value2 并刷新数据
    value1() {
      // 如果 value2 已经是空的了,那么简单的赋值不能触发它的 watch,所以我们得手动执行
      if (this.value2 === '') {
        this.loadData()
      } else {
        this.value2 = ''
      }
    },
    value2: 'loadData'
  },
  created() {
    // 组件创建完之后先获取一次数据
    this.loadData()
  }
}
</script>

目前来看这没什么问题,但有一天产品需要这个页面进入后,先从服务器获取上一次用户选中的状态,那么我们大概会这么改:

{
  created() {
    ajaxGetSelectedValue().then(({ value1, value2 }) => {
      this.value1 = value1
      this.value2 = value2
      this.loadData()
    })
  }
}

但是这样不行!value1value2 在被赋值后都触发了 watch,这样最坏的情况会导致数据被加载三次!

所以为了避免数据被触发多次,我们还需要继续改动代码,保证 watch 在必要的时候才执行,代码逻辑因此变得复杂了起来,你甚至需要深挖 Vue 的源码,确定上面代码中 value1value2 的 watch 哪个会被先执行、会被执行几次等等。

那么有什么更优雅的解决方案吗?

其实解决这个问题的根本在于,我们需要区分哪些情况一定需要做某些操作、哪些情况不需要。在这个例子里,当用户修改了状态,我们一定需要加载数据,但当组件自己修改了状态时,我们不一定需要——我们可以利用事件来区分状态的修改到底是来自用户还是来自我们自己。

现在,我们利用事件来重写这个组件:

<template>
  <!-- 注意这里的 @input -->
  <select v-model="value1" @input="() => { value2 = ''; loadData() }">
    <option value="">全部</option>
    <option value="a">a</option>
    <option value="b">b</option>
  </select>

  <!-- 注意这里的 @input -->
  <select v-model="value2" @input="loadData">
    <option value="">全部</option>
    <option value="c">c</option>
    <option value="d">d</option>
  </select>

  {{ data }}
</template>

<script>
export default {
  data() {
    return {
      value1: '',
      value2: '',
      data: '正在获取数据……'
    }
  },
  methods: {
    loadData() {
      ajaxGetData({value1, value2}).then(data => {
        this.data = data
      })
    }
  },
  // 我们现在已经没有 watch 了
  created() {
    ajaxGetSelectedValue().then(({ value1, value2 }) => {
      this.value1 = value1
      this.value2 = value2
      this.loadData()
    })
  }
}
</script>

v-model 其实是 :value="myValue" @input="myVale = $event" 的组合,但我们知道只有用户引起的状态改变才会触发 @input,而由于没有了 watch,所以状态改变了不会有任何副作用,我们可以任意修改状态,而不必担心会影响其它操作。

在设计组件的时候,我们也可以提供事件机制给我们的组件用户,帮助他们区分状态改变的来源。

Vue Router:403 页面的设计

内部系统一般都有权限控制,对于一个用户没有权限的 URL,我们会显示一个 403 页面给用户看,一般情况下,我们会用重定向的方式实现这个需求:

const router = new VueRouter({
  routes: [
    { path:'/', component: ... }
    // 403 是一个单独的 URL
    { path: '/403', component: ... }
  ]
})

router.beforeEach((to, from, next) => {
  // 如果用户没有页面权限,则重定向到 403 页面
  if (hasPermission(to)) return next('/403')
  next()
})

这样做其实没什么问题,但让略微处女座的我有点不爽的是:这种做法改变了用户浏览器的 URL。

举个例子,用户点击了别人分享给他的链接 /abc,但由于他没有权限,你给他重定向到了 /403,这时用户会复制浏览器里的地址发给你说:“给我加一下这个页面的权限。”然而你完全看不出来这是哪个页面。

另外,用户也可能会以为这个单独的 403 页面就是自己想访问的地方,他可能会在浏览器里将这个地址加入收藏夹,等自己有了权限之后再打开,但那时他仍然只能看到一个没有权限的提示。

简单来说,这一次重定向让用户(也让处理问题的我们)丢失了上下文。

我认为,URL 作为“统一资源定位符”,作为前端的我们应该确保:同一个 URL,无论哪个用户在什么时候打开,从始至终都指向同一个资源;即使没有权限、或者一段时间后页面被删除,也应该在这个 URL 下给出提示。

为了在 Vue Router 里实现这个目的,我们可以这样做:

const router = new VueRouter({
  routes: [
    { path:'/', component: ... }
    { path: '*', name: '404', component: ... } // 先放一个 404
    { path: '*', name; '403', component: ... } // 403 放在 404 后面,避免被当成 404 页面被匹配到
  ]
})

router.beforeEach((to, from, next) => {
  if (hasPermission(to)) {
    // 在不改变 URL 的情况下显示 403 界面
    next({
      name: '403',
      params: { '0': to.path } // 魔法代码 ;)
    })
    return
  }
  next()
})

上面那段魔法代码来自 https://github.com/vuejs/vue-router/issues/724#issuecomment-349966378

Vue Router:在不刷新页面的情况下修改 URL 里的 query string

这部分直接放个链接吧 :cry: How to set URL query params in Vue with Vue-Router - StackOverflow

lmk123 avatar Jul 17 '19 13:07 lmk123