Appearance
自定义指令 v-click-outside
点击当前元素外部时,会执行传入的函数。一般用于点击其他地方,关闭弹窗或者关闭下拉框,隐藏元素等。
html
<div v-click-outside="hide">
<button @click="open">打开弹窗</button>
<div v-if="show">我是弹窗</div>
</div>
<script>
function open() {
this.show = true
}
function hide() {
this.show = false
}
</script>js
Vue.directive('clickOutside', {
bind(el, bindings, vnode) {
el.handler = function (e) {
if (!el.contains(e.target)) {
const methed = bindings.expression
vnode.context[methed]()
}
}
document.addEventListener('click', el.handler)
},
unbind(el) {
document.removeEventListener('click', el.handler)
},
})大屏适配
开发可视化数据大屏如何做自适应?vw vh、rem、scale 到底哪种比较好?
适配方案分析
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| vw + vh(推荐) | 按照设计稿的尺寸,通过 postcss-px-to-viewport 插件,将 px 转换成 vw 和 vh | 1、可以动态变更图表宽高、字体等。2、屏幕比列不一致的时候,不会出现留白。 | 1、每个图表都需要单独设置字体、间距等适配。2、postcss-px-to-viewport无法把行内样式中的 px 转换,需要自己写方法。 |
| scale | 通过 transform: scale 属性,根据屏幕大小,进行整体缩放 | 配置简单、不需要对每个图表进行适配 | 1、当屏幕比例跟设计稿不一样时,可能会出现留白情况。2、弹窗无法对应缩放。3、当缩放比例过大时,图表事件热区会偏移,图表字体会有一点点模糊。4、会导致地图坐标偏移。 |
| rem | 通过 postcss-pxtorem 插件,自动将 px 转成 rem | 配置简单、不需要对每个图表进行适配 | 1、当屏幕比例跟设计稿不一样时,可能会出现留白情况。2、每个图表都需要单独设置字体、间距等适配 |
vw、vh 方案
- 通过
postcss-px-to-viewport插件把 css 文件中的 px 自动转成 vw、vh。
js
// postcss.config.js
module.exports = {
plugins: [
postcssPxToViewport({
unitToConvert: 'px', // 要转化的单位
viewportWidth: 1920, // UI设计稿的宽度
unitPrecision: 6, // 转换后的精度,即小数点位数
propList: [
'width',
'left',
'right',
'margin-left',
'margin-right',
'padding-left',
'padding-right',
], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
viewportUnit: 'vw', // 指定需要转换成的视窗单位,默认vw
fontViewportUnit: 'vw', // 指定字体需要转换成的视窗单位,默认vw
minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
mediaQuery: true, // 是否在媒体查询的css代码中也进行转换,默认false
replace: true, // 是否转换后直接更换属性值
exclude: [/node_modules/], // 设置忽略文件,用正则做目录名匹配
landscape: false, // 是否处理横屏情况
}),
postcssPxToViewport({
unitToConvert: 'px', // 要转化的单位
viewportWidth: 1080, // UI设计稿的宽度
unitPrecision: 6, // 转换后的精度,即小数点位数
propList: [
'height',
'line-height',
'top',
'bottom',
'margin-top',
'margin-bottom',
'padding-top',
'padding-bottom',
], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
viewportUnit: 'vh', // 指定需要转换成的视窗单位,默认vw
fontViewportUnit: 'vh', // 指定字体需要转换成的视窗单位,默认vw
minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
mediaQuery: true, // 是否在媒体查询的css代码中也进行转换,默认false
replace: true, // 是否转换后直接更换属性值
exclude: [/node_modules/], // 设置忽略文件,用正则做目录名匹配
landscape: false, // 是否处理横屏情况
}),
],
}- 处理行内样式
js
// 定义设计稿的宽高
const designWidth = 1920
const designHeight = 1080
// px转vw
export const px2vw = (_px) => {
return (_px * 100) / designWidth + 'vw'
}
export const px2vh = (_px) => {
return (_px * 100) / designHeight + 'vh'
}
export const px2font = (_px) => {
return (_px * 100) / designWidth + 'vw'
}- 处理图表中的文字及偏移等样式
echarts 的字体大小只支持具体数值(像素),不能用百分比或者 vw 等尺寸,一般字体不会去做自适应,当宽高比跟 ui 稿比例出入太大时,会出现文字跟图表重叠的情况。
所以我们利用当前屏幕宽度跟设计稿宽度的比例,去重新计算 px 值。
js
const fitChartSize = (size, defalteWidth = 1920) => {
let clientWidth =
window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth
if (!clientWidth) return size
let scale = clientWidth / defalteWidth
return Number((size * scale).toFixed(3))
}
// echarts 配置项中使用
const options = {
grid: {
left: fitChartSize(10),
right: fitChartSize(20),
},
textStyle: {
fontSize: fitChartSize(14),
},
}- 窗口大小变化之后,图表自动调整,利用
element-resize-detector插件,再加上封装自定义指令,实现自动调用 echarts 的 resize 方法。省得每个图表都写一个监听事件。
js
// directive.js
import * as ECharts from 'echarts'
import elementResizeDetectorMaker from 'element-resize-detector'
// 自定义指令:v-chart-resize 或 v-chart-resize="fn"
Vue.directive('chart-resize', {
bind(el, binding) {
el['_vue_resize_handler'] = binding.value
? binding.value
: () => {
let chart = ECharts.getInstanceByDom(el)
if (!chart) {
return
}
chart.resize()
}
// 监听绑定的div大小变化,更新 echarts 大小
elementResizeDetectorMaker().listenTo(el, el['_vue_resize_handler'])
},
unbind(el) {
// window.removeEventListener("resize", el['_vue_resize_handler']);
elementResizeDetectorMaker().removeListener(el, el['_vue_resize_handler'])
delete el['_vue_resize_handler']
},
})
// <div v-chart-resize class="chart"></div>这里要注意的是,图表中如果需要 tab 切换动态更新图表数据,在更新数据时一定不要用 echarts 的 dispose 方法先将图表移除,再重新绘制,因为 resize 指令中挂载到的图表实例还是旧的,就监听不到新的 chart 元素的 resize 了,更新数据只需要用 chart 的 setOption 方法重新设置配置项即可。
scale 方案
实现思路:
- 当
屏幕宽高比 < 设计稿宽高比,我们需要缩放的比例是屏幕宽度 / 设计稿宽度。 - 当
屏幕宽高比 > 设计稿宽高比,我们需要缩放的比例是屏幕高度 / 设计稿高度。 - 如何居中,默认动画的基点是元素中心点,但是我们需要设置成左上角
transform-origin: 0 0;
如果我们拿到的设计稿宽高为: 1920 * 1080,而我们的屏幕大小是 1440 * 900,那么 1440/900 = 1.6,1920/1080 = 1.8, 我们需要缩放的比例是:屏幕宽度除以设计稿宽度 = 1440/1920 = 0.75
html
<div class="screen-wrapper">
<div class="screen" id="screen"></div>
</div>
<style>
.screen-root {
height: 100%;
width: 100%;
}
.screen {
display: inline-block;
width: 1920px; /*设计稿的宽度*/
height: 960px; /*设计稿的高度*/
transform-origin: 0 0;
position: absolute;
left: 50%;
top: 50%;
}
</style>js
export default {
mounted() {
// 初始化自适应 ----在刚显示的时候就开始适配一次
handleScreenAuto()
// 绑定自适应函数 ---防止浏览器栏变化后不再适配
window.onresize = () => handleScreenAuto()
},
destory() {
window.onresize = null
},
methods: {
// 数据大屏自适应函数
handleScreenAuto() {
const designDraftWidth = 1920 //设计稿的宽度
const designDraftHeight = 960 //设计稿的高度
// 根据屏幕的变化适配的比例
const scale =
document.documentElement.clientWidth /
document.documentElement.clientHeight <
designDraftWidth / designDraftHeight
? document.documentElement.clientWidth / designDraftWidth
: document.documentElement.clientHeight / designDraftHeight
// 缩放比例
document.querySelector(
'#screen'
).style.transform = `scale(${scale}) translate(-50%, -50%)`
},
},
}也可以偷懒用第三方插件 v-scale-screen
rem 方案
动态设置 html 的 font-size,再利用 postcss-pxtorem 插件把 px 转成 rem
js
;(function init(screenRatioByDesign = 16 / 9) {
let docEle = document.documentElement
function setHtmlFontSize() {
var screenRatio = docEle.clientWidth / docEle.clientHeight
var fontSize =
((screenRatio > screenRatioByDesign
? screenRatioByDesign / screenRatio
: 1) *
docEle.clientWidth) /
10
docEle.style.fontSize = fontSize.toFixed(3) + 'px'
console.log(docEle.style.fontSize)
}
setHtmlFontSize()
window.addEventListener('resize', setHtmlFontSize)
})()js
// postcss.config.js
module.exports = {
plugins: [
postcssPxToRem({
rootValue: 16,
unitPrecision: 3,
propList: ['font', 'font-size', 'line-height', 'letter-spacing'],
selectorBlackList: [],
replace: true,
mediaQuery: false,
minPixelValue: 0,
exclude: /node_modules/i,
}),
],
}Web截取视频封面
通过 canvas 实现,封装一个组件:
- 传入 file 视频文件对象
- 返回截取图片的 file 文件对象,用于接口上传
html
<!-- video-to-img 组件 -->
<template>
<div class="video-to-img">
<img class="img" v-if="coverUrl" :src="coverUrl" alt="视频封面">
<video
ref="videoRef"
class="video"
controls
crossOrigin="anonymous"
@loadedmetadata="onVideoLoaded"
></video>
<canvas class="canvas" ref="canvasRef"></canvas>
</div>
</template>
<script >
export default {
props: {
currentTime: { // 截取视频时间点,单位秒
type: Number,
default: 2,
},
},
data() {
return {
coverUrl: '',
}
},
methods: {
onVideoChange(file) {
const url = URL.createObjectURL(file);
this.$refs.videoRef.src = url;
},
onVideoLoaded() {
const video = this.$refs.videoRef;
video.currentTime = this.currentTime;
video.addEventListener('seeked', this.drawFrameToCanvas, { once: true });
},
drawFrameToCanvas() {
const video = this.$refs.videoRef;
const canvas = this.$refs.canvasRef;
canvas.width = video.videoWidth || 700;
canvas.height = video.videoHeight || 300;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const coverFile = new File([blob], 'cover.png', { type: 'image/png' });
this.coverUrl = URL.createObjectURL(coverFile);
this.$emit('success', coverFile);
}, 'image/png', 0.92);
},
}
}
</script>
<style lang="scss" scoped>
.video-to-img {
.img {
width: 300px;
height: 150px;
object-fit: cover;
}
.video {
display: none;
}
.canvas {
display: none;
}
}
</style>在页面中使用
html
<template>
<div id="app">
<input type="file" accept="video/*" @change="onVideoChange" />
<video-to-img ref="toImgRef" @success="handleSuccess"></video-to-img>
</div>
</template>
<script>
import VideoToImg from './video-to-img.vue';
export default {
components: {
VideoToImg,
},
data() {
return {
}
},
methods: {
onVideoChange(e) {
const file = e.target.files[0];
if (!file) return;
this.$refs.toImgRef.onVideoChange(file);
},
handleSuccess(file) {
console.log('封面图文件:', file);
},
}
}
</script>uniapp 截取视频封面
目前只支持 app-vue 和 h5 两端。并且在ios app 上还有点问题。
html
<!-- video-to-img 组件 -->
<template>
<view class="video-to-image">
<view class="img-box" :config="config" :change:config="canvas.changeConfig" :prop="videoSrc" :change:prop="canvas.changeVideo"></view>
<canvas class="canvas" canvas-id="firstCanvas" id="firstCanvas"></canvas>
</view>
</template>
<script>
export default {
props: {
path: {
type: String,
default: '',
},
config: {
type: Object,
default: () => {
return {};
}
},
returnType: {
type: String,
default: 'base64',
validator(val) {
return ['base64', 'tempFilePath'].includes(val);
}
}
},
data() {
return {
videoSrc: '',
offscreenCanvas: null,
system: null,
};
},
watch: {
path: {
handler(val) {
if(val) {
this.onVideoChange(val);
}
},
immediate: true
}
},
mounted() {
this.system = uni.getSystemInfoSync();
setTimeout(() => {
this.offscreenCanvas = uni.createCanvasContext('firstCanvas', this);
}, 500);
},
methods: {
onVideoChange(videoPath) {
let url = '';
if(videoPath.startsWith('file:/')) {
url = videoPath;
} else {
url = 'file://' + plus.io.convertLocalFileSystemURL(videoPath);
}
this.videoSrc = url;
},
getBase64(obj) {
const defaults = {
currentTime: 2,
width: 350,
height: 150,
};
const _config = {
...defaults,
...this.config,
}
if(this.returnType === 'base64') {
this.$emit('success', obj.src);
} else {
this.offscreenCanvas.drawImage(obj.src, 0, 0, _config.width, _config.height);
uni.canvasToTempFilePath({
canvasId: 'firstCanvas',
canvas: this.offscreenCanvas,
success: (result) => {
this.$emit('success', result.tempFilePath);
}
});
}
}
}
};
</script>
<script module="canvas" lang="renderjs">
export default {
data() {
return {
ownerInstance: null,
videos: null,
_config: {
currentTime: 2,
width: 350,
height: 150,
}
}
},
methods: {
changeConfig(newValue) {
const defaults = {
currentTime: 2,
width: 350,
height: 150,
};
this._config = {
...defaults,
...newValue
};
},
changeVideo(newValue, oldValue, instance) {
if(!newValue) return;
this.ownerInstance = instance;
this.videos = document.createElement("video");
this.videos.src = newValue;
this.videos.crossOrigin = 'anonymous';
this.videos.muted = true;
this.videos.autoplay = true;
this.videos.style.width = this._config.width + 'px';
this.videos.style.height = this._config.height + 'px';
this.videos.currentTime = this._config.currentTime;
this.videos.addEventListener('seeked', this.drawFrameToCanvas, {once: true});
},
drawFrameToCanvas() {
let canvas = document.createElement('canvas');
canvas.width = this._config.width;
canvas.height = this._config.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(this.videos, 0, 0, canvas.width, canvas.height);
const imgSrc = canvas.toDataURL("image/png");
this.ownerInstance.callMethod('getBase64', {
src: imgSrc
});
this.videos.removeEventListener('seeked', this.drawFrameToCanvas);
this.videos = null;
canvas = null;
}
}
}
</script>
<style lang="scss" scoped>
.video-to-image {
.img-box {
width: 700rpx;
height: 350rpx;
.img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 1170px;
height: 2532px;
}
}
</style>在页面中使用
html
<template>
<view class="pages">
<button @click="handleClick">选择视频</button>
<image class="img" :src="coverUrl" mode="aspectFill"></image>
<video-to-image :path="videoPath" @success="handleSuccess"></video-to-image>
<!-- returnType="tempFilePath" -->
</view>
</template>
<script>
export default {
data() {
return {
coverUrl: '',
videoPath: ''
}
},
methods: {
handleClick() {
uni.chooseVideo({
maxDuration: 60,
success: (res) => {
console.log('选择的文件', res.tempFilePath)
this.videoPath = res.tempFilePath;
},
})
},
handleSuccess(val) {
this.coverUrl = val;
console.log('返回的数据', val)
},
}
}
</script>