使用 vite 構建一個表情選擇外掛

guangzan發表於2021-06-24

初始化

Vite 基於原生 ES 模組提供了豐富的內建功能,開箱即用。同時,外掛足夠簡單,它不需要任何執行時依賴,只需要安裝 vite (用於開發與構建)和 sass (用於開發環境編譯 .scss 檔案)。

npm i -D vite scss

專案配置

同時用 vite 開發外掛和構建外掛 demo,所以我建立了兩個 vite 配置檔案。 在專案根目錄建立 config 資料夾,存放 vite 配置檔案。

外掛配置

config/vite.config.ts 外掛配置檔案

import { defineConfig } from 'vite'
import { resolve } from 'path'

export default defineConfig({
  server: {
    open: true,
    port: 8080
  },
  build: {
    emptyOutDir: true,
    lib: {
      formats: ['es', 'umd', 'iife'],
      entry: resolve(__dirname, '../src/main.ts'),
      name: 'EmojiPopover'
    }
  }
})

server 物件下存放開發時配置。自動開啟瀏覽器,埠號設為 8080。

build 中存放構建時配置。build.emptyOutDir 是指打包時先清空上一次構建生成的目錄。如果這是 webpack,你通常還需要安裝 clean-webpack-plugin,並在 webpack 中進行一系列套娃配置才能實現這個簡單的功能,或者手動新增刪除命令在構建之前。而在 vite 中,僅需一句 emptyOutDir: true

通過 build.lib 開啟 vite 庫模式。vite 預設將 /index.html 作為入口檔案,這通常應用在構建應用時。而構建一個庫通常將 js/ts 作為入口,這在 vite 中同樣容易實現,lib.entry 即可指定 入口為 src/main.ts 檔案,這類似於 webpackConfig.entry。

再通過 lib.formats 指定構建後的檔案格式以及通過 lib.name 指定檔案匯出的變數名稱為 EmojiPopover。

外掛示例配置

給外掛寫一個用於展示使用的網頁,通常將它託管到 Pages 服務。直接通過 vite 本地開發和構建該外掛的示例網頁,同樣容易實現。

config/vite.config.exm.ts 外掛示例配置檔案

import { defineConfig, loadEnv } from 'vite'
import { resolve } from 'path'

export default ({ mode }) => {
  const __DEV__ = mode === 'development'

  return defineConfig({
    base: __DEV__ ? '/' : 'emoji-popover',
    root: 'example',
    server: {
      open: false,
      port: 3000
    },
    build: {
      outDir: '../docs',
      emptyOutDir: true
    }
  })
}

vite 配置檔案還可以以上面這種形式存在,預設匯出一個箭頭函式,函式中再返回 defineConfig,這樣我們可以通過解構直接取得一個引數 mode,通過它來區分當前是開發環境還是生產環境。

config.base 是指開發或生產環境服務的公共基礎路徑。因為我們需要將示例頁面部署到 Pages 服務,生產環境修改 base 以保證能夠正確載入資源。

構建後的示例網頁 html 資源載入路徑:

image

config.root 設定為 'example',因為我將示例頁面資源放到 /example 目錄下

通常構建後的目錄為 dist, 這裡 build.outDir 設為 'docs',原因是 Github Pages 預設只可以部署整個分支或者部署指定的 docs 目錄。即將 example 構建輸出到到 docs 並部署到 Pages 服務。

image

命令配置

我們還需要在 package.json 的 sript 欄位中新增本地開發以及構建的命令,通過 --config <config path> 指定配置檔案路徑,因為我將 vite 配置檔案都放到了 /config 下。

"scripts": {
    "dev": "vite --config config/vite.config.ts",
    "build": "vite build --config config/vite.config.ts",
    "dev:exm": "vite --config config/vite.config.exm.ts",
    "build:exm": "vite build --config config/vite.config.exm.ts"
},
  • dev 啟動外掛開發環境
  • build 構建外掛
  • dev:exm 啟動示例開發環境
  • build:exm 構建示例頁面

編寫外掛

├─src
│  ├─utils
│  │  ├─types.ts
│  │  └─helpers.ts
│  ├─index.scss
│  └─main.ts

main.ts

import { isUrl } from './utils/helper'
import { IEmojiItem, IOptions } from './utils/types'
import './index.scss'

class EmojiPopover {
  private options: IOptions
  private wrapClassName: string
  private wrapCount: number
  private wrapCountClassName: string

  constructor(private opts: IOptions) {
    const defaultOptions: IOptions = {
      container: 'body',
      button: '.e-btn',
      targetElement: '.e-input',
      emojiList: [],
      wrapClassName: '',
      wrapAnimationClassName: 'anim-scale-in'
    }

    this.options = Object.assign({}, defaultOptions, opts)
    this.wrapClassName = 'emoji-wrap'
    this.wrapCount = document.querySelectorAll('.emoji-wrap').length + 1
    this.wrapCountClassName = `emoji-wrap-${this.wrapCount}`

    this.init()
    this.createButtonListener()
  }

  /**
   * 初始化
   */
  private init(): void {
    const { emojiList, container, button, targetElement } = this.options

    const _emojiContainer = this.createEmojiContainer()
    const _emojiList = this.createEmojiList(emojiList)
    const _mask = this.createMask()
    _emojiContainer.appendChild(_emojiList)
    _emojiContainer.appendChild(_mask)

    const _targetElement = document.querySelector<HTMLElement>(targetElement)
    const { left, top, height } = _targetElement.getClientRects()[0]
    _emojiContainer.style.top = `${top + height + 12}px`
    _emojiContainer.style.left = `${left}px`

    const _container: HTMLElement = document.querySelector(container)
    _container.appendChild(_emojiContainer)
  }

  /**
   * 建立按鈕事件
   */
  private createButtonListener(): void {
    const { button } = this.options
    const _button = document.querySelector<HTMLElement>(button)
    _button.addEventListener('click', () => this.toggle(true))
  }

  /**
   * 建立表情皮膚容器
   * @returns {HTMLDivElement}
   */
  private createEmojiContainer(): HTMLDivElement {
    const { wrapAnimationClassName, wrapClassName } = this.options
    const container: HTMLDivElement = document.createElement('div')
    container.classList.add(this.wrapClassName)
    container.classList.add(this.wrapCountClassName)
    container.classList.add(wrapAnimationClassName)
    if (wrapClassName !== '') {
      container.classList.add(wrapClassName)
    }
    return container
  }

  /**
   * 建立表情列表皮膚
   * @param {IEmojiItem} emojiList
   * @returns {HTMLDivElement}
   */
  private createEmojiList(emojiList: Array<IEmojiItem>) {
    const emojiWrap: HTMLDivElement = document.createElement('div')
    emojiWrap.classList.add('emoji-list')

    emojiList.forEach(item => {
      const emojiItem = this.createEmojiItem(item)
      emojiWrap.appendChild(emojiItem)
    })

    return emojiWrap
  }

  /**
   * 建立表情項
   * @param {IEmojiItem} itemData
   * @returns {HTMLDivElement}
   */
  private createEmojiItem(emojiItemData): HTMLDivElement {
    const { value, label } = emojiItemData
    const emojiContainer: HTMLDivElement = document.createElement('div')
    let emoji: HTMLImageElement | HTMLSpanElement

    if (isUrl(value)) {
      emoji = document.createElement('img')
      emoji.classList.add('emoji')
      emoji.classList.add('emoji-img')
      emoji.setAttribute('src', value)
    } else {
      emoji = document.createElement('span')
      emoji.classList.add('emoji')
      emoji.classList.add('emoji-text')
      emoji.innerText = value
    }

    emojiContainer.classList.add('emoji-item')
    emojiContainer.appendChild(emoji)

    if (typeof label === 'string') {
      emojiContainer.setAttribute('title', label)
    }

    return emojiContainer
  }

  /**
   * 建立表情皮膚蒙層
   * @returns {HTMLDivElement}
   */
  private createMask(): HTMLDivElement {
    const mask: HTMLDivElement = document.createElement('div')
    mask.classList.add('emoji-mask')
    mask.addEventListener('click', () => this.toggle(false))
    return mask
  }

  /**
   *  開啟或關閉表情皮膚
   * @param isShow {boolean}
   */
  public toggle(isShow: boolean) {
    const emojiWrap: HTMLElement = document.querySelector(
      `.${this.wrapCountClassName}`
    )
    emojiWrap.style.display = isShow ? 'block' : 'none'
  }

  /**
   * 選擇表情
   */
  public onSelect(callback) {
    const emojiItems = document.querySelectorAll(
      `.${this.wrapCountClassName} .emoji-item`
    )
    const _this = this

    emojiItems.forEach(function (item) {
      item.addEventListener('click', function (e: Event) {
        const currentTarget = e.currentTarget as HTMLElement
        let value

        if (currentTarget.children[0].classList.contains('emoji-img')) {
          value = currentTarget.children[0].getAttribute('src')
        } else {
          value = currentTarget.innerText
        }
        _this.toggle(false)
        callback(value)
      })
    })
  }
}

export default EmojiPopover

編寫 d.ts

使用 rollup 構建庫時,通常藉助 rollup 外掛自動生成 d.ts 檔案。但是嘗試了社群的兩個 vite dts 外掛,效果不盡人意。由於這個專案比較簡單,乾脆直接手寫一個 d.ts 檔案。在 public 下建立 d.ts 檔案,vite 會在構建時自動將 /public 中的資源拷貝到 dist 目錄下。

public/emoji-popover.d.ts

export interface IEmojiItem {
  value: string
  label?: string
}

export interface IOptions {
  button: string
  container?: string
  targetElement: string
  emojiList: Array<IEmojiItem>
  wrapClassName?: string
  wrapAnimationClassName?: string
}

export declare class EmojiButton {
  private options: IOptions
  private wrapClassName: string
  private wrapCount: number
  private wrapCountClassName: string

  constructor(options: IOptions)

  private init(): void
  private createButtonListener(): void
  private createEmojiContainer()
  private createEmojiList()
  private createEmojiItem()
  private createMask()
  /*
   * Toggle emoji popover.
   */
  public toggle(isShow: boolean): void
  /*
   * Listen to Choose an emoji.
   */
  public onSelect(callback: (value: string) => void): void
}

export default EmojiButton

構建生成的檔案結構如下:

├─dist
│  ├─emoji-popover.d.ts
│  ├─emoji-popover.es.js
│  ├─emoji-popover.iife.js
│  ├─emoji-popover.umd.js
│  └─style.css

外掛樣式

有了 CSS 自定義屬性(或稱為 “CSS 變數”),可以不借助 css 前處理器即可實現樣式的定製,且是執行時的。也就是說,可以通過 CSS 自定義屬性實現外掛的樣式定製甚至網頁深色模式的跟隨,本部落格評論框中的 emoji 就是基於這個外掛,它可以跟隨本部落格的深色模式。

:root {
  --e-color-border: #e1e1e1; /* EmojiPopover border color */
  --e-color-emoji-text: #666; /* text emoji font color */
  --e-color-border-emoji-hover: #e1e1e1; /* emoji hover border color */
  --e-color-bg: #fff; /* EmojiPopover background color */
  --e-bg-emoji-hover: #f8f8f8; /* emoji hover background color */
  --e-size-emoji-text: 16px; /* text emoji font size */
  --e-width-emoji-img: 20px;  /* image emoji width */
  --e-height-emoji-img: 20px; /* image emoji height */
  --e-max-width: 288px; /* EmojiPopover max width */
}

.emoji-wrap {
  display: none;
  position: absolute;
  padding: 8px;
  max-width: var(--e-max-width);
  background-color: var(--e-color-bg);
  border: 1px solid var(--e-color-border);
  border-radius: 4px;
  z-index: 3;
  &::before,
  &::after {
    position: absolute;
    content: '';
    margin: 0;
    width: 0;
    height: 0;
  }
  &:after {
    top: -9px;
    left: 14px;
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    border-bottom: 8px solid var(--e-color-border);
  }
  &::before {
    top: -8px;
    left: 14px;
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    border-bottom: 8px solid var(--e-color-bg);
    z-index: 1;
  }
}

.emoji-list {
  display: flex;
  flex-wrap: wrap;
}

.emoji-item {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 6px 6px;
  color: var(--e-color-emoji-text);
  cursor: pointer;
  box-sizing: border-box;
  border: 1px solid transparent;
  border-radius: 4px;
  user-select: none;
  &:hover {
    background: var(--e-bg-emoji-hover);
    border-color: var(--e-color-border-emoji-hover);
    & > .emoji-text {
      transform: scale(1.2);
      transition: transform 0.15s cubic-bezier(0.2, 0, 0.13, 2);
    }
  }
}

.emoji-text {
  font-size: var(--e-size-emoji-text);
  font-weight: 500;
  line-height: 1.2em;
  white-space: nowrap;
}

.emoji-img {
  width: var(--e-width-emoji-img);
  height: var(--e-height-emoji-img);
}

.emoji-mask {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2;
  display: block;
  cursor: default;
  content: ' ';
  background: transparent;
  z-index: -1;
}

.anim-scale-in {
  animation-name: scale-in;
  animation-duration: 0.15s;
  animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5);
}

@keyframes scale-in {
  0% {
    opacity: 0;
    transform: scale(0.5);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

全域性外掛樣式

你可以重寫這些 CSS 變數(CSS 自定義屬性)來定製樣式。

:root {
  --e-color-border: #e1e1e1; /* EmojiPopover border color */
  --e-color-emoji-text: #666; /* text emoji font color */
  --e-color-border-emoji-hover: #e1e1e1; /* emoji hover border color */
  --e-color-bg: #fff; /* EmojiPopover background color */
  --e-bg-emoji-hover: #f8f8f8; /* emoji hover background color */
  --e-size-emoji-text: 16px; /* text emoji font size */
  --e-width-emoji-img: 20px;  /* image emoji width */
  --e-height-emoji-img: 20px; /* image emoji height */
  --e-max-width: 288px; /* EmojiPopover max width */
}

指定例項樣式

如果有多個例項,你可以通過 css 變數 scope 應用到指定例項。

.<custom-class-name> {
  --e-color-border: #e1e1e1; /* EmojiPopover border color */
  --e-color-emoji-text: #666; /* text emoji font color */
  --e-color-border-emoji-hover: #e1e1e1; /* emoji hover border color */
  --e-color-bg: #fff; /* EmojiPopover background color */
  --e-bg-emoji-hover: #f8f8f8; /* emoji hover background color */
  --e-size-emoji-text: 16px; /* text emoji font size */
  --e-width-emoji-img: 20px;  /* image emoji width */
  --e-height-emoji-img: 20px; /* image emoji height */
  --e-max-width: 288px; /* EmojiPopover max width */
}

使用你的 CSS

Emoji Popover 生成非常簡單的 DOM 結構,你也可以使用自己的樣式而不是匯入 style.css

編寫示例網頁

├─example
│  ├─index.html
│  └─index.css

首先安裝已經發布到 npm 的表情彈窗外掛

npm i emoji-popover
example/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DEMO · emoji-popover</title>
  </head>
  <body>
    <div class="container">
      <div class="wrap">
        <input class="e-input" type="text" />
        <button class="e-btn">系統表情</button>
      </div>
      <div class="wrap">
        <input class="e-input-2" type="text" />
        <button class="e-btn-2">文字表情</button>
      </div>
      <div class="wrap">
        <input class="e-input-3" type="text" />
        <button class="e-btn-3">網路圖片</button>
      </div>
    </div>

    <script type="module">
      import EmojiPopover from 'emoji-popover'
      import '../node_modules/emoji-popover/dist/style.css'
      import './index.css'

      const e1 = new EmojiPopover({
        button: '.e-btn',
        container: 'body',
        targetElement: '.e-input',
        emojiList: [
          {
            value: '?',
            label: '笑哭'
          },
          {
            value: '?',
            label: '笑哭'
          },
          {
            value: '?',
            label: '大笑'
          },
          {
            value: '?',
            label: '苦笑'
          },
          {
            value: '?',
            label: '斜眼笑'
          },
          {
            value: '?',
            label: '得意'
          },
          {
            value: '?',
            label: '微笑'
          },
          {
            value: '?',
            label: '酷!'
          },
          {
            value: '?',
            label: '花痴'
          },
          {
            value: '?',
            label: '呵呵'
          },
          {
            value: '?',
            label: '好崇拜哦'
          },
          {
            value: '?',
            label: '思考'
          },
          {
            value: '?',
            label: '白眼'
          },
          {
            value: '?',
            label: '略略略'
          },
          {
            value: '?',
            label: '呆住'
          },
          {
            value: '?',
            label: '大哭'
          },
          {
            value: '?',
            label: '頭炸了'
          },
          {
            value: '?',
            label: '冷汗'
          },
          {
            value: '?',
            label: '嚇死了'
          },
          {
            value: '?',
            label: '略略略'
          },
          {
            value: '?',
            label: '暈'
          },
          {
            value: '?',
            label: '憤怒'
          },
          {
            value: '?',
            label: '祝賀'
          },
          {
            value: '?',
            label: '小丑竟是我'
          },
          {
            value: '?',
            label: '噓~'
          },
          {
            value: '?',
            label: '猴'
          },
          {
            value: '?',
            label: '笑笑不說話'
          },
          {
            value: '?',
            label: '牛'
          },
          {
            value: '?',
            label: '啤酒'
          }
        ]
      })

      e1.onSelect(value => {
        document.querySelector('.e-input').value += value
      })

      const e2 = new EmojiPopover({
        button: '.e-btn-2',
        container: 'body',
        targetElement: '.e-input-2',
        emojiList: [
          {
            value: '(=・ω・=)',
            label: ''
          },
          {
            value: '(`・ω・´)',
            label: ''
          },
          {
            value: '(°∀°)ノ',
            label: ''
          },
          {
            value: '←_←',
            label: ''
          },
          {
            value: '→_→',
            label: ''
          },
          {
            value: 'Σ(゚д゚;)',
            label: ''
          },
          {
            value: '(。・ω・。)',
            label: ''
          },
          {
            value: '(-_-#)',
            label: ''
          }
        ]
      })

      e2.onSelect(value => {
        document.querySelector('.e-input-2').value += value
      })

      const e3 = new EmojiPopover({
        button: '.e-btn-3',
        container: 'body',
        targetElement: '.e-input-3',
        emojiList: [
          {
            value:
              'https://img1.baidu.com/it/u=3060109128,4247188337&fm=26&fmt=auto&gp=0.jpg',
            label: ''
          },
          {
            value:
              'https://img2.baidu.com/it/u=358795348,3036825421&fm=26&fmt=auto&gp=0.jpg',
            label: ''
          }
        ]
      })

      e3.onSelect(value => {
        document.querySelector('.e-input-3').value += value
      })
    </script>
  </body>
</html>

構建後的示例目錄如下,你也可以點選 這裡 檢視示例

image

連結

相關文章