如果说从0到1是解决温饱的过程,那么从1到100就是实现共同富裕的漫漫长路。
认真看过Vue.js官方文档和《Vue.js组件开发从0到1》的同学相信已经能胜任正常的业务开发了,但就像前文中提到的“实现同样一个小功能,可以有千万种写法”,作为一个积极向上的程序猿,我们始终在思考怎样才能写出优雅的代码,也就是更适合当前业务场景的代码。本文希望和大家共同探索学习如何从1到100优雅地开发Vue.js组件。
说到基于Vue.js的前端项目中的代码复用,个人总结下来有三种:
复用类型 | 复用形式 | 例子 |
---|---|---|
UI复用 | 组件 | 通用对话框 |
框架无关的纯js逻辑复用 | 工具类 | 日期时间转换工具类 |
与框架相关的逻辑复用(使用了框架的相关接口) | mixin | 页面渲染测速上报 |
UI复用我们已通过组件来实现。纯js逻辑的复用我们一般会把可复用代码抽离到一个工具类里,通过模块化的方式引入到各处使用。而与框架相关的逻辑代码怎么复用呢?例如这部分逻辑代码作为组件生命周期钩子函数的回调。Vue给我们提供了一种解决方案-混合 (mixins)。
mixins在官方文档中被定义为“一种分发 Vue 组件中可复用功能的非常灵活的方式”,其行为与继承特别像。 在mixin里定义的属性、方法会被自动挂载到使用该mixin的组件上。与继承不同的是,当mixin中定义的方法与组件中方法同名时,这两个同名方法都会被保留下来,并且默认优先执行mixin中的方法,而同名的是对象时,组件的键值对会覆盖mixin中对应的键值对。但选项合并的策略也是能自定义的,具体的例子请参考官方文档-选项合并和官方文档-自定义渲染合并策略,这里将举个页面渲染测速上报的例子来说明mixins的用法。
在页面(页面也就是一个组件)加载前(beforeCreate)记录时间点,在页面挂载到DOM后(mounted)再记录一个时间点,就能算出当前页面渲染耗时。这里可以看到,我们主要使用了Vue.js提供的生命周期钩子beforeCreate和mounted,代码如下:
<template>
<div>页面测速demodiv>
template>
<script>
export default {
startTime: 0,
beforeCreate() {
console.log('beforeCreate');
this.startTime = Date.now();
},
mounted() {
console.log('mounted');
console.log((Date.now() - this.startTime) + ' ms');
// 数据上报
}
};
script>
页面渲染过程(部分)如下timeline(时间线)所示:
页面测速逻辑是js逻辑,与UI代码复用无关,那这部分代码复用就只能归为上面提及的两种情形:
在页面测速时,我们必须依赖Vue.js提供的生命周期回调接口,所以属于与框架相关的逻辑复用。我们最多只能把计算逻辑和数据上报逻辑封装成工具类,在需要测速的页面中手动调用这些工具类完成测速需求。而mixins则能帮我们更进一步地封装和复用代码:
<template>
<div>页面测速demodiv>
template>
<script>
// 页面测速逻辑
// PS:应该抽离到mixins文件夹里统一管理,这里仅为演示方便
const renderSpeedTest = {
startTime: 0,
beforeCreate() {
console.log('beforeCreate');
this.startTime = Date.now();
},
mounted() {
console.log('mounted');
console.log((Date.now() - this.startTime) + ' ms');
}
}
// 页面逻辑
export default {
mixins: [renderSpeedTest]
};
script>
在需要测速的页面逻辑代码里添加mixins: [renderSpeedTest]
就能复用测速逻辑,这要比手动调用工具类更加优雅(简洁、不易出错、后期统一维护)。
但是前面已经提到,默认的选项合并策略是先执行mixin里的方法,再执行组件里的同名方法。而我们的页面测速需求是需要先执行mixin的beforeCreate方法作为开始点记录,后继续组件的生命周期(beforeCreate…mounted),最后执行mixin的mounted方法计算页面渲染时间。因此需要用到[自定义选项合并策略]来自定义同名选项的执行顺序,这里由于时间关系就不展开了。
“在 Vue2.0 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。” ——Vue官方文档 - 自定义指令
理解一样东西有两种方法:
个人觉得第一种方法将理解得更加深刻。归纳文档的话,指令的设计是为了覆盖DOM元素操作逻辑复用的情况。理解了设计初衷后,我们快速阅览Vue指令提供的钩子函数- bind、inserted、update、componentUpdated、unbind,就相当于学会了加减乘除,至于用加减乘除来做什么,更多的是看业务场景,看你对指令本身的理解。下面举个性能优化的例子来一起学习指令的用法。
性能优化场景:假设一个长页面的顶部有一个旋转的物体,在滚到页面底部时,虽然旋转物体不可见,但任在旋转,这是一个不必要的消耗。但类似的动画物体越来越多,滚动及后面的操作也会越来越卡顿。
性能优化方法:但旋转物体离开视图时,暂停旋转。重新进入视图前,恢复旋转。
借助指令,我们怎样优雅地解决这个问题:
<template>
<div>
<div>指令demodiv>
<div class="rotate-obj" v-scrollInOut="startOrStop">旋转物体div>
<ul>
<li v-for="i in testData">{{ i }}li>
ul>
div>
template>
<style scoped>
.rotate-obj { width: 100px; height: 100px; background: green; }
li { height: 100px; }
style>
<script>
import Vue from 'vue';
// 滚动检测逻辑
// PS:应该抽离到directives文件夹里统一管理,这里仅为演示方便
let io = null;
const scrollInOut = {
// 指令绑定
bind: function(el, binding) {
console.log('bind');
io = new IntersectionObserver(
entries => {
const entry = entries[0];
// 获取绑定的值
const callback = typeof binding.value === 'function' ? binding.value : null;
if (entry.intersectionRatio === 0 ) {
callback && callback('out of viewport');
} else {
callback && callback('in viewport');
}
}
);
io.observe(el);
},
// 指令解除绑定
unbind: function(el) {
io.unobserve(el);
io = null;
}
}
// 页面逻辑
export default {
data() {
return {
testData: [1,2,3,4,5,6,7,8,9];
}
},
methods: {
startOrStop(inOrOut) {
console.log('startOrStop: ', inOrOut);
}
},
directives: {
scrollInOut
}
};
script>
上面使用了浏览器一个新的接口IntersectionObserver API来监听具体元素是否在视窗内,可用于做图片懒加载等资源加载优化,具体内容请参考IntersectionObserver API 使用教程 - 阮一峰,简单易懂。总的来说,我们设计了一个指令,绑定该指令的DOM元素将拥有一个监听自身是否在视窗内的回调方法。类似地,我们可以用指令来封装一系列事件的绑定和组合,例如实现长按事件,滑动事件等。
结合作者设计的初衷、Vue指令提供的钩子函数以及上面的例子,相信大家在考虑怎么复用代码时能想起有指令这一种选择。
学习了组件、mixins和指令,大家几乎能针对每一种场景写出合适的、优雅的代码了。随着项目的发展,组件的逐步积累,天然会按照项目交互和视觉规范形成一套组件,这一套组件的沉淀,将是后续启动新项目的基石,怎么将已有项目的组件用到新项目或其他已有项目中,将是我们要讨论的问题。
复制粘贴是一个有效的方法,但显然算不上优雅。将组件以npm包的规范整理成一个组件库,同时利用npm包的更新机制解决组件更新同步的问题,是业界的常规做法,这里以饿了么Vue组件库 - mint-ui为例,和大家一起学习怎么去搭建一个组件库?
我们先来看看Mint UI官网里的介绍:
从使用方式来看,是标准的npm模块引入,再通过Vue插件规范来进行全局注册或者按需引入部分组件。从特性来看,上面列举的丰富齐全、关注移动端性能、复用代码减小体积都是具体实现的事情,和架构无关。因此我们的重点在于:
纵使设计得再高深,答案也在代码里。
通过简单观察代码库里的代码文件结构以及阅读组件库入口文件index.js,我已找到答案。mint-ui将组件统一放在packages文件夹里,在index.js文件中全部引入,再对外export所有组件的引入,以及最重要的install方法。了解Vue插件的同学都知道,install是Vue插件的入口函数,在调用Vue.use(MyPlugin)
来注册插件时,实际上会调用MyPlugin.install(Vue)
,所以入口文件index.js在入口函数install里面利用Vue.component
来全局注册组件或者利用Vue.use
来引入组件库里的插件。抽象来说,全部加载组件库这种做法,是通过Vue插件规范来实现的。
那按需加载是怎么实现的呢?下面介绍一个babel插件babel-plugin-component,babel主要用来将我们编写的es6、es7代码编译成浏览器兼容的es5代码,而babel-plugin-component则在编译过程中又做了一些小事:
// 编译前
import { Button } from 'components'
// 编译后
var button = require('components/lib/button')
require('components/lib/button/style.css')
至于这个css文件时怎么编译来的,通过阅读代码可知,.vue文件里采用css模块化解决方案——postcss来组织样式,可编译成单个css文件。
这么一说大家应该都明白了,babel-plugin-component具体配置请参考文档。mint-ui按照Vue插件的规范来组织代码,然后通过babel-plugin-component来实现按需加载。
而单个Vue组件怎么实现,在《Vue.js组件开发从0到1》中已详细介绍过。这里mint-ui将每一个组件都作为npm包,通过package.json文件指定入口文件为index.js,再在index.js里引入并对外暴露单vue文件(如果需要对插件进行逻辑操作,可以在这进行封装,前文也已经介绍过),如下:
export { default } from './src/button.vue';
看得比较浅显,简单回顾一下前面两个问题:
最后和大家分享一下之前阅读index.js的一个小困惑:
// auto install
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue);
}
这里的自动安装是为了使得通过直接引入mint-ui.js时能作为插件自动安装,之前一直想不明白,是局限于自己仅关注通过npm包这种使用场景,觉得这是一段冗余代码。
别钻牛角尖,学会多角度全面看待问题和自己遇到的困惑。
简单说一下:提供了一个render函数,使用底层方法构造任意想要的组件,返回。