滚动字幕的实现

曹至梧 2019-04-15 11:27 更新
  1. 定义问题
  2. 实现

1. 定义问题

1.1. 滚动过程

滚动字幕,简单来说,就是从下往上,把一些内容顺序组织之后,同步移动。

这个看似很简单的效果,在配合实际场景的“内容产生不确定性”这个特点之后,就会有一点点挑战了。至少,比可以乱飞,可重叠的 B 站式弹幕要麻烦得多。

从上面看,也许初步的思路,是创建很多 div 之后,不断计算它们的位置,就实现了同步滚动。这样处理不是不行,只是计算每个 div 位置,会有一些性能浪费,同时,因为每个 div 的高度是可能不同的,在判断“进入”及“完成”时,总是需要先取得正确的对象,并获取其高度之后,才能计算。这也就是说,你必须得保持每一个 div 的引用。这可不是明智的办法。

更容易的处理方式,是把这些 div 打包处理:

在单个包内部,通过 css 调整好间距之后,滚动的效果只是单个块滚动的结果。判断“进入”用“完成”需要保持每个块的引用,这在大部分时候,要比单个 div 少一个数量级。当然,极端情况,每个块中只有一个 div ,也就退化到前面一样的场景了。

为了达到滚动不间断的效果(单个块的高度,有可能比可视区高度大,也可能小),我们需要在适当的时候,在底部加入新的块。同时,为了稳定页面资源的消耗,也需要把完成显示的块给删除掉。

我个人在想像这个过程的时候,很自然会联想到机枪的装弹夹及换弹夹的过程。显示的过程,就是在输送弹夹的过程。内容移出显示区,就是打出了一颗子弹。一个弹夹的所有子弹都打完的时候,这个弹夹就需要被卸掉。同时,弹夹的输送需要是不间断的,一个弹夹接着一个弹夹(但是它们不能有重叠)。

基于这个比喻,整个过程大概变成了:

1.2. 准备过程

再看准备过程。

上面的第一步,当我们获取到一个弹夹的时候,把它置于初始位,然后控制它开始往上滚动。当滚动到第二步的位置时,我们需要再得到一个新的弹夹,把它置于第一步位置,如此反复。

在这一步,需要的抽象,是“获取弹夹”这个行为。

我们可以定义一个“弹夹工厂”,当需要弹夹的时候,总是去找它使就可以了。它一定会返回一个弹夹,哪怕是空弹夹。至于,这个“弹夹工厂”自己是如何获取子弹,如何生产弹夹,可以完全内化。

1.3. 回收过程

额外的这个回收过程,是为了处理一种“意外场景”。即在内容滚动完,又没有新的内容产生的时候,可以用旧的内容来代替。

但注意一点,在旧的内容滚动过程中,新的内容可能随时产生,所以,为了能尽快把新内容显示出来,我们可以把旧内容弹夹,永远只放一颗子弹,这样,这个弹夹就很“小”。一旦它达到第二步位置,我们就有机会去判断是否有新内容需要马上被放进来。

回收过程本身,也就是当弹夹被显示完之后,这个弹夹中的子弹,再次被送到弹夹工厂。当然,这个工厂弹夹生产策略,可能和新内容的那个工厂不同。除了前面说过了,一个弹夹只放一颗子弹之外,还有,比如,旧内容,一般我们只需要保留最新 N 条。

至此,整个问题就差不多是这样了。

2. 实现

2.1. 非界面抽象逻辑

滚动的实现,肯定是只能放在页面,通过控制 DOM 的属性来实现了。这种跟页面相关的,一般我们放在后面再去处理。因为与页面相关的代码,我们需要在浏览器中调试。而与界面无关的部分,就容易得多了,只需要打开编辑器,写完,运行就好了。

非界面的逻辑,很清楚地,就是上面的右侧,“弹夹工厂”,“弹夹”,“子弹”这套东西。

class Bullet {
    constructor(text){
        this.text = text;
    }
}

class Clip {
    constructor(){
        this.bulletList = [];
    }

    fill(bullet){
        this.bulletList.push(bullet);
    }

    isEmpty(){
        return this.bulletList.length === 0;
    }

    getBulletAmount(){
        return this.bulletList.length;
    }

    getBulletList(){
        return this.bulletList;
    }

}

class ClipFactory {
    constructor(bulletMaxAmountPerClip, maxClipAmount){
        this.bulletMaxAmountPerClip = bulletMaxAmountPerClip || 30;
        this.maxClipAmount = maxClipAmount || 0;
        this.clipList = [];
        this.currentClip = new Clip();
    }

    receive(bullet){
        this.currentClip.fill(bullet);
        if(this.currentClip.getBulletAmount() >= this.bulletMaxAmountPerClip){
            this.clipList.push(this.currentClip);
            this.currentClip = new Clip();
            if(this.maxClipAmount){
                if(this.clipList.length > this.maxClipAmount){
                    this.clipList.shift();
                }
            }
        }
    }

    pop(){
        let clip = this.clipList.shift();
        if(!clip){
            // 有可能是空弹夹
            clip = this.currentClip;
            this.currentClip = new Clip();
        }
        return clip;
    }
}

class StandbyClipFactory extends ClipFactory {
    constructor(){
        super(1, 30);
    }
}

上面代码中,最后多了一个 StandbyClipFactory ,是备用弹夹工厂。因为需要处理它的逻辑,所以弹夹工厂多出来了两个参数, bulletMaxAmountPerClipmaxClipAmount ,用于定义,单个弹夹的子弹数,及,工厂可保存的最大弹夹数。

所以定义写好之后,可以加上随机逻辑让它们跑起来看看:

function main() {
    const clipFactory = new ClipFactory();
    const standbyClipFactory = new StandbyClipFactory();

    const fillStandby = () => {
        const bullet = new Bullet('子弹');
        standbyClipFactory.receive(bullet);
        setTimeout(() => {
            fillStandby();
        }, Math.random() * 1000)
    }

    const fill = () => {
        const bullet = new Bullet('子弹');
        clipFactory.receive(bullet);
        setTimeout(() => {
            fill();
        }, Math.random() * 100)
    }

    const check = () => {
        let clip = clipFactory.pop();
        if(clip.isEmpty()){
            clip = standbyClipFactory.pop();
            console.log(clip, 'standby');
        } else {
            console.log(clip);
        }
        setTimeout(() => {
            check();
        }, Math.random() * 500)
    }

    fillStandby();
    fill();
    check();
}


if (require.main === module) {
    main();
}

2.2. 滚动显示逻辑

滚动的 DOM 结构是很简单的:

<div id="view">
  <div class="clip">
    <div class="bullet"><div class="inner">哈哈哈</div></div>
    <div class="bullet"><div class="inner">哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈</div></div>
  </div>
</div>

再看执行流程,无非是:

其实谈到了“获取策略”,和“回收策略”,很自然会想到“优先弹夹工厂”的逻辑,即,实现,某些内容一旦产生,是直接优先“插队”显示的。这步就不延展了。

流程列清楚了,代码不难:

class Engine {
    constructor(wrapper, clipFactory, standbyClipFactory){
        this.wrapper = wrapper;
        this.clipFactory = clipFactory;
        this.standbyClipFactory = standbyClipFactory;
        this.wrapperHeight = wrapper.clientHeight;
    }

    createClipElement(clip){
        const dom = document.createElement('div');
        dom.setAttribute('class', 'clip');
        clip.getBulletList().map(bullet => {
            const d = document.createElement('div');
            d.setAttribute('class', 'bullet');
            d.innerHTML = '<div class="inner">' + bullet.text + '</div>';
            dom.appendChild(d);
        });
        return dom;
    }

    move(clip, nextCallback, endCallback){

      const ele = this.createClipElement(clip);
      ele.style.opacity = 0;
      this.wrapper.appendChild(ele);

      let y = this.wrapperHeight;
      let delta = 1;
      let nextY = this.wrapperHeight - ele.clientHeight;
      let endY = -1 * ele.clientHeight;
      let status = 'before'; // before, in, end

      ele.style.transform = `translate3d(0, ${y}px, 0)`;
      ele.style.opacity = 1;
      const animation = () => {
        y -= delta;
        ele.style.transform = `translate3d(0, ${y}px, 0)`;

        if( (y <= nextY) && (status == 'before')){
          status = 'in';
          nextCallback();
        }
        if( (y <= endY) && (status === 'in') ){
          status = 'end';
          ele.remove();
          endCallback();
        }

        if(status === 'end'){
          return;
        }
        requestAnimationFrame(animation);
      };

      animation();
    }

    // 获取策略
    getNewClip(callback){
        let clip = this.clipFactory.pop();
        if(clip.isEmpty()){
          clip = this.standbyClipFactory.pop();
          if(clip.isEmpty()){
            setTimeout(() => {
              this.getNewClip(callback);
            }, 500);
            return;
          }
        }
        callback(clip);
    }

    // 回收策略
    onClipEnd(clip){
        clip.getBulletList().map(bullet => {
          this.standbyClipFactory.receive(bullet);
        });
    }

    run(){
      this.getNewClip(clip => {
        this.move(clip, this.run.bind(this), () => {this.onClipEnd(clip)});
      });
    }
}

2.3. 完整代码

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<title>滚动字幕</title>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/babel-standalone@6.15.0/babel.js"></script>
<style type="text/css">
  .view {
    width: 200px;
    height: 500px;
    background-color: #aaa;
    position: relative;
    overflow: hidden;
  }
  .bullet {
    padding: 10px;
    box-sizing: border-box;
  }
  .inner {
    background-color: white;
    padding: 5px;
  }
  .clip {
    transform: translate3d(0, 0px, 0);
    position: absolute;
    width: 100%;
  }
</style>
</head>
<body>
  <div id="app"></div>


  <script type="text/babel">
class Bullet {
    constructor(text){
        this.text = text;
    }
}

class Clip {
    constructor(){
        this.bulletList = [];
    }

    fill(bullet){
        this.bulletList.push(bullet);
    }

    isEmpty(){
        return this.bulletList.length === 0;
    }

    getBulletAmount(){
        return this.bulletList.length;
    }

    getBulletList(){
        return this.bulletList;
    }

}

class ClipFactory {
    constructor(bulletMaxAmountPerClip, maxClipAmount){
        this.bulletMaxAmountPerClip = bulletMaxAmountPerClip || 30;
        this.maxClipAmount = maxClipAmount || 0;
        this.clipList = [];
        this.currentClip = new Clip();
    }

    receive(bullet){
        this.currentClip.fill(bullet);
        if(this.currentClip.getBulletAmount() >= this.bulletMaxAmountPerClip){
            this.clipList.push(this.currentClip);
            this.currentClip = new Clip();
            if(this.maxClipAmount){
                if(this.clipList.length > this.maxClipAmount){
                    this.clipList.shift();
                }
            }
        }
    }

    pop(){
        let clip = this.clipList.shift();
        if(!clip){
            // 有可能是空弹夹
            clip = this.currentClip;
            this.currentClip = new Clip();
        }
        return clip;
    }
}

class StandbyClipFactory extends ClipFactory {
    constructor(){
        super(1, 30);
    }
}

class Engine {
    constructor(wrapper, clipFactory, standbyClipFactory){
        this.wrapper = wrapper;
        this.clipFactory = clipFactory;
        this.standbyClipFactory = standbyClipFactory;
        this.wrapperHeight = wrapper.clientHeight;
        this.isStop = false;
    }

    createClipElement(clip){
        const dom = document.createElement('div');
        dom.setAttribute('class', 'clip');
        clip.getBulletList().map(bullet => {
            const d = document.createElement('div');
            d.setAttribute('class', 'bullet');
            d.innerHTML = '<div class="inner">' + bullet.text + '</div>';
            dom.appendChild(d);
        });
        return dom;
    }

    move(clip, nextCallback, endCallback){

      const ele = this.createClipElement(clip);
      ele.style.opacity = 0;
      this.wrapper.appendChild(ele);

      let y = this.wrapperHeight;
      let delta = 1;
      let nextY = this.wrapperHeight - ele.clientHeight;
      let endY = -1 * ele.clientHeight;
      let status = 'before'; // before, in, end

      ele.style.transform = `translate3d(0, ${y}px, 0)`;
      ele.style.opacity = 1;
      const animation = () => {
        y -= delta;
        ele.style.transform = `translate3d(0, ${y}px, 0)`;

        if( (y <= nextY) && (status == 'before')){
          status = 'in';
          nextCallback();
        }
        if( (y <= endY) && (status === 'in') ){
          status = 'end';
          ele.remove();
          endCallback();
        }

        if(status === 'end'){ return }

        if(this.isStop){return}

        requestAnimationFrame(animation);
      };

      animation();
    }

    getNewClip(callback){
        let clip = this.clipFactory.pop();
        if(clip.isEmpty()){
          clip = this.standbyClipFactory.pop();
          if(clip.isEmpty()){
            setTimeout(() => {
              this.getNewClip(callback);
            }, 500);
            return;
          }
        }
        callback(clip);
    }

    run(){
      this.getNewClip(clip => {
        this.move(clip, this.run.bind(this), () => {
          clip.getBulletList().map(bullet => {
            this.standbyClipFactory.receive(bullet);
          });
        });
      });
    }

    stop(){
        this.isStop = true;
    }

}

  </script>

  <script type="text/babel">

    class MyComponent extends React.Component {
        constructor(props){
          super(props);
          this.state = {};
          this.state.counter = 0;
          this.state.genNumber = null;
          this.clipFactory = new ClipFactory();
          this.standbyClipFactory = new StandbyClipFactory();
          this.engine = null;
        }

        componentDidMount(){
          setInterval(() => {
            this.setState({counter: this.state.counter + 1});
          }, 1000);
        }

        componentWillUmount(){
          if(this.engine){this.engine.stop()}
        }

        onView(dom){
          if(!!this.engine){return}
          if(!dom){return}
          this.engine = new Engine(dom, this.clipFactory, this.standbyClipFactory);
          this.engine.run();
        }

        gen(){
          if(!this.state.genNumber){return}
          (new Array(this.state.genNumber)).fill(1).map(o => {
            const bullet = new Bullet(this.state.counter);
            this.clipFactory.receive(bullet);
          });
        }

        render(){
          return (
            <div>
              <div>滚动字幕显示 {this.state.counter}</div>
              <div style={{margin: 10}}>
                <span>随机生成</span>
                <input onChange={ e => {this.setState({genNumber: parseInt(e.target.value, 10)})}} />
                <span>条消息</span>
                <button onClick={this.gen.bind(this)}>确定</button>
              </div>
              <div className="view" key="view" ref={dom => this.onView(dom)}></div>
            </div>
          )
        }
    }
    ReactDOM.render(<MyComponent />, document.getElementById('app'));
  </script>
</body>
</html>
评论
粤ICP备18106170号-1