前言
之前写了一遍文章,如何开发一个可以拖拽,可以resize的组件,当中实现了一个可以在“轨道”内拖拽和resize素材的组件,如下:
后来觉得这种交互不能把“素材”在轨道间移动,就参照“剪映”的交互改良了一下,现在是下面这样。
这样一来需要处理的逻辑就变多了,有一些很头疼的问题:
- 组件间传值增加很多
- 有一些参数按道理应该放在子组件里,但又常常必须在外面使用。
- Vue组件中既要处理界面交互逻辑,又要维护数据,写得头大。
其实逻辑不怕复杂,就怕没有边界,泄漏而分散。
解题
数据层抽象
我们先不管界面怎么交互,比如何实现拖拽,如何缩放。我们先把交互后要做的事想清楚,相信我,当你写完数据抽象层,再写交互就像水到渠成一样简单。
这个“视频剪辑器”中目前有三个概念:时间轴 / 轨道 / 素材。时间轴里面有很多轨道 ,轨道里有很多素材,就这么简单。三个概念分别对应三个类,这里定一下类名:Timeline / Track / Item。
时间轴
我们先说时间轴,就是最外层的这个概念。
- 眼前显而易见的,它应该有一个属性叫 tracks ,应该是一个数组,里面放了很多 Track 类的对象
- 因为这个时间轴要支持缩放,那一定有一个比例尺的概念。所谓比例尺就是一个数字,表示当前界面上应该用多少个像素来表示1秒时长。
- 除了属性以外,它还应该提供一系列方法,让调用者方便地对时间轴进行操作,比如最容易想到的一个方法
addTrack(track)
,往时间轴加一个轨道。 - 我计划后续有一个交互,鼠标在时间轴上划动的时候,有一个指针贯穿所有轨道,此时预览画面上会显示当前时刻所有应该展示的元素,实际上就是快速预览,剪辑器最基本的一个功能。那么我就要规划一个方法,可以通过时间点,获取这个时间点所有的元素。名字应该是
getItemsByTime(time)
- 当然还有很多方法,在需要时再加,我写一个初版的
Timeline
类,有兴趣的可以概读一下,短短几十行代码,做了很多事。
export default class Timeline {
constructor(options) {
const defaultOptions = {
scale: 60, // 60px = 1s
tracks: []
}
this.options = Object.assign(defaultOptions, options)
this.scale = this.options.scale
this.tracks = this.options.tracks
}
addTrack(track) {
track.timeline = this
this.tracks.push(track)
}
removeTrack(track) {
this.tracks = this.tracks.filter((t) => t.id !== track.id)
}
addItemToTrack(track, item) {
track.items.push(item)
}
getItemById(id) {
let item = null
this.tracks.forEach((track) => {
track.items.forEach((i) => {
if (i.id === id) {
item = i
return
}
})
})
return item
}
getItemsByTime(time) {
const items = []
this.tracks.forEach((track) => {
track.items.forEach((i) => {
if (i.start <= time && i.start + i.duration >= time) {
items.push(i)
}
})
})
return items
}
// 把一个 track 加在指定track 后面
insertTrack(track, preTrack) {
const index = this.tracks.indexOf(preTrack)
if (index < 0) {
throw new Error('preTrack not found')
} else {
this.tracks.splice(index, 0, track)
}
}
// 通过遍历时间轴中所有的item,可以找到最后一个item,这个item的结尾其实就是整个时间轴的结尾
get end() {
let end = 0
this.tracks.forEach((track) => {
track.items.forEach((i) => {
if (i.end > end) {
end = i.end
}
})
})
return end
}
// end 得到的“结尾”是以时间为单位的,比如42秒,
// 这里结合“比例尺”就可以算出这个时间轴应该在界面上宽多少像素
get lengthInpx() {
return this.end * this.scale * 1.2 // 1.2倍系数,防止最后一个item无法往右拖
}
}
轨道
有了上面的铺垫,现在想想轨道这个类?
- 它应该有一个
items
属性,用来干嘛不用我说了。 - 我习惯在实体类里面加一个
id
字段,作为对象的唯一标识,一般来说用一个UUID就行。这常常会派上大用场,因为前端很多地方是不能存引用的,而字符串序列化起来就方便了。比如你不可能把一个对象,存到localStorage
里面,你只能把它的ID
存起来,需要找它时,用ID到内存里找。实际我在本组件中也用到了ID
,在拖拽元素时,我用到了drag / drop 相关API,drag 和 drop 之前传递数据时就不可以用引用,只能用可以序列化的值。细节可以看这里。 - 同样下面简单写一下
Track
类,这个类代码就更少了。
export default class Track {
constructor(items = [], timeline) {
this.id = crypto.randomUUID()
this.items = items
this.timeline = timeline
}
addItem(item) {
this.items.push(item)
item.track = this
this.items = this.items.sort((a, b) => {
return a.start - b.start
})
}
removeItem(item) {
this.items = this.items.filter((i) => i.id !== item.id)
}
getItemById(id) {
return this.items.find((i) => i.id === id)
}
getItemByTime(time) {
return this.items.find((i) => {
return i.start <= time && i.start + i.duration >= time
})
}
insertAfter(preTrack, timeline = this.timeline) {
timeline.insertTrack(this, preTrack)
}
}
元素
一个元素item
会有哪些东西呢?
- 一样的我给它一个ID。
- 元素有很多种,有图片 / 视频 / 音频 / 文字 / 特效 …,所有得有一个
type
属性。 - 元素在轨道上最重要的一个信息就是,它何时出现,何时消失。
- 最后有一点很重要,我在说上面说“轨道”的时候没有说,现在我啰嗦两句。虽然在概念上,时间轴包含轨道,轨道包含元素,一级一级下来的。但我们在元素里记录一下自己属于哪个轨道,在很多时候会让你操作非常方便,非常爽,即优雅。仔细体会一下下面的
moveToTrack(track)
,如果没有记录自己当前在哪个track
里,你是不是又要到Timeline
中遍历所有track
,再从track
中遍历所有item
,看自己在哪里?而界面上拖拽操作时,我只能知道两个信息,即现在正在拖的是哪个item
,放开鼠标的时候,我drop在了哪个轨道上,moveToTrack(track)
这个方法只需要这两个信息就可帮你把一个元素从一个轨道移到另一个轨道上。
/* {
id: '14f055d4-7be0-4913-abbb-b0528f562a5d',
type: 'image',
start: 0.9,
duration: 2.523
} */
export default class Item {
constructor(type, start, duration, track) {
this.id = crypto.randomUUID()
this.type = type
this.start = start
this.duration = duration
this.track = track
}
// 通用 start , duration 可以“虚拟”出一个属性,为了后续使用方便
get end() {
return this.start + this.duration
}
moveToTrack(track) {
this.track.removeItem(this)
this.track = track
track.addItem(this)
}
}
解放大脑
上一章节中的三个类已经帮我们安排好了所有逻辑和工具,下面我们只要画画 Dom 就行了。下面这段代码是模拟一些数据,
import { reactive } from 'vue'
const mockData = new Timeline({
scale: 10
})
for (let i = 0; i < 3; i++) {
const track = new Track()
for (let j = 0; j < 10; j++) {
const item = new Item('image', j * 10, 5)
track.addItem(item)
}
mockData.addTrack(track)
}
const timeline = reactive(mockData)
-
每一个
Item
位置和大小已经确定了,位置(left)是item.start * item.track.timeline.scale
,宽度是item.duration * item.track.timeline.scale
-
拖拽前记录一下鼠标位置(e.pageX),放开后再记录一下位置,
item.start = (nowPageX - oldPageX) / item.track.timeline.scale
,简简单单你就可以看到界面上被拖动的元素位置更新了。 -
元素的 resize 操作也是差不多的道理,缩放就更简单了。
// 调整比例尺 function changeScale(e) { const temp = timeline.scale + e.wheelDelta / 10 timeline.scale = Math.max(temp, 1) // 最小1 }
留个思考题,顶部的“时间标尺”是怎么实现的?