我发现了一个有意思的文字动效,看我手动逆向源码,把它移植到三大框架上

2,020 阅读19分钟

发现

本文代码地址:color-animate-text: 一个有意思的文字动效,三个框架都可使用 (gitee.com)

去年某天在逛别人博客时,我发现了一个有意思的文字动效。本来想在今天找找我是在哪儿看到它的,可惜找不到原地址了,或者说那人的博客已经关闭了。不过幸好当时我在那页面上找到了这个效果的min.js,并保存了下来。我们直接用代码来看看它是个啥样的效果吧:

当时我很喜欢这个效果,并将它用在了我的个人网站中:

gif

身为全栈,我做东西,各个框架平等对待。Angular,Vue,React,一个都不能少。现在我将方法与代码分享出来:在前端三大框架中分别去实现它,并加上一些可调整的配置参数。过段时间我可能会在Java swing中继续整活出这个效果。

源码解析

可以看到,这个代码片段中的js是被压缩之后的js,你会感觉无从下手。不过没关系,我们一步步来。首先我们给它做个简单地格式化,同时我把var都换成let方便阅读:

let chakhsu = function (r) {

   let l = "";
   let o = ["前端小白"].map(function (r) {
      return r + ""
   });
   let a = 2, g = 1, s = 5, d = 75;
   let b = ["rgb(110,64,170)", "rgb(150,61,179)", "rgb(191,60,175)", "rgb(228,65,157)", "rgb(254,75,131)", "rgb(255,94,99)", "rgb(255,120,71)", "rgb(251,150,51)", "rgb(226,183,47)", "rgb(198,214,60)", "rgb(175,240,91)", "rgb(127,246,88)", "rgb(82,246,103)", "rgb(48,239,130)", "rgb(29,223,163)", "rgb(26,199,194)", "rgb(35,171,216)", "rgb(54,140,225)", "rgb(76,110,219)", "rgb(96,84,200)"];
   let c = { text: "", prefixP: -s, skillI: 0, skillP: 0, direction: "forward", delay: a, step: g };

   i();

   function t() {
      return b[Math.floor(Math.random() * b.length)]
   }

   function e() {
      return String.fromCharCode(94 * Math.random() + 33)
   }

   function n(r) {
      let f = document.createDocumentFragment();
      for (let i = 0; r > i; i++) {
         let l = document.createElement("span");
         l.textContent = e();
         l.style.color = t();
         f.appendChild(l);
      }
      return f;
   }

   function i() {
      let t = o[c.skillI];
      c.step ? c.step-- : (c.step = g, c.prefixP < l.length ? (c.prefixP >= 0 && (c.text += l[c.prefixP]), c.prefixP++) : "forward" === c.direction ? c.skillP < t.length ? (c.text += t[c.skillP], c.skillP++) : c.delay ? c.delay-- : (c.direction = "backward", c.delay = a) : c.skillP > 0 ? (c.text = c.text.slice(0, -1), c.skillP--) : (c.skillI = (c.skillI + 1) % o.length, c.direction = "forward")), r.textContent = c.text, r.appendChild(n(c.prefixP < l.length ? Math.min(s, s + c.prefixP) : Math.min(s, t.length - c.skillP))), setTimeout(i, d)
   }

};

先从最简单的下手,很明显,

  • let o = ["前端小白"]:需要做动效的文字,他是一个数组,你可以给定多组文字达到循环显示的效果
  • let b = ["rgb(110,64,170)"...:定义动效中各个字符的颜色
  • String.fromCharCode(94 * Math.random() + 33):生成动效每一帧的随机字符
  • b[Math.floor(Math.random() * b.length)]:每一帧动效中,每个随机出现的字符的颜色
  • function n(r):将每个随机字符添加到dom中
  • function i():这一段是具体的逻辑方法,我们暂时不管,在接下来的文章中再去研究

在Angular中去实现

为什么首先使用Angular去实现它呢?很简单,我具有6年Angular经验,首先使用Angular去实现它是最稳妥的办法。之后我会将它移植到Vue和React上。

第一步:将源码搬运过去

创建项目的过程就不赘述了。

angular

创建一个组件,名为color-animate-text。直接看搬运过去后的组件代码:

color-animate-text.component.html和最开始相同,创建一个dom去显示文字动效

<div id="chakhsu"></div>

color-animate-text.component.ts直接将原来的min.js中的代码复制进去,并修改为ts能够认识的样子

@Component({
   selector: 'app-color-animate-text',
   templateUrl: 'color-animate-text.component.html',
   styleUrls: ['color-animate-text.component.scss'],
})
export class ColorAnimateTextComponent implements OnInit {

   l = "";
   o = ["前端小白"].map(function (r) {
      return r + ""
   });
   a = 2;
   g = 1;
   s = 5;
   d = 75;
   b = ["rgb(110,64,170)", "rgb(150,61,179)", "rgb(191,60,175)", "rgb(228,65,157)", "rgb(254,75,131)", "rgb(255,94,99)", "rgb(255,120,71)", "rgb(251,150,51)", "rgb(226,183,47)", "rgb(198,214,60)", "rgb(175,240,91)", "rgb(127,246,88)", "rgb(82,246,103)", "rgb(48,239,130)", "rgb(29,223,163)", "rgb(26,199,194)", "rgb(35,171,216)", "rgb(54,140,225)", "rgb(76,110,219)", "rgb(96,84,200)"];
   c = { text: "", prefixP: -this.s, skillI: 0, skillP: 0, direction: "forward", delay: this.a, step: this.g };

   ngOnInit(): void {
      this.i();
   }

   t() {
      return this.b[Math.floor(Math.random() * this.b.length)]
   }

   e() {
      return String.fromCharCode(94 * Math.random() + 33)
   }

   n(r: any) {
      let f = document.createDocumentFragment();
      for (let i = 0; r > i; i++) {
         let l = document.createElement("span");
         l.textContent = this.e();
         l.style.color = this.t();
         f.appendChild(l);
      }
      return f;
   }

   i() {
      let t = this.o[this.c.skillI];
      let r = document.getElementById('chakhsu');
      if (r != null) {
         this.c.step ? this.c.step-- : (this.c.step = this.g, this.c.prefixP < this.l.length ? (this.c.prefixP >= 0 && (this.c.text += this.l[this.c.prefixP]), this.c.prefixP++) : "forward" === this.c.direction ? this.c.skillP < t.length ? (this.c.text += t[this.c.skillP], this.c.skillP++) : this.c.delay ? this.c.delay-- : (this.c.direction = "backward", this.c.delay = this.a) : this.c.skillP > 0 ? (this.c.text = this.c.text.slice(0, -1), this.c.skillP--) : (this.c.skillI = (this.c.skillI + 1) % this.o.length, this.c.direction = "forward")), r.textContent = this.c.text, r.appendChild(this.n(this.c.prefixP < this.l.length ? Math.min(this.s, this.s + this.c.prefixP) : Math.min(this.s, t.length - this.c.skillP))), setTimeout(()=>{this.i()}, this.d)
      }
   }

}

引用这个组件后,我们就可以看到这个效果正确运行起来了:

gif

第二步:优化变量名以及方法名

接下来我们去修改变量名与方法名,优化代码结构,让它看上去不那么像min.js。这一步也是为了后面功能扩展做准备。这一步骤中,我们也将获取dom的方法从document.getElementById改为Angular的方式。在源码function i()里面,末尾处有一段setTimeout方法,在此也将它单独提出来。

color-animate-text.component.html

<div #container></div>

color-animate-text.component.ts

@Component({
   selector: 'app-color-animate-text',
   templateUrl: 'color-animate-text.component.html',
   styleUrls: ['color-animate-text.component.scss'],
})
export class ColorAnimateTextComponent implements OnInit {

   @ViewChild("container", { static: true }) container?: ElementRef<HTMLDivElement>;

   private texts: Array<string> = ['前端小白'];
   private l = "";
   private a = 2;
   private g = 1;
   private s = 5;
   private frameTime = 75;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private c = { text: '', prefixP: -this.s, skillI: 0, skillP: 0, direction: "forward", delay: this.a, step: this.g };

   ngOnInit(): void {
      let dom = this.container?.nativeElement;
      if (dom != null) {
         this.loop(dom);
      }
   }

   private loop(dom: HTMLDivElement): void {
      this.render(dom);
      setTimeout(() => {
         this.loop(dom)
      }, this.frameTime);
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement) {
      let t = this.texts[this.c.skillI];
      this.c.step ? this.c.step-- : (this.c.step = this.g, this.c.prefixP < this.l.length ? (this.c.prefixP >= 0 && (this.c.text += this.l[this.c.prefixP]), this.c.prefixP++) : "forward" === this.c.direction ? this.c.skillP < t.length ? (this.c.text += t[this.c.skillP], this.c.skillP++) : this.c.delay ? this.c.delay-- : (this.c.direction = "backward", this.c.delay = this.a) : this.c.skillP > 0 ? (this.c.text = this.c.text.slice(0, -1), this.c.skillP--) : (this.c.skillI = (this.c.skillI + 1) % this.texts.length, this.c.direction = "forward")), dom.textContent = this.c.text, dom.appendChild(this.fragment(this.c.prefixP < this.l.length ? Math.min(this.s, this.s + this.c.prefixP) : Math.min(this.s, t.length - this.c.skillP)));
   }

}

相比于之前的代码,可以看到本次不仅优化了变量与方法名,以及代码结构,同时也多了一个frameTime变量。在将setTimeout单独提出来时候,我发现了在上一步骤中,setTimeout(()=>{this.i()}, this.d)这一段,里面的this.d就是控制该效果的速度。所以对应的我将它的变量名改为frameTime,也就是每一帧之后等待的时间。在这里我将它分别20与200,可以看到速度产生了明显地变化:

frameTime = 20

gif

frameTime = 200

gif

第三步:拆解那一段三元表达式

现在代码已经稍有可读性与配置性了。接下来就拆解源码function i()里面那一段复杂的三元表达式。

这里除了徒手拆,没别的办法了。手动反编译,最需要的就是耐心。

1. 手动格式化,做好缩进

做好格式化和缩进,有助于去理解它的判断结构。

this.c.step ?
   this.c.step-- :
   (
      this.c.step = this.g,
      this.c.prefixP < this.l.length ?
         (this.c.prefixP >= 0 && (this.c.text += this.l[this.c.prefixP]), this.c.prefixP++) :
         "forward" === this.c.direction ?
            this.c.skillP < t.length ?
               (this.c.text += t[this.c.skillP], this.c.skillP++) :
               this.c.delay ?
                  this.c.delay-- :
                  (this.c.direction = "backward", this.c.delay = this.a) :
            this.c.skillP > 0 ?
               (this.c.text = this.c.text.slice(0, -1), this.c.skillP--) :
               (this.c.skillI = (this.c.skillI + 1) % this.texts.length, this.c.direction = "forward")
   ),
   dom.textContent = this.c.text,
   dom.appendChild(
      this.fragment(
         this.c.prefixP < this.l.length ?
            Math.min(this.s, this.s + this.c.prefixP) :
            Math.min(this.s, t.length - this.c.skillP)
      )
   );

2. 将三元表达式改写为if-else

使用if-else,才能更好地去读懂它的逻辑顺序。同时将代码中的逗号都换为分号。

private render(dom: HTMLDivElement) {
   let t = this.texts[this.c.skillI];
   if (this.c.step) {
      this.c.step--
   } else {
      this.c.step = this.g;
      if (this.c.prefixP < this.l.length) {
         this.c.prefixP >= 0 && (this.c.text += this.l[this.c.prefixP]);
         this.c.prefixP++;
      } else {
         if ("forward" === this.c.direction) {
            if (this.c.skillP < t.length) {
               this.c.text += t[this.c.skillP];
               this.c.skillP++;
            } else {
               if (this.c.delay) {
                  this.c.delay--;
               } else {
                  this.c.direction = "backward";
                  this.c.delay = this.a;
               }
            }
         } else {
            if (this.c.skillP > 0) {
               this.c.text = this.c.text.slice(0, -1);
               this.c.skillP--;
            } else {
               this.c.skillI = (this.c.skillI + 1) % this.texts.length;
               this.c.direction = "forward";
            }
         }
      }
   }
   dom.textContent = this.c.text;
   let value;
   if (this.c.prefixP < this.l.length) {
      value = Math.min(this.s, this.s + this.c.prefixP);
   } else {
      value = Math.min(this.s, t.length - this.c.skillP);
   }
   dom.appendChild(this.fragment(value));
}

至此我们已经初步知道它运行过程中的逻辑顺序了。

第四步:寻找其它变量的意义

至此我们已经完成了min.js的反编译,徒手将一段被压缩的代码逆向成我们能够清楚地去读懂的代码。它现在是这样的:

@Component({
   selector: 'app-color-animate-text',
   templateUrl: 'color-animate-text.component.html',
   styleUrls: ['color-animate-text.component.scss'],
})
export class ColorAnimateTextComponent implements OnInit {

   @ViewChild('container', { static: true }) container?: ElementRef<HTMLDivElement>;

   private texts: Array<string> = ['前端小白'];
   private l = '';
   private a = 2;
   private g = 1;
   private s = 5;
   private frameTime = 75;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private c = {
      text: '',
      prefixP: -this.s,
      skillI: 0,
      skillP: 0,
      direction: 'forward',
      delay: this.a,
      step: this.g
   };

   ngOnInit(): void {
      let dom = this.container?.nativeElement;
      if (dom != null) {
         this.loop(dom);
      }
   }

   private loop(dom: HTMLDivElement): void {
      this.render(dom);
      setTimeout(() => {
         this.loop(dom)
      }, this.frameTime);
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement) {
      let t = this.texts[this.c.skillI];
      if (this.c.step) {
         this.c.step--
      } else {
         this.c.step = this.g;
         if (this.c.prefixP < this.l.length) {
            this.c.prefixP >= 0 && (this.c.text += this.l[this.c.prefixP]);
            this.c.prefixP++;
         } else {
            switch (this.c.direction) {
               case 'forward':
                  if (this.c.skillP < t.length) {
                     this.c.text += t[this.c.skillP];
                     this.c.skillP++;
                  } else {
                     if (this.c.delay) {
                        this.c.delay--;
                     } else {
                        this.c.direction = 'backward';
                        this.c.delay = this.a;
                     }
                  }
                  break;
               case 'backward':
                  if (this.c.skillP > 0) {
                     this.c.text = this.c.text.slice(0, -1);
                     this.c.skillP--;
                  } else {
                     this.c.skillI = (this.c.skillI + 1) % this.texts.length;
                     this.c.direction = 'forward';
                  }
                  break;
               default:
                  break;
            }
         }
      }
      dom.textContent = this.c.text;
      let value;
      if (this.c.prefixP < this.l.length) {
         value = Math.min(this.s, this.s + this.c.prefixP);
      } else {
         value = Math.min(this.s, t.length - this.c.skillP);
      }
      dom.appendChild(this.fragment(value));
   }

}

做到这一步,你已经可以将代码复制到你的项目中,自由地去探索了。接下来我来讲讲上述代码中, l = ""; a = 2; g = 1; s = 5; c = {...}这些变量的作用。

1. 前缀文字

l = ""该变量的作用为前缀文字。我将它改为prefixString。给它赋值看看效果:

prefixString = 'AAA'

gif

2. 第一次效果的长度

s = 5,该变量控制第一次运行时的长度。我将它改为colorTextLength。给它赋值看看效果:

prefixString = 'AAA'; colorTextLength = 20

gif

3. 每个文字停留多久

g = 1;,该变量的作用为控制每个文字停留多久。我将它改为textWaitStep。具体不好描述,直接放图看效果:

textWaitStep = 1

gif

textWaitStep = 10

gif

4. 每段文字停留多久

至于其他,我发现了a = 2;该变量的作用是控制每段文字效果完成后停留多久,和上一点类似,就不作示例了。

4. 优化结果

继续代码,现在的代码是这样的:

@Component({
   selector: 'app-color-animate-text',
   templateUrl: 'color-animate-text.component.html',
   styleUrls: ['color-animate-text.component.scss'],
})
export class ColorAnimateTextComponent implements OnInit, AfterViewInit, OnDestroy {

   @ViewChild('container', { static: true }) container?: ElementRef<HTMLDivElement>;

   @Input() prefixString: string = ''; // 前缀文字
   @Input() texts: Array<string> = ['']; // 需要做动效的文字
   @Input() defaultRun: boolean = true; // 在初始化组件时是否默认运行
   @Input() infinite: boolean = true; // 是否无限循环运行
   @Input() frameTime: number = 75; // 每一帧所用时间
   @Input() textWaitStep: number = 1; // 每个文字停留多久
   @Input() paragraphWaitStep: number = 2; // 每段文字停留多久

   private runTexts = [''];
   private colorTextLength = 5;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private config = {
      text: '',
      prefix: -(this.prefixString.length + this.colorTextLength),
      skillI: 0, skillP: 0, step: this.textWaitStep,
      direction: 'forward',
      delay: this.paragraphWaitStep,
   };

   private destroyed: boolean = false;
   private continue: boolean = false;
   private infinite0: boolean = true;

   ngOnInit(): void {
      this.runTexts = [...this.texts];
      this.continue = this.defaultRun;
      this.infinite0 = this.infinite;
      if (!this.infinite0) {
         if (this.runTexts.length > 1) {
            console.warn('在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。');
         }
      }
   }

   ngAfterViewInit(): void {
      this.init();
   }

   ngOnDestroy(): void {
      this.destroyed = true;
   }

   private init(): void {
      let dom = this.container?.nativeElement;
      dom && this.loop(dom);
   }

   /** 循环 */
   private loop(dom: HTMLDivElement): void {
      setTimeout(() => {
         if (this.continue) {
            if (this.destroyed) {
               return;
            }
            let index = this.config.skillI;
            if (this.texts.toString() != this.runTexts.toString()) {
               // 文字已更新
               let currentText = this.runTexts[index]; // 原始
               let updateText = this.texts[index]; // 变化的
               if (updateText == null) {
                  updateText = this.texts[this.texts.length - 1];
                  this.config.skillI = this.texts.length - 1;
               }
               this.render(dom, currentText, updateText);
               this.runTexts = [...this.texts];
            } else {
               // 文字未更新
               let currentText = this.runTexts[index];
               this.render(dom, currentText);
            }
         }
         if (this.infinite0) {
            this.loop(dom);
         } else {
            if (this.config.skillP < this.runTexts[0].length) {
               this.loop(dom);
            }
         }
      }, this.frameTime);
   }

   /** 继续 */
   resume(): void {
      this.continue = true;
   }

   /** 暂停 */
   suspend(): void {
      this.continue = false;
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement, currentText: string, updateText?: string): void {
      if (this.config.step) {
         this.config.step--;
      } else {
         this.config.step = this.textWaitStep;
         if (this.config.prefix < this.prefixString.length) {
            this.config.prefix >= 0 && (this.config.text += this.prefixString[this.config.prefix]);
            this.config.prefix++;
         } else {
            switch (this.config.direction) {
               case "forward":
                  if (this.config.skillP < currentText.length) {
                     this.config.text += currentText[this.config.skillP];
                     this.config.skillP++;
                  } else {
                     if (this.config.delay) {
                        this.config.delay--;
                     } else {
                        this.config.direction = 'backward';
                        this.config.delay = this.paragraphWaitStep;
                     }
                  }
                  break;
               case "backward":
                  if (this.config.skillP > 0) {
                     this.config.text = this.config.text.slice(0, -1);
                     this.config.skillP--;
                  } else {
                     this.config.skillI = (this.config.skillI + 1) % this.runTexts.length;
                     this.config.direction = 'forward';
                  }
                  break;
               default:
                  break;
            }
         }
      }
      if (updateText != null) {
         this.config.text = updateText.substring(0, this.config.skillP);
         if (this.config.skillP > updateText.length) {
            this.config.skillP = updateText.length
         }
      }
      dom.textContent = this.config.text;
      let value;
      if (this.config.prefix < this.prefixString.length) {
         value = Math.min(this.colorTextLength, this.colorTextLength + this.config.prefix);
      } else {
         value = Math.min(this.colorTextLength, currentText.length - this.config.skillP);
      }
      dom.appendChild(this.fragment(value));
   }

}

第五步:添加配置参数

在本章节中,我们给它配置参数,即通过Angular的@Input()能够实现通过传入参数达到配置该组件的效果

1. 声明可以配置的传入变量

声明如下@Input()变量,以供在调用该组件的时候直接通过传入参数就能获得自己想要的配置效果:

@Input() prefixString = ''; // 前缀文字
@Input() texts: Array<string> = ['']; // 需要做动效的文字
@Input() defaultRun: boolean = true; // 在初始化组件时是否默认运行
@Input() infinite: boolean = true; // 是否无限循环运行
@Input() frameTime: number = 75; // 每一帧所用时间
@Input() textWaitStep: number = 3; // 每个文字停留多久

2. 修改代码,让这些配置加入到动效过程中

具体代码怎么修改的,就不作详细介绍了。毕竟详细讲起来很浪费时间与篇幅,大家直接研究代码即可。在此放上修改完成的代码:

@Component({
   selector: 'app-color-animate-text',
   templateUrl: 'color-animate-text.component.html',
   styleUrls: ['color-animate-text.component.scss'],
})
export class ColorAnimateTextComponent implements OnInit, AfterViewInit, OnDestroy {

   @ViewChild('container', { static: true }) container?: ElementRef<HTMLDivElement>;

   @Input() prefixString = ''; // 前缀文字
   @Input() texts: Array<string> = ['']; // 需要做动效的文字
   @Input() defaultRun: boolean = true; // 在初始化组件时是否默认运行
   @Input() infinite: boolean = true; // 是否无限循环运行
   @Input() frameTime: number = 75; // 每一帧所用时间
   @Input() textWaitStep: number = 3; // 每个文字停留多久

   private runTexts = [''];
   private colorTextLength = 5;
   private step = 1;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private config = {
      text: '',
      prefix: -(this.prefixString.length + this.colorTextLength),
      skillI: 0, skillP: 0, step: this.step,
      direction: 'forward',
      delay: this.textWaitStep,
   };

   private destroyed: boolean = false;
   private continue: boolean = false;
   private infinite0: boolean = true;

   ngOnInit(): void {
      this.runTexts = [...this.texts];
      this.continue = this.defaultRun;
      this.infinite0 = this.infinite;
      this.config.delay = this.textWaitStep;
      if (!this.infinite0) {
         if (this.runTexts.length > 1) {
            console.warn('在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。');
         }
      }
   }
   
   ngAfterViewInit(): void {
      this.init();
   }

   ngOnDestroy(): void {
      this.destroyed = true;
   }

   private init(): void {
      let dom = this.container?.nativeElement;
      dom && this.loop(dom);
   }

   /** 循环 */
   private loop(dom: HTMLDivElement): void {
      setTimeout(() => {
         if (this.continue) {
            if (this.destroyed) {
               return;
            }
            let index = this.config.skillI;
            if (this.texts.toString() != this.runTexts.toString()) {
               // 文字已更新
               let currentText = this.runTexts[index]; // 原始
               let updateText = this.texts[index]; // 变化的
               if (updateText == null) {
                  updateText = this.texts[this.texts.length - 1];
                  this.config.skillI = this.texts.length - 1;
               }
               this.render(dom, currentText, updateText);
               this.runTexts = [...this.texts];
            } else {
               // 文字未更新
               let currentText = this.runTexts[index];
               this.render(dom, currentText);
            }
         }
         if (this.infinite0) {
            this.loop(dom);
         } else {
            if (this.config.skillP < this.runTexts[0].length) {
               this.loop(dom);
            }
         }
      }, this.frameTime);
   }

   /** 继续 */
   resume(): void {
      this.continue = true;
   }

   /** 暂停 */
   suspend(): void {
      this.continue = false;
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement, currentText: string, updateText?: string): void {
      if (this.config.step) {
         this.config.step--;
      } else {
         this.config.step = this.step;
         if (this.config.prefix < this.prefixString.length) {
            this.config.prefix >= 0 && (this.config.text += this.prefixString[this.config.prefix]);
            this.config.prefix++;
         } else {
            switch (this.config.direction) {
               case "forward":
                  if (this.config.skillP < currentText.length) {
                     this.config.text += currentText[this.config.skillP];
                     this.config.skillP++;
                  } else {
                     if (this.config.delay) {
                        this.config.delay--;
                     } else {
                        this.config.direction = 'backward';
                        this.config.delay = this.textWaitStep;
                     }
                  }
                  break;
               case "backward":
                  if (this.config.skillP > 0) {
                     this.config.text = this.config.text.slice(0, -1);
                     this.config.skillP--;
                  } else {
                     this.config.skillI = (this.config.skillI + 1) % this.runTexts.length;
                     this.config.direction = 'forward';
                  }
                  break;
               default:
                  break;
            }
         }
      }
      if (updateText != null) {
         this.config.text = updateText.substring(0, this.config.skillP);
         if (this.config.skillP > updateText.length) {
            this.config.skillP = updateText.length
         }
      }
      dom.textContent = this.config.text;
      let value;
      if (this.config.prefix < this.prefixString.length) {
         value = Math.min(this.colorTextLength, this.colorTextLength + this.config.prefix);
      } else {
         value = Math.min(this.colorTextLength, currentText.length - this.config.skillP);
      }
      dom.appendChild(this.fragment(value));
   }

}

3. 试试效果吧!

在此我通过几种不同的配置去调用该组件,来看看现在文字动效是怎么运行的:

场景一:一般情况

<app-color-animate-text [prefixString]="'稀土掘金:'" [texts]="['CrimsonHu','全栈工程师']">
</app-color-animate-text>

gif

场景二:只运行一次

<app-color-animate-text [texts]="['CrimsonHu']" [infinite]="false"></app-color-animate-text>

gif

场景三:默认不运行,需手动开启

<button (click)="colorAnimateText.resume()">开始</button>
<button (click)="colorAnimateText.suspend()">暂停</button>
<app-color-animate-text [prefixString]="'稀土掘金:'" [texts]="['CrimsonHu','全栈工程师']"
                  [defaultRun]="false" #colorAnimateText>
</app-color-animate-text>

gif

场景四:实时更新文字内容

<app-color-animate-text [texts]="flag ? value1 : value2"></app-color-animate-text>
<button (click)="change()">变换</button>
<div>
   当前:{{ flag ? value1.toString() : value2.toString() }}
</div>
@Component({
   selector: 'app-root',
   templateUrl: './app.component.html',
   styleUrls: ['./app.component.scss']
})
export class AppComponent {

   flag = true;

   value1 = ['CrimsonHu', '全栈工程师', '谁会拒绝一个有意思的文字动效呢'];
   value2 = ['我的技术栈', 'Angular+Vue+React+Java'];

   change(): void {
      this.flag = !this.flag;
   }

}

gif

至此只做这么多事例,大家可以拉取代码,根据传入参数的不同自行尝试。

移植至Vue中

这么好玩的东西,怎么能只在Angular中做出来呢?肯定是要移植到Vue和React上的。

第一步:移植思路

思路很重要。如果要将上面在Angular中写好的代码,移植到Vue中需要大面积重构,那就很没意思了。于是在此我保留Angular中面向对象的思维,使用对接的思路,将代码以最小的成本移植到Vue中去

我们先看看,在Angular的类组件中,它是怎么存在的:

  1. 该页面组件是一个对象
  2. 这个对象受到框架的生命周期管控
  3. 它虽然是一个页面组件,但是我们可以将它比作一个实际的业务,它以对象的方式存在

于是,我们将它当做一个对象,声明并暴露出生命周期接口,对接Vue的生命周期钩子以及传入参数等,是不是就可以轻松移植过去了呢?

第二步:将思路运用上去

vue

1. 搬运代码至Vue中

创建一个组件,名为color-animate-text。将Angular中的该组件搬运至Vue项目中,去掉Angular的注解,重命名为color-animate-text.instance.ts。现在它是一个空白的组件,代码如下:

color-animate-text.component.vue

<template>
   <div ref="container"></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
   name: 'app-color-animate-text',
   props: {
   },
   setup() {
   },
   created() {
   },
   beforeUnmount() {
   },
});
</script>

color-animate-text.instance.ts

export class ColorAnimateTextInstance {

   prefixString: string = ''; // 前缀文字
   texts: Array<string> = ['']; // 需要做动效的文字
   defaultRun: boolean = true; // 在初始化组件时是否默认运行
   infinite: boolean = true; // 是否无限循环运行
   frameTime: number = 75; // 每一帧所用时间
   textWaitStep: number = 1; // 每个文字停留多久
   paragraphWaitStep: number = 2; // 每段文字停留多久

   private runTexts = [''];
   private colorTextLength = 5;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private config = {
      text: '',
      prefix: -(this.prefixString.length + this.colorTextLength),
      skillI: 0, skillP: 0, step: this.textWaitStep,
      direction: 'forward',
      delay: this.paragraphWaitStep,
   };

   private destroyed: boolean = false;
   private continue: boolean = false;
   private infinite0: boolean = true;

   ngOnInit(): void {
      this.runTexts = [...this.texts];
      this.continue = this.defaultRun;
      this.infinite0 = this.infinite;
      if (!this.infinite0) {
         if (this.runTexts.length > 1) {
            console.warn('在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。');
         }
      }
   }

   ngAfterViewInit(): void {
      this.init();
   }

   ngOnDestroy(): void {
      this.destroyed = true;
   }

   private init(): void {
      // let dom = this.container?.nativeElement;
      // dom && this.loop(dom);
   }

   /** 循环 */
   private loop(dom: HTMLDivElement): void {
      setTimeout(() => {
         if (this.continue) {
            if (this.destroyed) {
               return;
            }
            let index = this.config.skillI;
            if (this.texts.toString() != this.runTexts.toString()) {
               // 文字已更新
               let currentText = this.runTexts[index]; // 原始
               let updateText = this.texts[index]; // 变化的
               if (updateText == null) {
                  updateText = this.texts[this.texts.length - 1];
                  this.config.skillI = this.texts.length - 1;
               }
               this.render(dom, currentText, updateText);
               this.runTexts = [...this.texts];
            } else {
               // 文字未更新
               let currentText = this.runTexts[index];
               this.render(dom, currentText);
            }
         }
         if (this.infinite0) {
            this.loop(dom);
         } else {
            if (this.config.skillP < this.runTexts[0].length) {
               this.loop(dom);
            }
         }
      }, this.frameTime);
   }

   /** 继续 */
   resume(): void {
      this.continue = true;
   }

   /** 暂停 */
   suspend(): void {
      this.continue = false;
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement, currentText: string, updateText?: string): void {
      if (this.config.step) {
         this.config.step--;
      } else {
         this.config.step = this.textWaitStep;
         if (this.config.prefix < this.prefixString.length) {
            this.config.prefix >= 0 && (this.config.text += this.prefixString[this.config.prefix]);
            this.config.prefix++;
         } else {
            switch (this.config.direction) {
               case "forward":
                  if (this.config.skillP < currentText.length) {
                     this.config.text += currentText[this.config.skillP];
                     this.config.skillP++;
                  } else {
                     if (this.config.delay) {
                        this.config.delay--;
                     } else {
                        this.config.direction = 'backward';
                        this.config.delay = this.paragraphWaitStep;
                     }
                  }
                  break;
               case "backward":
                  if (this.config.skillP > 0) {
                     this.config.text = this.config.text.slice(0, -1);
                     this.config.skillP--;
                  } else {
                     this.config.skillI = (this.config.skillI + 1) % this.runTexts.length;
                     this.config.direction = 'forward';
                  }
                  break;
               default:
                  break;
            }
         }
      }
      if (updateText != null) {
         this.config.text = updateText.substring(0, this.config.skillP);
         if (this.config.skillP > updateText.length) {
            this.config.skillP = updateText.length
         }
      }
      dom.textContent = this.config.text;
      let value;
      if (this.config.prefix < this.prefixString.length) {
         value = Math.min(this.colorTextLength, this.colorTextLength + this.config.prefix);
      } else {
         value = Math.min(this.colorTextLength, currentText.length - this.config.skillP);
      }
      dom.appendChild(this.fragment(value));
   }

}

2. 对接Vue的生命周期

我们在setup中new该对象,并直接将原本Angular中的生命钩子方法对接到Vue的生命周期上:

color-animate-text.component.vue

export default defineComponent({
   name: 'app-color-animate-text',
   props: {},
   setup() {
      let instance!: ColorAnimateTextInstance;
      return {
         instance,
      }
   },
   created() {
      this.instance = new ColorAnimateTextInstance(this);
      this.instance.ngOnInit();
   },
   mounted() {
      this.instance.ngAfterViewInit();
   },
   beforeUnmount() {
      this.instance.ngOnDestroy();
   },
});

同时在ColorAnimateTextInstance的构造函数中添加一个参数,用于接收当前组件的相关参数:

color-animate-text.instance.ts

constructor(
   private vue: any,
) {
}

3. 对接Vue的传入参数

将之前在Angular中标有@Input()注解的变量放入到Vue组件传入参数的位置:

props: {
   prefixString: { type: String, default: '' }, // 前缀文字
   texts: { type: [Array<string>], default: [''] }, // 需要做动效的文字
   defaultRun: { type: Boolean, default: true }, // 在初始化组件时是否默认运行
   infinite: { type: Boolean, default: true }, // 是否无限循环运行
   frameTime: { type: Number, default: 75 }, // 每一帧所用时间
   textWaitStep: { type: Number, default: 1 }, // 每个文字停留多久
   paragraphWaitStep: { type: Number, default: 2 }, // 每段文字停留多久
},

修改ColorAnimateTextInstance的代码,使其能够接收到Vue的传入参数。其实很简单,上一步在构造函数中传入了vue,直接取值即可:

export class ColorAnimateTextInstance {

   private runTexts = [''];
   private colorTextLength = 5;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private config;

   private destroyed: boolean = false;
   private continue: boolean = false;
   private infinite0: boolean = true;

   constructor(
      private vue: any,
   ) {
      this.config = {
         text: '',
         prefix: -(this.vue.prefixString.length + this.colorTextLength),
         skillI: 0, skillP: 0, step: this.vue.textWaitStep,
         direction: 'forward',
         delay: this.vue.paragraphWaitStep,
      }
   }

   ngOnInit(): void {
      this.runTexts = [...this.vue.texts];
      this.continue = this.vue.defaultRun;
      this.infinite0 = this.vue.infinite;
      if (!this.infinite0) {
         if (this.runTexts.length > 1) {
            console.warn('在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。');
         }
      }
   }

   ngAfterViewInit(): void {
      this.init();
   }

   ngOnDestroy(): void {
      this.destroyed = true;
   }

   private init(): void {
      // let dom = this.container?.nativeElement;
      // dom && this.loop(dom);
   }

   /** 循环 */
   private loop(dom: HTMLDivElement): void {
      setTimeout(() => {
         if (this.continue) {
            if (this.destroyed) {
               return;
            }
            let index = this.config.skillI;
            if (this.vue.texts.toString() != this.runTexts.toString()) {
               // 文字已更新
               let currentText = this.runTexts[index]; // 原始
               let updateText = this.vue.texts[index]; // 变化的
               if (updateText == null) {
                  updateText = this.vue.texts[this.vue.texts.length - 1];
                  this.config.skillI = this.vue.texts.length - 1;
               }
               this.render(dom, currentText, updateText);
               this.runTexts = [...this.vue.texts];
            } else {
               // 文字未更新
               let currentText = this.runTexts[index];
               this.render(dom, currentText);
            }
         }
         if (this.infinite0) {
            this.loop(dom);
         } else {
            if (this.config.skillP < this.runTexts[0].length) {
               this.loop(dom);
            }
         }
      }, this.vue.frameTime);
   }

   /** 继续 */
   resume(): void {
      this.continue = true;
   }

   /** 暂停 */
   suspend(): void {
      this.continue = false;
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement, currentText: string, updateText?: string): void {
      if (this.config.step) {
         this.config.step--;
      } else {
         this.config.step = this.vue.textWaitStep;
         if (this.config.prefix < this.vue.prefixString.length) {
            this.config.prefix >= 0 && (this.config.text += this.vue.prefixString[this.config.prefix]);
            this.config.prefix++;
         } else {
            switch (this.config.direction) {
               case "forward":
                  if (this.config.skillP < currentText.length) {
                     this.config.text += currentText[this.config.skillP];
                     this.config.skillP++;
                  } else {
                     if (this.config.delay) {
                        this.config.delay--;
                     } else {
                        this.config.direction = 'backward';
                        this.config.delay = this.vue.paragraphWaitStep;
                     }
                  }
                  break;
               case "backward":
                  if (this.config.skillP > 0) {
                     this.config.text = this.config.text.slice(0, -1);
                     this.config.skillP--;
                  } else {
                     this.config.skillI = (this.config.skillI + 1) % this.runTexts.length;
                     this.config.direction = 'forward';
                  }
                  break;
               default:
                  break;
            }
         }
      }
      if (updateText != null) {
         this.config.text = updateText.substring(0, this.config.skillP);
         if (this.config.skillP > updateText.length) {
            this.config.skillP = updateText.length
         }
      }
      dom.textContent = this.config.text;
      let value;
      if (this.config.prefix < this.vue.prefixString.length) {
         value = Math.min(this.colorTextLength, this.colorTextLength + this.config.prefix);
      } else {
         value = Math.min(this.colorTextLength, currentText.length - this.config.skillP);
      }
      dom.appendChild(this.fragment(value));
   }

}

4. 使用Vue的dom获取方式

上述代码我们基本完成了移植的,最后只需要将原先Angular获取dom的方式改为Vue获取dom的方式即可。将上述文件中的init()方法改为如下内容:

private init(): void {
   let dom = this.vue.$refs.container;
   dom && this.loop(dom);
}

至此移植完成,可以开始使用了。

第三步:开始使用吧!

我们在App.vue中使用该组件。

场景一:一般情况

<template>
   <app-color-animate-text :prefix-string="'稀土掘金:'" :texts="['CrimsonHu','全栈工程师']">
   </app-color-animate-text>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import AppColorAnimateText from "@/color-animate-text/color-animate-text.component.vue";

export default defineComponent({
   name: 'app-root',
   components: {
      AppColorAnimateText,
   },
});
</script>

可以看到,按照Vue的组件使用方法,以及Vue的传参方式,顺利地将上文在Angular中所实现的功能展现出来:

gif

场景二:只运行一次

<template>
   <app-color-animate-text :texts="['CrimsonHu']" :infinite="false"></app-color-animate-text>
</template>

gif

场景三:默认不运行,需手动开启

<template>
   <button @click="this.$refs.colorAnimateText.instance.resume()">开始</button>
   <button @click="this.$refs.colorAnimateText.instance.suspend()">暂停</button>
   <app-color-animate-text :prefix-string="'稀土掘金:'" :texts="['CrimsonHu','全栈工程师']"
                     :default-run="false" ref="colorAnimateText">
   </app-color-animate-text>
</template>

gif

场景四:实时更新文字内容

<template>
   <app-color-animate-text :texts="flag ? value1 : value2"></app-color-animate-text>
   <button @click="change()">变换</button>
   <div>
      当前:{{ flag ? value1.toString() : value2.toString() }}
   </div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import AppColorAnimateText from "@/color-animate-text/color-animate-text.component.vue";

export default defineComponent({
   name: 'app-root',
   components: {
      AppColorAnimateText,
   },
   data() {
      return {
         flag: true,
         value1: ['CrimsonHu', '全栈工程师', '谁会拒绝一个有意思的文字动效呢'],
         value2: ['我的技术栈', 'Angular+Vue+React+Java'],
      }
   },
   methods: {
      change(): void {
         this.flag = !this.flag;
      }
   }
});
</script>

gif

移植至React中

同样,按照移植到Vue的方式,继续使用对接的思路,将代码以最小的成本移植到React中去

第一步:创建项目

使用Create React App创建一个TS项目,同样将Angular中的组件类搬运到React项目中,并去掉Angular的相关引用。具体代码就不多赘述了,总之和上面Vue的方法一样。

react

color-animate-text.component.tsx

import React from "react";

function ColorAnimateTextComponent() {
    return (
        <div></div>
    );
}

export default ColorAnimateTextComponent;

第二步:与React进行对接

同样,将该动效当做一个对象,声明并暴露出生命周期接口,对接React的生命周期钩子以及传入参数等,即可完成移植。

1. 对接React的生命周期

在Effect中实例化对象,并调用生命周期接口。

color-animate-text.component.tsx

function ColorAnimateTextComponent() {
    let instance = useRef<ColorAnimateTextInstance>();
    useEffect(() => {
       // 创建组件
       instance.current = new ColorAnimateTextInstance(param);
       instance.current.ngOnInit();
       instance.current.ngAfterViewInit(container.current);
       return () => {
          // 销毁组件
          instance.current?.ngOnDestroy();
       };
    }, []);
    return (
        <div></div>
    );
}

export default ColorAnimateTextComponent;

2. 对接React的传入参数

继续添加代码,将配置参数以React的传入方式写出来。

color-animate-text.component.tsx

function ColorAnimateTextComponent(
    {
        prefixString = '', // 前缀文字
        texts = [''], // 需要做动效的文字
        defaultRun = true, // 在初始化组件时是否默认运行
        infinite = true, // 是否无限循环运行
        frameTime = 75, // 每一帧所用时间
        textWaitStep = 1, // 每个文字停留多久
        paragraphWaitStep = 2, // 每段文字停留多久
    }
) {
    let instance = useRef<ColorAnimateTextInstance>();
    useLayoutEffect(() => {
        // 开始创建组件
        instance.current = new ColorAnimateTextInstance();
        instance.current.ngOnInit();
    });
    useEffect(() => {
       // 创建组件
       instance.current = new ColorAnimateTextInstance(param);
       instance.current.ngOnInit();
       instance.current.ngAfterViewInit(container.current);
       return () => {
          // 销毁组件
          instance.current?.ngOnDestroy();
       };
    }, []);
    return (
        <div></div>
    );
}

export default ColorAnimateTextComponent;

3. 对接React的dom获取方式

该步骤很简单,具体就不过多描述了:

const container = useRef(null)

<div ref={container}></div>

instance.current.ngAfterViewInit(container.current)

4. 其它修改与最终代码

因为Vue与Angular在响应式与组件传值的处理上很类似,没有做过多的修改就移植完成了。但是React和这两个框架不同,故此需要稍作修改,才能达到移植目的。详细修改方法就不过多描述了,都是React基础部分。直接看组件的最终代码吧:

color-animate-text.component.tsx

const ColorAnimateTextComponent: React.FC<any> = forwardRef((
   {
      prefixString = '', // 前缀文字
      texts = [''], // 需要做动效的文字
      defaultRun = true, // 在初始化组件时是否默认运行
      infinite = true, // 是否无限循环运行
      frameTime = 75, // 每一帧所用时间
      textWaitStep = 1, // 每个文字停留多久
      paragraphWaitStep = 2, // 每段文字停留多久
   }, ref: React.Ref<unknown> | undefined,
) => {

   const param = {
      prefixString: prefixString,
      texts: texts,
      defaultRun: defaultRun,
      infinite: infinite,
      frameTime: frameTime,
      textWaitStep: textWaitStep,
      paragraphWaitStep: paragraphWaitStep,
   };

   const container = useRef(null);
   const instance = useRef<ColorAnimateTextInstance>();

   instance.current?.setParam(param);

   useImperativeHandle(ref, () => ({
      getInstance: () => {
         return instance.current;
      }
   }));

   useEffect(() => {
      // 创建组件
      instance.current = new ColorAnimateTextInstance(param);
      instance.current.ngOnInit();
      instance.current.ngAfterViewInit(container.current);
      return () => {
         // 销毁组件
         instance.current?.ngOnDestroy();
      };
   }, []);

   return (
      <>
         <div ref={container}></div>
      </>
   );
})

export default ColorAnimateTextComponent;

color-animate-text.instance.ts

type InputParam = {
   prefixString: string; // 前缀文字
   texts: Array<string>; // 需要做动效的文字
   defaultRun: boolean; // 在初始化组件时是否默认运行
   infinite: boolean; // 是否无限循环运行
   frameTime: number; // 每一帧所用时间
   textWaitStep: number; // 每个文字停留多久
   paragraphWaitStep: number; // 每段文字停留多久
}

export class ColorAnimateTextInstance {

   private runTexts = [''];
   private colorTextLength = 5;
   private colors = [
      'rgb(110,64,170)', 'rgb(150,61,179)', 'rgb(191,60,175)', 'rgb(228,65,157)',
      'rgb(254,75,131)', 'rgb(255,94,99)', 'rgb(255,120,71)', 'rgb(251,150,51)',
      'rgb(226,183,47)', 'rgb(198,214,60)', 'rgb(175,240,91)', 'rgb(127,246,88)',
      'rgb(82,246,103)', 'rgb(48,239,130)', 'rgb(29,223,163)', 'rgb(26,199,194)',
      'rgb(35,171,216)', 'rgb(54,140,225)', 'rgb(76,110,219)', 'rgb(96,84,200)'
   ];
   private config;

   private destroyed: boolean = false;
   private continue: boolean = false;
   private infinite0: boolean = true;

   constructor(
      private param: InputParam,
   ) {
      this.config = {
         text: '',
         prefix: -(this.param.prefixString.length + this.colorTextLength),
         skillI: 0, skillP: 0, step: this.param.textWaitStep,
         direction: 'forward',
         delay: this.param.paragraphWaitStep,
      }
   }

   setParam(param: InputParam) {
      this.param = param;
   }

   ngOnInit(): void {
      this.runTexts = [...this.param.texts];
      this.continue = this.param.defaultRun;
      this.infinite0 = this.param.infinite;
      if (!this.infinite0) {
         if (this.runTexts.length > 1) {
            console.warn('在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。');
         }
      }
   }

   ngAfterViewInit(dom: HTMLDivElement | null): void {
      dom && this.init(dom);
   }

   ngOnDestroy(): void {
      this.destroyed = true;
   }

   private init(dom: HTMLDivElement): void {
      this.loop(dom);
   }

   /** 循环 */
   private loop(dom: HTMLDivElement): void {
      setTimeout(() => {
         if (this.continue) {
            if (this.destroyed) {
               return;
            }
            let index = this.config.skillI;
            if (this.param.texts.toString() != this.runTexts.toString()) {
               // 文字已更新
               let currentText = this.runTexts[index]; // 原始
               let updateText = this.param.texts[index]; // 变化的
               if (updateText == null) {
                  updateText = this.param.texts[this.param.texts.length - 1];
                  this.config.skillI = this.param.texts.length - 1;
               }
               this.render(dom, currentText, updateText);
               this.runTexts = [...this.param.texts];
            } else {
               // 文字未更新
               let currentText = this.runTexts[index];
               this.render(dom, currentText);
            }
         }
         if (this.infinite0) {
            this.loop(dom);
         } else {
            if (this.config.skillP < this.runTexts[0].length) {
               this.loop(dom);
            }
         }
      }, this.param.frameTime);
   }

   /** 继续 */
   resume(): void {
      this.continue = true;
   }

   /** 暂停 */
   suspend(): void {
      this.continue = false;
   }

   private getNextColor(): string {
      return this.colors[Math.floor(Math.random() * this.colors.length)];
   }

   private getNextChar(): string {
      return String.fromCharCode(94 * Math.random() + 33);
   }

   private fragment(value: number): DocumentFragment {
      let f = document.createDocumentFragment();
      for (let i = 0; value > i; i++) {
         let span = document.createElement('span');
         span.textContent = this.getNextChar();
         span.style.color = this.getNextColor();
         f.appendChild(span);
      }
      return f;
   }

   private render(dom: HTMLDivElement, currentText: string, updateText?: string): void {
      if (this.config.step) {
         this.config.step--;
      } else {
         this.config.step = this.param.textWaitStep;
         if (this.config.prefix < this.param.prefixString.length) {
            this.config.prefix >= 0 && (this.config.text += this.param.prefixString[this.config.prefix]);
            this.config.prefix++;
         } else {
            switch (this.config.direction) {
               case "forward":
                  if (this.config.skillP < currentText.length) {
                     this.config.text += currentText[this.config.skillP];
                     this.config.skillP++;
                  } else {
                     if (this.config.delay) {
                        this.config.delay--;
                     } else {
                        this.config.direction = 'backward';
                        this.config.delay = this.param.paragraphWaitStep;
                     }
                  }
                  break;
               case "backward":
                  if (this.config.skillP > 0) {
                     this.config.text = this.config.text.slice(0, -1);
                     this.config.skillP--;
                  } else {
                     this.config.skillI = (this.config.skillI + 1) % this.runTexts.length;
                     this.config.direction = 'forward';
                  }
                  break;
               default:
                  break;
            }
         }
      }
      if (updateText != null) {
         this.config.text = updateText.substring(0, this.config.skillP);
         if (this.config.skillP > updateText.length) {
            this.config.skillP = updateText.length
         }
      }
      dom.textContent = this.config.text;
      let value;
      if (this.config.prefix < this.param.prefixString.length) {
         value = Math.min(this.colorTextLength, this.colorTextLength + this.config.prefix);
      } else {
         value = Math.min(this.colorTextLength, currentText.length - this.config.skillP);
      }
      dom.appendChild(this.fragment(value));
   }

}

与移植到Vue的代码相比,除了dom传入的方式有点变化,这一点理解起来也容易。重点来了,这里比之前多了个 setParam()方法,它的作用是什么呢?

很简单,因为我的业务在一个class里面,传入参数是给class用的。React组件收到传入参数变化后,手动去更新下业务class里面的参数,这样就能达到响应式更新的效果了。

第三步:开始使用吧

我们在App.tsx中使用该组件。

场景一:一般情况

function App() {

   return (
      <>
         <ColorAnimateTextComponent prefixString={'稀土掘金:'} texts={['CrimsonHu','全栈工程师']}></ColorAnimateTextComponent>
      </>
   );
}

export default App;

gif

场景二:只运行一次

function App() {

   return (
      <>
         <ColorAnimateTextComponent texts={['CrimsonHu']} infinite={false}></ColorAnimateTextComponent>
      </>
   );
}

export default App;

gif

场景三:默认不运行,需手动开启

function App() {

   const component: any = useRef(null);

   return (
      <>
         <button onClick={() => { component.current?.getInstance().resume() }}>开始</button>
         <button onClick={() => { component.current?.getInstance().suspend() }}>暂停</button>
         <ColorAnimateTextComponent prefixString={'稀土掘金:'} texts={['CrimsonHu', '全栈工程师']}
                              defaultRun={false} ref={component}></ColorAnimateTextComponent>
      </>
   );
}

export default App;

gif

场景四:实时更新文字内容

function App() {

   const [flag, setFlag] = useState(true);
   const value1 = useRef(['CrimsonHu', '全栈工程师', '谁会拒绝一个有意思的文字动效呢']);
   const value2 = useRef(['我的技术栈', 'Angular+Vue+React+Java']);

   return (
      <>
         <button onClick={() => { setFlag(!flag) }}>变换</button>
         <ColorAnimateTextComponent texts={flag ? value1.current : value2.current}></ColorAnimateTextComponent>
         <div>
            当前:{flag ? value1.current.toString() : value2.current.toString()}
         </div>
      </>
   );
}

export default App;

gif

如何在自己项目中使用

很简单,我写工具写插件一向不喜欢发npm,我就喜欢最简单直接的,给源码,自己放到项目中直接调用即可。我认为这样的好处就是不会影响现有依赖,以及我直接给你源码,你可以直接学习并使用,更可以自己定制。

本文给了git地址,直接拉取代码,将里面对应框架的组件直接拖到自己的项目中,就可以愉快地使用了。

结束语

以上就是本文的全部内容。也许方法与代码还有欠缺的地方,比如我在一些地方直接使用了any,但是这同样也是简单化处理问题的一种办法,让大家在阅读过程中专注业务、专注思路,而不是去为了一点点小细节花大量时间去写函数体操。具体代码已经提交git,大家可以拉取下来自己玩耍,自己设计一些场景来试试。

color-animate-text: 一个有意思的文字动效,三个框架都可使用 (gitee.com)