基於 vite2 + Vue3 寫一個線上幫助文件工具

金色海洋(jyk)發表於2022-03-11

提起幫助文件,想必大家都會想到 VuePress等,我也體驗了一下,但是感覺和我的思路不太一樣,我希望的是那種可以直接線上編輯文件,然後無需編譯就可以直接釋出的方式,另外可以線上寫(修改)程式碼並且執行的效果。

VuePress 是“靜態網站生成器”,需要我們自行編寫文件,然後交給VuePress變成網站,VuePress 並沒有提供編寫環境,我知道有很多編寫 Markdown 的方式,但是我還是喜歡編寫、瀏覽合為“一體”的方式。

似乎沒有,那麼 —— 自己動手豐衣足食吧,開幹!

技術棧

  • vite: ^2.7.0
  • vue: ^3.2.23
  • axios: ^0.25.0 獲取json格式的配置和文件
  • element-plus: ^2.0.2 UI庫
  • nf-ui-elp": ^0.1.0 二次封裝的UI庫
  • @element-plus/icons-vue: ^0.2.4 圖示
  • @kangc/v-md-editor:"^2.3.13 md 編輯器
  • vite-plugin-prismjs: ^0.0.8 程式碼高亮
  • nf-state": ^0.2.4 狀態管理
  • nf-web-storage": ^0.2.3 訪問 indexedDB

建立庫專案(@naturefw/press-edit)實現文件的編寫、瀏覽功能

首先使用 vite2 建立一個 Vue3 的專案:

  • 安裝 elementPlus 實現頁面效果;
  • 安裝 v-md-editor 實現 Markdown 的編輯和顯示;
  • 安裝 @naturefw/storage 操作 indexedDB ,實現幫助文件的儲存;
  • 安裝 @naturefw/nf-state 實現狀態管理;
  • 安裝axios 用於載入 json檔案,實現匯入功能。
  • 用node寫一個後端API,實現寫入json檔案的功能。

注意:庫專案需要安裝以上外掛,幫助文件專案只需要安裝 @naturefw/press-edit 即可。

基本功能就是這樣,心急的可以先看線上演示和原始碼。

  1. 線上演示:https://nfpress.gitee.io/nf-press-edit/
  2. 原始碼:https://gitee.com/nfpress/nf-press-edit

兩個狀態:編輯和瀏覽

一開始做了兩個專案,分別實現編輯文件和顯示文件的功能,但是後來發現,內部程式碼大部分是相同的,維護的時候有點麻煩,所以改為在編輯文件的專案里加入“瀏覽”的狀態,然後設定切換的功能,這樣便於內部程式碼的維護,以後成熟了可能會分為兩個單獨的專案。

編輯狀態的功能

  • 選單維護
  • 文件維護
  • 文件展示
  • 匯入匯出
  • 線上編寫/執行程式碼

我喜歡線上編輯的方式,這樣更省心,於是我用 el-menu 實現導航和左側的選單,然後加上了維護功能。
使用 v-md-editor 實現 Markdown 的編輯和顯示。
然後用node寫了一個後端API,實現儲存 json檔案的功能,這樣就完美了。

瀏覽狀態的功能

  • 導航
  • 選單
  • 文件展示
  • 執行程式碼

就是在編輯狀態的功能的基礎上,去掉一些功能。或者其實可以反過來思考。

實現導航

首先參考 VuePress 設定一個json檔案,用於載入和儲存網站資訊、導航資訊。

/public/docs/.nfpress/project.json

{
  "projectId": "1000",
  "title": "nf-press-edit !",
  "description": "這是一個線上編輯、展示文件的小工具",
  "navi": [
    {
      "naviId": "1010",
      "text": "指南",
      "link": "menu"
    },
    {
      "naviId": "1020",
      "text": "元件",
      "link": "menu"
    },
    {
      "naviId": "1380",
      "text": "Gitee",
      "link": "https://gitee.com/nfpress/nf-press-edit"
    },
    {
      "naviId": "1390",
      "text": "線上演示",
      "link": "https://nfpress.gitee.io/nf-press-edit/"
    },
    {
      "naviId": "1395",
      "text": "我要提意見",
      "link": "https://gitee.com/nfpress/nf-press-edit/issues"
    }
  ]
}
  • projectId:專案ID,可以用於區分不同的幫助文件專案。
  • navi: 存放導航項。
  • naviId: 關聯到選單。
  • text: 導航上顯示的文字。
  • link: 連線方式或連結地址。menu:表示要開啟對應的選單;URL:在新頁面裡開啟連線。

然後做一個元件,用 el-menu 繫結資料渲染出來即可實現導航效果。

/lib/navi/navi.vue

  <el-menu
    :default-active="activeIndex2"
    class="el-menu-demo"
    mode="horizontal"
    v-bind="$attrs"
    :background-color="backgroundColor"
    @select="handleSelect"
  >
    <el-menu-item
      v-for="(item, index) in naviList"
      :key="index"
      :index="item.naviId"
    >
      {{item.text}}
    </el-menu-item>
  </el-menu>

可以是多級的導航,暫時沒有實現線上維護功能。

  import { ref } from 'vue'
  import { ElMenu, ElMenuItem } from 'element-plus'
  import { state } from '@naturefw/nf-state'
   
  const props = defineProps({
    'background-color': { // 預設背景色
      type: String,
      default: '#ece5d9'
    },
    itemProps: Object
  })

  // 獲取狀態和導航內容
  const { current, naviList } = state
  // 啟用第一個導航項
  const activeIndex2 = ref(naviList[0].naviId)
  
  const handleSelect = (key, keyPath) => {
    const navi = naviList.find((item) => item.naviId === key)
    if (navi.link === 'menu') {
      // 開啟選單
      current.naviId = key
    } else {
      // 開啟連線
      window.open(navi.link, '_blank')
    }
  }
  • @naturefw/nf-state
    自己寫的一個輕量級狀態管理,可以當做大號 reactive 來使用,通過狀態管理載入 project.json 然後繫結渲染。

  • naviList
    導航列表,由狀態管理載入。

  • current
    當前啟用的各種資訊,比如“current.naviId”表示啟用的導航項。

實現選單

和導航類似,只是需要增加兩個功能:n級分組和維護。

首先參考 VuePress 設定一個json檔案,儲存選單資訊。

/public/docs/.nfpress/menu.json

[
  {
    "naviId": "1010",
    "menus": [
      {
        "menuId": "110100",
        "text": "介紹",
        "description": "描述",
        "icon": "FolderOpened",
        "children": []
      },
      {
        "menuId": "111100",
        "text": "快速上手",
        "description": "描述",
        "icon": "FolderOpened",
        "children": [
          {
            "menuId": 111120,
            "text": "編輯文件專案",
            "description": "",
            "icon": "UserFilled",
            "children": []
          },
          {
            "menuId": 111130,
            "text": "展示文件專案",
            "description": "",
            "icon": "UserFilled"
          }
        ]
      } 
    ],
    "ver": 1.6
  },
  {
    "naviId": "1020",
    "menus": [
      {
        "menuId": "21000",
        "text": "導航(docNavi)",
        "description": "描述",
        "icon": "Star",
        "children": []
      } 
    ],
    "ver": 1.5
  }
]
  • naviId: 關聯導航項ID,可以是數字,也可以是其他字元。需要和導航項ID對應。
  • menus: 導航項對應的選單項集合。
  • menuId: 選單項ID,關聯一個文件,可以是數字或者英文。
  • text: 選單項名稱。
  • description: 描述,考慮以後用於查詢。
  • icon: 選單使用的圖示名稱。
  • children: 子選單專案,沒有的話可以去掉。
  • ver: 版本號,便於更新文件。

然後用 el-menu 繫結資料渲染,因為要實現n級分組,所以做一個遞迴元件實現n級選單的效果。

實現n級分組選單

做一個遞迴元件實現n級分組的功能:

/lib/menu/menu-sub-edit.vue

  <template v-for="(item, index) in subMenu">
    <!--樹枝-->
    <template v-if="item.children && item.children.length > 0">
      <el-sub-menu 
        :key="item.menuId + '_' + index"
        :index="item.menuId"
        style="vertical-align: middle;"
      >
        <template #title>
          <div style="display:inline;width: 100%;">
            <component
              :is="$icon[item.icon]"
              style="width: 1.5em; height: 1.5em; margin-right: 8px;vertical-align: middle;"
            >
            </component>
            <span>{{item.text}}</span>
          </div>
        </template>
        <!--遞迴子選單-->
        <my-sub-menu2
          :subMenu="item.children"
          :dialogAddInfo="dialogAddInfo"
          :dialogModInfo="dialogModInfo"
        />
      </el-sub-menu>
    </template>
    <!--樹葉-->
    <el-menu-item v-else
      :index="item.menuId"
      :key="item.menuId + 'son_' + index"
    >
      <template #title>
        <div style="display:inline;width: 100%;">
          <span style="float: left;">
            <component
              :is="$icon[item.icon]"
              style="width: 1.5em; height: 1.5em; margin-right: 8px;vertical-align: middle;"
            >
            </component>
            <span >{{item.text}}</span>
          </span>
        </div>
      </template>
    </el-menu-item>
  </template>
  import { ElMenuItem, ElSubMenu } from 'element-plus'
  // 展示子選單 - 遞迴
  import mySubMenu2 from './menu-sub.vue'

  const props = defineProps({
    subMenu: Array, // 要顯示的選單,可以n級
    dialogAddInfo: Object, // 新增選單
    dialogModInfo: Object // 修改選單
  })
  • subMenu 要顯示的子選單項
  • dialogAddInfo 新增選單的資訊
  • dialogModInfo 修改選單的資訊

實現選單的維護功能

這個就比較簡單了,做個表單實現選單的增刪改即可,篇幅有限跳過。

實現 Markdown 的編輯

使用 v-md-editor 實現 Markdown 的編輯和展示,首先該外掛非常好用,其次支援VuePress的主題。

建立 /lib/md/md-edit.vue 實現編輯 Markdown 的功能:

  <v-md-editor
    :toolbar="toolbar"
    left-toolbar="undo redo clear | tip emoji code | h bold italic strikethrough quote | ul ol table hr | link image  | save | customToolbar"
    :include-level="[1, 2, 3, 4]"
    v-model="current.docInfo.md"
    :height="editHeight + 'px'"
    @save="mySave"
  >
  </v-md-editor>
  import { watch,ref  } from 'vue'
  import { ElMessage, ElRadioGroup, ElRadioButton } from 'element-plus'
  import mdController from '../service/md.js'
  
  // 狀態
  import { state } from '@naturefw/nf-state'

  // 獲取當前啟用的資訊
  const current = state.current
  // 文件的載入和儲存
  const { loadDocById, saveDoc } = mdController()
  
  // 可見的高度
  const editHeight = document.documentElement.clientHeight - 200

  // 單擊 儲存 按鈕,實現儲存功能
  const mySave = (text, html) => {
    saveDoc(current)
  }
  // 定時儲存
  let timeout = null
  let isSaved = true
  const timeSave = () => {
    if (isSaved) {
      // 儲存過了,重新計時
      isSaved = false
    } else {
      return // 有計時,退出
    }

    timeout = setTimeout(() => {
      // 儲存文件
      saveDoc(current).then(() => {
        ElMessage({
          message: '自動儲存文件成功!',
          type: 'success',
        })
      })
      isSaved = true
    }, 10000)
  }

  // 定時儲存文件
  watch(() => current.docInfo.md, () => {
    timeSave()
  })

  // 根據啟用的選單項,載入對應的文件
  watch( () => current.menuId, async (id) => {
    const ver = current.ver
    loadDocById(id, ver).then((res) => {
      // 找到了文件
      Object.assign(current.docInfo, res)
    }).catch((res) => {
      // 沒有文件
      Object.assign(current.docInfo, res)
    })
  })

  • mdController 實現文件的增刪改查的controller
  • timeSave 定時儲存文件,避免忘記點儲存按鈕

是不是挺簡單的。

實現線上編寫程式碼並且執行的功能

因為是基於Vue3建立的專案,而且也是為了寫vue3相關的幫助文件,那麼就有一個很實用的要求:線上寫程式碼並且可以執行

個人感覺這個功能還是很實用的,我知道有第三方網站提供了這種功能,但是網速有點慢,另外有一種大炮打蚊子的感覺,我只需要實現簡單的程式碼演示。

於是我基於 vue 的 defineAsyncComponent 寫了一個簡單版的線上編寫程式碼且執行的功能:

/lib/runCode/run.vue

  <div style="padding: 5px; border: 1px solid #ccc!important;">
    <async-comp></async-comp>
  </div>

  import {
    defineAsyncComponent,
    ref, reactive,...
    // 其他常用的vue內建指令
  } from 'vue'

  // 使用 eval編譯js程式碼
  const mysetup = `
    (function setup () {
      {{code}}
    })
  `
  
  // 通過屬性傳入需要執行的程式碼和模板
  const props = defineProps({
    code: {
      type: Object,
      default: () => {
        return {
          js: '',
          template: '',
          style: ''
        }
      }
    }
  })

  const code = props.code

  // 使用 defineAsyncComponent 讓程式碼執行起來
  const AsyncComp = defineAsyncComponent(
    () => new Promise((resolve, reject) => {
        resolve({
          template: code.template, // 設定模板
          style: [code.style], // 大概是樣式設定,但是好像沒啥效果
          setup: (props, ctx) => {
            const tmpJs = code.js // 獲取js程式碼
            let fun = null // 轉換後的函式
            try {
              if (tmpJs)
                fun = eval(mysetup.replace('{{code}}', tmpJs)) // 用 eval 把 字串 變成 函式
            } catch (error) {
              console.error('轉換出現異常:', error)
            }

            const re = typeof fun === 'function' ? fun : () => {}

            return {
              ...re(props, ctx) // 執行函式,解構返回物件
            }
          }
        })
      })
  )
  • defineAsyncComponent
    實用 defineAsyncComponent 載入元件,需要設定三個部分:模板、setup和style。

  • template: 字串形式,可以直接傳入

  • setup: js程式碼,可以用eval的方式進行動態編譯。

  • style: 可以設定樣式。

這樣即可讓線上編寫的程式碼執行起來,當然功能有限,只能用於一些簡單的程式碼演示。

匯出

以上這些功能都是基於 indexedDB 進行的,想要釋出的話,需要先匯出為json檔案。

因為瀏覽器裡不能直接寫檔案,所以需要使用折中的方式:

  • 複製貼上
  • 下載
  • 匯出

複製貼上

這個簡單,用文字域顯示json即可。

下載

使用 chrome 瀏覽器提供的下載功能下載檔案。

  const uri = 'data:text/json;charset=utf-8,\ufeff' + encodeURIComponent(show.navi)

  //通過建立a標籤實現
  var link = document.createElement("a")
  link.href = uri
  //對下載的檔案命名
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)

以上介紹的是內部原理,如果只是想簡單使用的話,可以跳過,直接看下面的介紹。

用後端寫檔案

以上兩種都不太方便,於是用node做了個簡單的後端API,用於實現寫入json檔案的功能。

程式碼放在了 api資料夾裡,可以使用 yarn api執行。當然需要在 package.json 裡做一下設定。

  "scripts": {
    "dev": "vite",
    "build": "vite build --mode project",
    "lib": "vite build --mode lib",
    "serve": "vite preview",
    "api": "node api/server.js"
  },

實現一個幫助文件的專案

上面介紹的是庫專案的基本原理,我們要做幫助文件的時候,並不需要那麼複雜。

使用 vite2 建立一個vue3的專案,然後安裝 @naturefw/press-edit,使用提供的元件即可方便的實現。

main.js

首先需要在 main.js 裡面做一些設定。

import { createApp } from 'vue'
import App from './App.vue'

// 設定 axios 的 baseUrl
const baseUrl = (document.location.host.includes('.gitee.io')) ?
  '/doc-ui-core/' :  '/'

// 輕量級狀態
// 設定 indexedDB 資料庫,存放文件的各種資訊。
import { setupIndexedDB, setupStore } from '@naturefw/press-edit'
// 初始化 indexedDB 資料庫
setupIndexedDB(baseUrl)
  
// UI庫
import ElementPlus from 'element-plus'
// import 'element-plus/lib/theme-chalk/index.css'
// import 'dayjs/locale/zh-cn'
import zhCn from 'element-plus/es/locale/lang/zh-cn'

// 二次封裝
import { nfElementPlus } from '@naturefw/ui-elp'
// 設定icon
import installIcon from './icon/index.js'

// 設定 Markdown 的配置函式
import setMarkDown from './main-md.js'

// 主題
import vuepressTheme from '@kangc/v-md-editor/lib/theme/vuepress.js'

const {
  VueMarkdownEditor, // Markdown 的編輯器
  VMdPreview // Markdown 的瀏覽器
} = setMarkDown(vuepressTheme)

const app = createApp(App)
app.config.globalProperties.$ELEMENT = {
  locale: zhCn,
  size: 'small'
}

app.use(setupStore) // 狀態管理
  .use(nfElementPlus) // 二次封裝的元件
  .use(installIcon) // 註冊全域性圖示
  .use(ElementPlus, { locale: zhCn, size: 'small' }) // UI庫
  .use(VueMarkdownEditor) // markDown編輯器
  .use(VMdPreview) // markDown 顯示
  .mount('#app')

  • baseUrl: 根據釋出平臺的情況進行設定,比如這裡需要設定為:“/doc-ui-core/”

  • setupIndexedDB: 初始化 indexedDB 資料庫

  • setupStore: 設定狀態

  • element-plus:element-plus 可以不掛載,但是css需要 import 進來,這裡採用CDN的方式引入。

  • nfElementPlus: 二次封裝的元件,便於實現增刪改查。

  • setMarkDown: 載入 v-md-editor ,以及需要的外掛。

  • vuepressTheme: 設定主題。

設定 Markdown

因為 v-md-editor 相關設定比較多,所以設定了一個單獨檔案進行管理:

/src/main-md.js


// Markdown 編輯器
import VueMarkdownEditor from '@kangc/v-md-editor'
import '@kangc/v-md-editor/lib/style/base-editor.css'
// 在這裡引入,不被識別?
// import vuepressTheme from '@kangc/v-md-editor/lib/theme/vuepress.js'
import '@kangc/v-md-editor/lib/theme/style/vuepress.css'

// 程式碼高亮
import Prism from 'prismjs'


// emoji
import createEmojiPlugin from '@kangc/v-md-editor/lib/plugins/emoji/index'
import '@kangc/v-md-editor/lib/plugins/emoji/emoji.css'

// 流程圖
// import createMermaidPlugin from '@kangc/v-md-editor/lib/plugins/mermaid/cdn'
// import '@kangc/v-md-editor/lib/plugins/mermaid/mermaid.css'

// todoList
import createTodoListPlugin from '@kangc/v-md-editor/lib/plugins/todo-list/index'
import '@kangc/v-md-editor/lib/plugins/todo-list/todo-list.css'

// 程式碼行號
import createLineNumbertPlugin from '@kangc/v-md-editor/lib/plugins/line-number/index';

// 高亮程式碼行
import createHighlightLinesPlugin from '@kangc/v-md-editor/lib/plugins/highlight-lines/index'
import '@kangc/v-md-editor/lib/plugins/highlight-lines/highlight-lines.css'

// 複製程式碼
import createCopyCodePlugin from '@kangc/v-md-editor/lib/plugins/copy-code/index'
import '@kangc/v-md-editor/lib/plugins/copy-code/copy-code.css'


// markdown 顯示器
import VMdPreview from '@kangc/v-md-editor/lib/preview'
// import '@kangc/v-md-editor/lib/style/preview.css'


/**
 * 設定 Markdown 編輯器 和瀏覽器
 * @param {*} vuepressTheme 
 * @returns 
 */
export default function setMarkDown (vuepressTheme) {

  // 設定 vuePress 主題
  VueMarkdownEditor.use(vuepressTheme,
    {
      Prism,
      extend(md) {
        // md為 markdown-it 例項,可以在此處進行修改配置,並使用 plugin 進行語法擴充套件
        // md.set(option).use(plugin);
      },
    }
  )
  
  // 預覽
  VMdPreview.use(vuepressTheme,
    {
      Prism,
      extend(md) {
        // md為 markdown-it 例項,可以在此處進行修改配置,並使用 plugin 進行語法擴充套件
        // md.set(option).use(plugin);
      },
    }
  )
  
  // emoji
  VueMarkdownEditor.use(createEmojiPlugin())
  // 流程圖
  // VueMarkdownEditor.use(createMermaidPlugin())
  // todoList
  VueMarkdownEditor.use(createTodoListPlugin())
  // 程式碼行號
  VueMarkdownEditor.use(createLineNumbertPlugin())
  // 高亮程式碼行
  VueMarkdownEditor.use(createHighlightLinesPlugin())
  // 複製程式碼
  VueMarkdownEditor.use(createCopyCodePlugin())
  

  // 預覽的外掛
  VMdPreview.use(createEmojiPlugin())
  VMdPreview.use(createTodoListPlugin())
  VMdPreview.use(createLineNumbertPlugin())
  VMdPreview.use(createHighlightLinesPlugin())
  VMdPreview.use(createCopyCodePlugin())
  
  return {
    VueMarkdownEditor,
    VMdPreview
  }

}

不多介紹了,可以根據需要選擇外掛。

佈局

在App.vue檔案裡面進行整體佈局

  <el-container>
    <el-header>
      <!--導航-->
      <div style="float: left;">
        <!--寫網站logo、標題等-->
        <h1>nf-press</h1>
      </div>
      <div style="float: right;min-width: 100px;height: 60px;padding-top: 13px;">
        <!--寫網站logo、標題等-->
        <el-switch v-model="$state.current.isView" v-bind="itemProps"></el-switch>
      </div>
      <div style="float: right;min-width: 600px;height: 60px;">
        <!--網站導航-->
        <doc-navi ></doc-navi>
      </div>
    </el-header>
    <el-container>
      <!--左側邊欄-->
      <el-aside width="330px">
        <!--選單-->
        <doc-menu ></doc-menu>
      </el-aside>
      <el-main>
        <!--文件區域-->
        <component
          :is="docControl[$state.current.isView]"
        />
      </el-main>
    </el-container>
  </el-container>
  import { reactive, defineAsyncComponent } from 'vue'
  import { ElHeader, ElContainer ,ElAside, ElMain } from 'element-plus'
  import { docMenu, docNavi, config } from '@naturefw/press-edit' // 選單 導航
  import docView from './views/doc.vue' // 顯示文件

  // 載入選單子控制元件
  const docControl = {
    true: docView,
    false: defineAsyncComponent(() => import('./views/main.vue')) // 修改文件
  }

  const itemProps = reactive({
    'inline-prompt': true,
    'active-text': '看',
    'inactive-text': '寫',
    'active-color': '#378FEB',
    'inactive-color': '#EA9712'
  })
  • $state:全域性狀態,$state.current.isView 設定是否是瀏覽狀態。
  • doc-navi:導航元件
  • doc-menu:選單元件
  • docControl:根據狀態選擇載入顯示元件或者編輯元件的字典。

這種方式雖然有點麻煩,但是比較靈活,可以根據需要進行各種靈活設定,比如新增版權資訊、備案資訊、廣告等內容。

導航、選單、編輯和瀏覽

直接使用元件實現,比較簡單不搬運了,直接看原始碼即可。

打包釋出與版本管理

需要打包的情況分為兩種:第一次打包、修改程式碼(非線上編輯的程式碼)後打包。

如果只是文件內容有變化的話,只需要直接上傳json檔案即可,不需要再次打包。

內建了一個簡單的版本管理功能,可以通過 ver.json檔案裡的版本號實現更新功能。

原始碼

https://gitee.com/nfpress/nf-press-edit

線上演示

https://nfpress.gitee.io/nf-press-edit/

demo

https://gitee.com/nfpress/doc-ui-elp

相關文章