5
头图

先睹为快

如下图,代码在自己一行一行写程序,逐渐画出一个喜气灯笼的模样(PC移动端都支持噢),想不想知道是它怎么实现的呢?和胖头鱼一起来探究一番吧O(∩_∩)O~

你也可以直接点击 用程序自动画了一个灯笼 体验一番,胖头鱼的掘金活动仓库查看源码

原理探究

这个效果就好像一个打字员在不断地录入文字,页面呈现动态效果。又好像一个早已经录制好影片,而我们只是坐在放映机前观看。

原理本身也非常简单,只要你会一点点前端知识,就可以马上亲手做一个出来。

1. 滚动的代码

定时器字符累加: 相信聪明的你早已经猜到屏幕中滚动的htmlcss代码就是通过启动一个定时器,然后将预先准备好的字符,不断累加到一个pre标签中。

2. 灯笼的布局

动态添加html片段css片段,一张静态网页由htmlcss组成,灯笼能不断地发生变化,背后自然是组成灯笼的htmlcss不断变化的结果。

3. 例子解释

想象一下你要往一张网页每间隔0.1秒增加一个字,是不是开个定时器,间断地往body里面塞,就可以啊!没错,做到这一步就完成了原理的第一部分

再想象一下,在往页面里面塞的时候,我还想改变啊字的字体颜色以及网页背景颜色,那应该怎么做呢,是不是执行下面的代码就可以呢?

.xxx{
  color: blue;
  background: red; 
}

没错,只不过更改字体和背景色不是突然改变的,而是开个定时器,间断地往style标签中塞入以下代码,这样就完成了原理的第二步,是不是好简单 , 接下来让我们一步步完成它。

简要解析

1.编辑器布局

工欲善其事,必先利其器。在实现代码自己画画的前提是有个类似编辑器地方给他show,所以会有编辑htmlcss和预览三个区域。

移动端布局

上下结构布局,上面是htmlcss的编辑区域,下面的灯笼的展示区域

PC端布局

左右结构布局,左边是htmlcss的编辑区域,右边是灯笼的展示区域

模板

<template>
  <div :class="containerClasses">
    <div class="edit">
      <div class="html-edit" ref="htmlEditRef">
        <!-- 这是html代码编辑区域 -->
        <pre v-html="htmlEditPre" ref="htmlEditPreRef"></pre>
      </div>
      <div class="css-edit" ref="cssEditRef">
        <!-- 这是css代码编辑区域 -->
        <pre v-html="styleEditPre"></pre>
      </div>
    </div>
    <div class="preview">
      <!-- 这是预览区域,灯笼最终会被画到这里噢 -->
      <div class="preview-html" v-html="previewHtmls"></div>
      <!-- 这里是样式真正起作用的地方,密码就隐藏... -->
      <div v-html="previewStyles"></div>
    </div>
  </div>
</template>

端控制

简单的做一下移动端和PC端的适配,然后通过样式去控制布局即可
computed: {
containerClasses () {
  // 做一个简单的适配
  return [
    'container',
    isMobile() ? 'container-mobile' : ''
  ]
}
}

2.代码高亮

示例中的代码高亮是借助prismjspre进行转化处理的,只需要填充你想要高亮的代码,以及选择高亮的语言就可以实现上述效果。
// 核心代码,只有一行
this.styleEditPre = Prism.highlight(previewStylesSource, Prism.languages.css)

3. 灯笼布局实现

要实现灯笼不断变化的布局,需要两个东西,一个是灯笼本身的html元素还有就是控制html样式的css

通过preview-html`承载html片段,通过previewStyles承载由style标签包裹的css`样式

// 容器
<div class="preview">
  <!-- 这是预览区域,灯笼最终会被画到这里噢 -->
  <div class="preview-html" v-html="previewHtmls"></div>
  <!-- 这里是样式真正起作用的地方 -->
  <div v-html="previewStyles"></div>
</div>

逻辑代码

// 样式控制核心代码
this.previewStyles = `
  <style>
    ${previewStylesSource}
  </style>
`
// html控制核心代码
this.previewHtmls = previewHtmls

4. 代码配置预览

我们通过一个个步骤将代码按阶段去执行,而代码本身是通过两个文件进行配置的,一个是控制html的文件,一个是控制css的文件。每一个步骤都是数组的一项

4.1 html配置

注意下面的代码格式是故意弄成这种格式的,并非是没有对齐
export default [
  // 开头寒暄
  `
  <!-- 
    XDM好,我是前端胖头鱼~~~
    听说掘金又在搞活动了,奖品还很丰厚...
    我能要那个美腻的小姐姐吗?
  -->
  `,
  // 说明主旨
  `
  <!-- 
    以前都是用“手”写代码,今天想尝试一下
    “代码写代码”,自动画一个喜庆的灯笼
  -->  
  `,
  // 创建编辑器
  `
  <!-- 
    第①步,先创建一个编辑器
  -->  
  `,
  // 创建编辑器html结构
  ` 
  <div class="container">
    <div class="edit">
      <div class="html-edit">
        <!-- 这是html代码编辑区域 -->
        <pre v-html="htmlEditPre">
          <!-- htmlStep0 -->
        </pre>
      </div>
      <div class="css-edit">
        <!-- 这是css代码编辑区域 -->
        <pre v-html="cssEditPre"></pre>
      </div>
    </div>
    <div class="preview">
      <!-- 这是预览区域,灯笼最终会被画到这里噢 -->
      <div class="preview-html"></div>
      <!-- 这里是样式真正起作用的地方,密码就隐藏... -->
      <div v-html="cssEditPre"></div>
    </div>
  </div>
  `,
  // 开始画样式
  `
  <!-- 
    第②步,给编辑器来点样式,我要开始画了喔~~
  -->  
  `,
  // 画灯笼的大肚子
  `
          <!-- 第③步,先画灯笼的大肚子结构 -->
          <div class="lantern-container">
            <!-- htmlStep1 -->
            <!-- 大红灯笼区域 -->
            <div class="lantern-light">
            <!-- htmlStep2 -->
            </div>
          </div>
  `,
  // 提着灯笼的线
  `
            <!-- 第④步,灯笼顶部是有根线的 -->
            <div class="lantern-top-line"></div>
  `,
  `
              <!-- 第⑤步,给灯笼加两个盖子 -->
              <div class="lantern-hat-top"></div>
              <div class="lantern-hat-bottom"></div>
              <!-- htmlStep3 -->
  `,
  `
              <!-- 第⑥步,感觉灯笼快要成了,再给他加上四根线吧 -->
              <div class="lantern-line-out">
                <div class="lantern-line-innner">
                  <!-- htmlStep5 -->
                </div>
              </div>
              <!-- htmlStep4 -->
  `,
  `
              <!-- 第⑦步,灯笼是不是还有底部的小尾巴呀 -->
              <div class="lantern-rope-top">
                <div class="lantern-rope-middle"></div>
                <div class="lantern-rope-bottom"></div>
              </div>
  `,
  `
                <!-- 第⑧步,最后当然少不了送给大家的福啦 -->
                <div class="lantern-fu">福</div>
  `

]

4.2 css配置

export default [
  // 0. 添加基本样式
  `
  /* 首先给所有元素加上过渡效果 */
  * {
    transition: all .3s;
    -webkit-transition: all .3s;
  }
  /* 白色背景太单调了,我们来点背景 */
  html {
    color: rgb(222,222,222); 
    background: rgb(0,43,54); 
  }
  /* 代码高亮 */
  .token.selector{ 
    color: rgb(133,153,0); 
  }
  .token.property{ 
    color: rgb(187,137,0); 
  }
  .token.punctuation{ 
    color: yellow; 
  }
  .token.function{ 
    color: rgb(42,161,152); 
  }
  `,
  // 1.创建编辑器本身的样式
  `
  /* 我们需要做一个铺满全屏的容器 */
    .container{
      width: 100%;
      height: 100vh;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    /* 代码编辑区域50%宽度,留一些空间给预览区域 */
    .edit{
      width: 50%;
      height: 100%;
      background-color: #1d1f20;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
    }

    .html-edit, .css-edit{
      flex: 1;
      overflow: scroll;
      padding: 10px;
    }

    .html-edit{
      border-bottom: 5px solid #2b2e2f;
    }
    /* 预览区域有50%的空间 */
    .preview{
      flex: 1;
      height: 100%;
      background-color: #2f1f47;
    }

    .preview-html{
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
    }

    /* 好啦~ 你应该看到一个编辑器的基本感觉了,我们要开始画灯笼咯 */
  `,
  // 2
  `
  /* 给灯笼的大肚子整样式 */
  .lantern-container {
    position: relative;
  }

  .lantern-light {
    position: relative;
    width: 120px;
    height: 90px;
    background-color: #ff0844;
    border-radius: 50%;
    box-shadow: -5px 5px 100px 4px #fa6c00;
    animation: wobble 2.5s infinite ease-in-out;
    transform-style: preserve-3d;
  }
  /* 让他动起来吧 */
  @keyframes wobble {
    0% {
      transform: rotate(-6deg);
    }

    50% {
      transform: rotate(6deg);
    }

    100% {
      transform: rotate(-6deg);
    }
  }
  `,
  // 3
  `
  /* 顶部的灯笼线 */
  .lantern-top-line {
    width: 4px;
    height: 50px;
    background-color: #d1bb73;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    top: -20px;
    border-radius: 2px 2px 0 0;
  }
  `,
  // 4
  `
  /* 灯笼顶部、底部盖子样式 */
  .lantern-hat-top,
  .lantern-hat-bottom {
    content: "";
    position: absolute;
    width: 60px;
    height: 12px;
    background-color: #ffa500;
    left: 50%;
    transform: translateX(-50%);
  }
  /* 顶部位置 */
  .lantern-hat-top {
    top: -8px;
    border-radius: 6px 6px 0 0;
  }
  /* 底部位置 */
  .lantern-hat-bottom {
    bottom: -8px;
    border-radius: 0 0 6px 6px;
  }
  `,
  // 5
  `
  /* 灯笼中间的线条 */
  .lantern-line-out,
  .lantern-line-innner {
    height: 90px;
    border-radius: 50%;
    border: 2px solid #ffa500;
    background-color: rgba(216, 0, 15, 0.1);
  }
  /* 线条外层 */
  .lantern-line-out {
    width: 100px;
    margin: 12px 8px 8px 10px;
  }
  /* 线条内层 */
  .lantern-line-innner {
    margin: -2px 8px 8px 26px;
    width: 45px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  `,
  // 6
  `
  /* 灯笼底部线条 */
  .lantern-rope-top {
    width: 6px;
    height: 18px;
    background-color: #ffa500;
    border-radius: 0 0 5px 5px;
    position: relative;
    margin: -5px 0 0 60px;
    /* 让灯穗也有一个动画效果 */
    animation: wobble 2.5s infinite ease-in-out;
  }

  .lantern-rope-middle,
  .lantern-rope-bottom {
    position: absolute;
    width: 10px;
    left: -2px;
  }

  .lantern-rope-middle {
    border-radius: 50%;
    top: 14px;
    height: 10px;
    background-color: #dc8f03;
    z-index: 2;
  }

  .lantern-rope-bottom {
    background-color: #ffa500;
    border-bottom-left-radius: 5px;
    height: 35px;
    top: 18px;
    z-index: 1;
  }
  `,
  // 7
  `
  /* 福样式 */
  .lantern-fu {
    font-size: 30px;
    font-weight: bold;
    color: #ffa500;
  }
  `
]

整体流程

实现原理和整个过程所需的知识点,通过简要解析相信你已经明白了,接下来我们要做的事情就是把这些知识点组合在一起,完成自动画画。
import Prism from 'prismjs'
import htmls from './config/htmls'
import styles from './config/styles'
import { isMobile, delay } from '../../common/utils'

export default {
  name: 'newYear2022',
  data () {
    return {
      // html代码展示片段
      htmlEditPre: '',
      htmlEditPreSource: '',
      // css代码展示片段
      styleEditPre: '',
      // 实际起作用的css
      previewStylesSource: '',
      previewStyles: '',
      // 预览的html
      previewHtmls: '',

    }
  },
  computed: {
    containerClasses () {
      // 做一个简单的适配
      return [
        'container',
        isMobile() ? 'container-mobile' : ''
      ]
    }
  },
  async mounted () {
    // 1. 打招呼
    await this.doHtmlStep(0)
    // 2. 说明主旨
    await this.doHtmlStep(1)

    await delay(500)

    // 3. 第一步声明
    await this.doHtmlStep(2)

    await delay(500)
    // 4. 创建写代码的编辑器
    await this.doHtmlStep(3)
    await delay(500)
    // 5. 准备写编辑器的样式
    await this.doHtmlStep(4)
    await delay(500)
    // 6. 基本样式
    await this.doStyleStep(0)
    await delay(500)
    // 7. 编辑器的样式
    await this.doStyleStep(1)
    await delay(500)
    // 8. 画灯笼的大肚子html
    await Promise.all([ 
      this.doHtmlStep(5, 0), 
      this.doEffectHtmlsStep(5, 0),
    ])
    await delay(500)
    // 8. 画灯笼的大肚子css
    await this.doStyleStep(2)
    await delay(500)
    // 9. 提着灯笼的线html
    await Promise.all([ 
      this.doHtmlStep(6, 1), 
      this.doEffectHtmlsStep(6, 1),
    ])
    await delay(500)
    // 10. 提着灯笼的线css
    await this.doStyleStep(3)
    await delay(500)
    // 11. 给灯笼加两个盖子html
    await Promise.all([ 
      this.doHtmlStep(7, 2), 
      this.doEffectHtmlsStep(7, 2),
    ])
    await delay(500)
    // 12. 给灯笼加两个盖子css
    await this.doStyleStep(4)
    await delay(500)
    // 13. 感觉灯笼快要成了,再给他加上四根线吧html
    await Promise.all([ 
      this.doHtmlStep(8, 3), 
      this.doEffectHtmlsStep(8, 3),
    ])
    await delay(500)
    // 14. 感觉灯笼快要成了,再给他加上四根线吧css
    await this.doStyleStep(5)
    await delay(500)
    // 15. 灯笼是不是还有底部的小尾巴呀html
    await Promise.all([ 
      this.doHtmlStep(9, 4), 
      this.doEffectHtmlsStep(9, 4),
    ])
    await delay(500)
    // 16. 灯笼是不是还有底部的小尾巴呀css
    await this.doStyleStep(6)
    await delay(500)
    // 17. 最后当然少不了送给大家的福啦html
    await Promise.all([ 
      this.doHtmlStep(10, 5), 
      this.doEffectHtmlsStep(10, 5),
    ])
    await delay(500)
    // 18. 最后当然少不了送给大家的福啦css
    await this.doStyleStep(7)
    await delay(500)
  },
  methods: {
    // 渲染css
    doStyleStep (step) {
      const cssEditRef = this.$refs.cssEditRef

      return new Promise((resolve) => {
        // 从css配置文件中取出第n步的样式
        const styleStepConfig = styles[ step ]

        if (!styleStepConfig) {
          return
        }

        let previewStylesSource = this.previewStylesSource
        let start = 0
        let timter = setInterval(() => {
          // 挨个累加
          let char = styleStepConfig.substring(start, start + 1)

          previewStylesSource += char

          if (start >= styleStepConfig.length) {
            console.log('css结束')
            clearInterval(timter)
            resolve(start)
          } else {
            this.previewStylesSource = previewStylesSource
            // 左边编辑器展示给用户看的
            this.styleEditPre = Prism.highlight(previewStylesSource, Prism.languages.css)
            // 右边预览区域实际起作用的css
            this.previewStyles = `
              <style>
                ${previewStylesSource}
              </style>
            `
            start += 1
            // 因为要不断滚动到底部,简单粗暴处理一下
            document.documentElement.scrollTo({
              top: 10000,
              left: 0,
            })
            // 因为要不断滚动到底部,简单粗暴处理一下
            cssEditRef && cssEditRef.scrollTo({
              top: 100000,
              left: 0,
            })
          }
        }, 0)
      })
    },
    // 渲染html
    doEffectHtmlsStep (step, insertStepIndex = -1) {
      // 注意html部分和css部分最大的不同在于后面的步骤是有可能插入到之前的代码中间的,并不是一味地添加到尾部
      // 所以需要先找到标识,然后插入
      const insertStep = insertStepIndex !== -1 ? `<!-- htmlStep${insertStepIndex} -->` : -1
      return new Promise((resolve) => {
        const htmlStepConfig = htmls[ step ]
        let previewHtmls = this.previewHtmls
        const index = previewHtmls.indexOf(insertStep)
        const stepInHtmls = index !== -1
        
        let frontHtml = stepInHtmls ? previewHtmls.slice(0, index + insertStep.length) : previewHtmls
        let endHtml = stepInHtmls ? previewHtmls.slice(index + insertStep.length) : ''
        
        let start = 0
        let chars = ''
        let timter = setInterval(() => {
          let char = htmlStepConfig.substring(start, start + 1)
          // 累加字段
          chars += char

          previewHtmls = frontHtml + chars + endHtml

          if (start >= htmlStepConfig.length) {
            console.log('html结束')
            clearInterval(timter)
            resolve(start)
          } else {
            // 赋值html片段
            this.previewHtmls = previewHtmls
            start += 1
          }
        }, 0)
      })
    },
    // 编辑区域html高亮代码
    doHtmlStep (step, insertStepIndex = -1) {
      const htmlEditRef = this.$refs.htmlEditRef
      const htmlEditPreRef = this.$refs.htmlEditPreRef
      // 同上需要找到插入标志
      const insertStep = insertStepIndex !== -1 ? `<!-- htmlStep${insertStepIndex} -->` : -1
      return new Promise((resolve) => {
        const htmlStepConfig = htmls[ step ]
        let htmlEditPreSource = this.htmlEditPreSource
        const index = htmlEditPreSource.indexOf(insertStep)
        const stepInHtmls = index !== -1
        // 按照条件拼接代码
        let frontHtml = stepInHtmls ? htmlEditPreSource.slice(0, index + insertStep.length) : htmlEditPreSource
        let endHtml = stepInHtmls ? htmlEditPreSource.slice(index + insertStep.length) : ''
        
        let start = 0
        let chars = ''
        let timter = setInterval(() => {
          let char = htmlStepConfig.substring(start, start + 1)

          chars += char

          htmlEditPreSource = frontHtml + chars + endHtml

          if (start >= htmlStepConfig.length) {
            console.log('html结束')
            clearInterval(timter)
            resolve(start)
          } else {
            this.htmlEditPreSource = htmlEditPreSource
            // 代码高亮处理
            this.htmlEditPre = Prism.highlight(htmlEditPreSource, Prism.languages.html)
            start += 1

            if (insertStep !== -1) {
              // 当要插入到中间时,滚动条滚动到中间,方便看代码
              htmlEditRef && htmlEditRef.scrollTo({
                top: (htmlEditPreRef.offsetHeight - htmlEditRef.offsetHeight) / 2,
                left: 1000,
              })
            } else {
              // 否则直接滚动到底部
              htmlEditRef && htmlEditRef.scrollTo({
                top: 100000,
                left: 0,
              })
            }
          }
        }, 0)
      })
    },
  }
}

结尾

马上就要新年啦!愿大家新年快乐,“码”到成功。

参考

  1. 过年了~我用CSS画了个灯笼,看着真喜庆
  2. 用原生 js 写一个 "多动症" 的简历

前端胖头鱼
3.7k 声望6.2k 粉丝