基于Vue3實(shí)現(xiàn)一個(gè)簡(jiǎn)歷生成工具
項(xiàng)目介紹
之前在做個(gè)人簡(jiǎn)歷的時(shí)候,發(fā)現(xiàn)目前一些工具網(wǎng)站上使用起來(lái)不太方便,于是打算動(dòng)手簡(jiǎn)單實(shí)現(xiàn)一個(gè)在線的簡(jiǎn)歷工具網(wǎng)站,主要支持以下功能:
- 支持以markdown格式輸入,渲染成簡(jiǎn)歷內(nèi)容
- 多模板切換
- 樣式調(diào)整
- 上傳導(dǎo)出功能
體驗(yàn)地址: hj-hao.github.io/md2cv/
技術(shù)選型
項(xiàng)目整體技術(shù)棧如下:
- 框架: Vue 3
- 構(gòu)建:Vite
- 項(xiàng)目UI: PrimeVue + TailwindCSS
- 狀態(tài)管理: Pinia
- Markdown處理:Markdown-it + gray-matter
功能實(shí)現(xiàn)
接下來(lái)簡(jiǎn)單介紹下具體的功能實(shí)現(xiàn)
Markdown解析&渲染
首先要處理的就是對(duì)輸入Markdown的解析。由于需要將內(nèi)容渲染在內(nèi)置的模板簡(jiǎn)歷中,這里就只需要MD -> HTML的能力,因此選用了Markdown-it進(jìn)行實(shí)現(xiàn)。拿到html字符串后在vue中直接渲染即可。
<template> <div v-html="result"></div> </template> <script setup> import { ref, computed } from 'vue' import markdownit from 'markdown-it' const md = markdownit({ html: true, }) const input = ref('') const result = computed(() => md.render(input)) </script>
上面這段簡(jiǎn)易的代碼就能支持將用戶輸入文本,轉(zhuǎn)換成html了。
在這個(gè)基礎(chǔ)上如果希望增加一些前置元數(shù)據(jù)的配置,類似在Vitepress中我們可以在MD前用YAML語(yǔ)法編寫一些配置。可以使用gray-matter這個(gè)庫(kù),能通過(guò)分割符將識(shí)別解析文本字符串中的YAML格式信息。
此處用官方的例子直接展示用法, 可以看到其將輸入中的YAML部分轉(zhuǎn)換為對(duì)象返回,而其余部分則保持輸入直接輸出。
console.log(matter('---\ntitle: Front Matter\n---\nThis is content.')); // 輸出 { content: '\nThis is content.', data: { title: 'Front Matter' } }
在這個(gè)項(xiàng)目中,就通過(guò)這個(gè)庫(kù)將簡(jiǎn)歷個(gè)人信息(YAML)和簡(jiǎn)歷正本部分(MD)整合在同一個(gè)輸入框中編輯了,具體的實(shí)現(xiàn)如下:
<template> <div v-html="result.content"></div> </template> <script setup> import { ref, computed } from 'vue' import matter from 'gray-matter' import markdownit from 'markdown-it' const md = markdownit({ html: true, }) const input = ref('') const result = computed(() => { // 解析yaml const { data, content } = matter(input.value) return { data, content: md.render(content), } }) </script>
模板功能
模板實(shí)現(xiàn)
之后是將上面解析后的內(nèi)容渲染到簡(jiǎn)歷模板上,以及可以在不同模板間直接切換實(shí)時(shí)渲染出對(duì)應(yīng)的效果。
實(shí)現(xiàn)上每個(gè)模板都是一個(gè)單獨(dú)的組件,UI由兩部分組件一個(gè)是簡(jiǎn)歷模板個(gè)人信息以及正文部分,除組件部分外還有模板相關(guān)的配置項(xiàng)跟隨組件需要導(dǎo)出,因此這里選用JSX/TSX實(shí)現(xiàn)簡(jiǎn)歷模板組件。構(gòu)造一個(gè)基礎(chǔ)的組件封裝公共部分邏輯, 模板間的UI差異通過(guò)slot實(shí)現(xiàn)
import '@/style/templates/baseTemplate.css' import { defineComponent } from 'vue' import { storeToRefs } from 'pinia' import { useStyleConfigStore } from '@/store/styleConfig' // base component to reuse in other cv templates export default defineComponent({ name: 'BaseTemplate', props: { content: { type: String, default: '', }, page: { type: Number, default: 1, }, className: { type: String, default: '', }, }, setup(props, { slots }) { // 可支持配置的樣式,在基礎(chǔ)模板中通過(guò)注入css變量讓子元素訪問(wèn) const { pagePadding, fontSize } = storeToRefs(useStyleConfigStore()) return () => ( <div class="page flex flex-col" style={{ '--page-padding': pagePadding.value + 'px', '--page-font-size': fontSize.value + 'px', }} > {/** 渲染不同模板對(duì)應(yīng)的信息模塊 */} {props.page === 1 && (slots.header ? slots.header() : '')} {/** 簡(jiǎn)歷正文部分 */} <div class={`${props.className} template-content`} innerHTML={props.content} ></div> </div> ) }, })
其余模板組件在上面組件的基礎(chǔ)上繼續(xù)擴(kuò)展,下面是其中一個(gè)組件示例
import { defineComponent, computed, type PropType } from 'vue' import BaseTemplate from '../BaseTemplate' import ResumeAvatar from '@/components/ResumeAvatar.vue' import { A4_PAGE_SIZE } from '@/constants' import '@/style/templates/simpleTemplate.css' const defaultConfig = { name: 'Your Name', blog: 'https://yourblog.com', phone: '123-456-7890', location: 'Your Location', } // 模板名(組件名稱) export const name = 'SimpleTemplate' // 模板樣式 類名 const className = 'simple-template-content-box' // 模板每頁(yè)的最大高度,用于分頁(yè)計(jì)算 export const getCurrentPageHeight = (page: number) => { if (page === 1) { return A4_PAGE_SIZE - 130 } return A4_PAGE_SIZE } export default defineComponent({ name: 'SimpleTemplate', components: { BaseTemplate, ResumeAvatar, }, props: { config: { type: Object as PropType<{ [key: string]: any }>, default: () => ({ ...defaultConfig }), }, content: { type: String, default: '', }, page: { type: Number, default: 1, }, }, setup(props) { const config = computed(() => { return { ...defaultConfig, ...props.config } }) const slots = { header: () => ( <div class="flex relative gap-2.5 mb-2.5 items-center"> <div class="flex flex-col flex-1 gap-2"> <div class="text-3xl font-bold"> {config.value.name} </div> <div class="flex items-center text-sm"> <div class="text-gray-500 not-last:after:content-['|'] after:m-1.5"> <span>Blog:</span> <a href="javascript:void(0)" rel="external nofollow" target="_blank" rel="noopener noreferrer" > {config.value.blog} </a> </div> <div class="text-gray-500 not-last:after:content-['|'] after:m-1.5"> <span>Phone:</span> {config.value.phone} </div> <div class="text-gray-500 not-last:after:content-['|'] after:m-1.5"> <span>Location:</span> {config.value.location} </div> </div> </div> <ResumeAvatar /> </div> ), } return () => ( <BaseTemplate v-slots={slots} page={props.page} content={props.content} className={className} /> ) }, })
/** @/style/templates/simpleTemplate.css */ .simple-template-content-box { h1 { font-size: calc(var(--page-font-size) * 1.4); font-weight: bold; border-bottom: 2px solid var(--color-zinc-800); margin-bottom: 0.5em; } h2 { font-weight: bold; margin-bottom: 0.5em; } }
模板加載
完成不同模板組件后,項(xiàng)目需要能自動(dòng)將這些組件加載到項(xiàng)目中,并將對(duì)應(yīng)的組件信息注入全局。通過(guò)Vite提供的import.meta.glob
可以在文件系統(tǒng)匹配導(dǎo)入對(duì)應(yīng)的文件,實(shí)現(xiàn)一個(gè)Vue插件,就能在Vue掛載前加載對(duì)應(yīng)目錄下的組件,并通過(guò)provide注入。完整代碼如下
// plugins/templateLoader.ts import type { App, Component } from 'vue' export type TemplateMeta = { name: string component: Component getCurrentPageHeight: (page: number) => number } export const TemplateProvideKey = 'Templates' const templateLoaderPlugin = { install(app: App) { const componentModules = import.meta.glob( '../components/templates/**/index.tsx', { eager: true } ) const templates: Record<string, TemplateMeta> = {} const getTemplateName = (path: string) => { const match = path.match(/templates\/([^/]+)\//) return match ? match?.[1] : null } // path => component Name for (const path in componentModules) { // eg: ../components/templates/simple/index.vue => simple const name = getTemplateName(path) if (name) { const config = (componentModules as any)[path] templates[name] = { component: config.default, name: config.name || name, getCurrentPageHeight: config.getCurrentPageHeight, } as TemplateMeta } } app.provide(TemplateProvideKey, templates) }, } export default templateLoaderPlugin
預(yù)覽分頁(yè)
有了對(duì)應(yīng)的組件和內(nèi)容后,就能在頁(yè)面中將簡(jiǎn)歷渲染出來(lái)了。但目前還存在一個(gè)問(wèn)題,如果內(nèi)容超長(zhǎng)了需要分頁(yè)不能直接體現(xiàn)用戶,僅能在導(dǎo)出預(yù)覽時(shí)候進(jìn)行分頁(yè)。需要補(bǔ)充上分頁(yè)的能力,將渲染的效果和導(dǎo)出預(yù)覽的效果對(duì)齊。
整體思路是先將組件渲染在不可見(jiàn)的區(qū)域,之后讀取對(duì)應(yīng)的dom節(jié)點(diǎn),計(jì)算每個(gè)子元素的高度和,超過(guò)后當(dāng)前內(nèi)容最大高度后,新建一頁(yè)。最后返回每頁(yè)對(duì)應(yīng)的html字符串,循環(huán)模板組件進(jìn)行渲染。具體代碼如下:
import { computed, onMounted, ref, watch, nextTick, type Ref } from 'vue' import { useTemplateStore } from '@/store/template' import { useStyleConfigStore } from '@/store/styleConfig' import { useMarkdownStore } from '@/store/markdown' import { storeToRefs } from 'pinia' export const useSlicePage = (target: Ref<HTMLElement | null>) => { const { currentConfig, currentTemplate } = storeToRefs(useTemplateStore()) const { pagePadding, fontSize } = storeToRefs(useStyleConfigStore()) const { result } = storeToRefs(useMarkdownStore()) const pages = ref<Element[]>() // 每頁(yè)渲染的html字符串 const renderList = computed(() => { return pages.value?.map((el) => el.innerHTML) }) const pageSize = computed(() => pages.value?.length || 1) // 獲取當(dāng)前模板的內(nèi)容高度,減去邊距 const getCurrentPageHeight = (page: number) => { return ( currentConfig.value.getCurrentPageHeight(page) - pagePadding.value * 2 ) } const createPage = (children: HTMLElement[] = []) => { const page = document.createElement('div') children.forEach((item) => { page.appendChild(item) }) return page } // getBoundingClientRect 只返回元素的寬度 需要getComputedStyle獲取邊距 // 由于元素上下邊距合并的特性,此處僅考慮下邊距,上邊距通過(guò)樣式限制為0 const getElementHeightWithBottomMargin = (el: HTMLElement): number => { const style = getComputedStyle(el) const marginBottom = parseFloat(style.marginBottom || '0') const height = el.getBoundingClientRect().height return height + marginBottom } const sliceElement = (element: Element): Element[] => { const children = Array.from(element.children) let currentPage = 1 let currentPageElement = createPage() // 當(dāng)前頁(yè)面可渲染的高度 let PageSize = getCurrentPageHeight(currentPage) // 剩余可渲染高度 let resetPageHeight = PageSize // 頁(yè)面dom數(shù)組 const pages = [currentPageElement] while (children.length > 0) { const el = children.shift() as HTMLElement const height = getElementHeightWithBottomMargin(el) // 大于整頁(yè)高度,如果包含子節(jié)點(diǎn)就直接分隔 // 無(wú)子節(jié)點(diǎn)直接放入當(dāng)頁(yè),然后創(chuàng)建新頁(yè)面 if (height > PageSize) { const subChildren = Array.from(el.children) if (subChildren.length > 0) { children.unshift(...subChildren) } else { pages.push( createPage([el.cloneNode(true)] as HTMLElement[]) ) // Create a new page for the oversized element currentPage += 1 PageSize = getCurrentPageHeight(currentPage) resetPageHeight = PageSize currentPageElement = createPage() pages.push(currentPageElement) // Push the new page to the pages array } continue // Skip to the next element } // 針對(duì)高度大于300的元素且包含子元素的節(jié)點(diǎn)進(jìn)行分隔 // 無(wú)子元素或高度小于300直接創(chuàng)建新頁(yè)面放入 if (height > resetPageHeight && height > 300) { const subChildren = Array.from(el.children) if (subChildren.length > 0) { children.unshift(...subChildren) } else { currentPageElement = createPage([ el.cloneNode(true), ] as HTMLElement[]) // Create a new page currentPage += 1 PageSize = getCurrentPageHeight(currentPage) resetPageHeight = PageSize - height pages.push(currentPageElement) // Push the new page to the pages array } } else if (height > resetPageHeight && height <= 300) { currentPageElement = createPage([ el.cloneNode(true), ] as HTMLElement[]) // Create a new page currentPage += 1 PageSize = getCurrentPageHeight(currentPage) resetPageHeight = PageSize - height pages.push(currentPageElement) // Push the new page to the pages array } else { currentPageElement.appendChild( el.cloneNode(true) as HTMLElement ) resetPageHeight -= height } } return pages } const getSlicePage = () => { const targetElement = target.value?.querySelector(`.template-content`) const newPages = sliceElement(targetElement!) pages.value = newPages } watch( () => [ result.value, currentTemplate.value, pagePadding.value, fontSize.value, ], () => { nextTick(() => { getSlicePage() }) } ) onMounted(() => { nextTick(() => { getSlicePage() }) }) return { getSlicePage, pages, pageSize, renderList, } }
<!-- 實(shí)際展示容器 --> <div class="bg-white dark:bg-surface-800 rounded-lg shadow-md overflow-auto" ref="previewRef" > <component v-for="(content, index) in renderList" :key="index" :is="currentComponent" :config="result.data" :content="content" :page="index + 1" /> </div> <!-- 隱藏的容器 --> <div ref="renderRef" class="render-area"> <component :is="currentComponent" :config="result.data" :content="result.content" /> </div> <script setup> // 省略其他代碼 const renderRef = ref<HTMLElement | null>(null) const previewRef = ref<HTMLElement | null>(null) const mdStore = useMarkdownStore() const templateStore = useTemplateStore() const { result, input } = storeToRefs(mdStore) const { currentComponent } = storeToRefs(templateStore) const { renderList } = useSlicePage(renderRef) </script>
上面的代碼目前還存在一些邊界場(chǎng)景分頁(yè)問(wèn)題比如:
- 一個(gè)僅包含文本的P或者DIV節(jié)點(diǎn),目前這個(gè)節(jié)點(diǎn)不會(huì)被分割,而是整體處理,導(dǎo)致可能會(huì)出現(xiàn)一個(gè)高度剛好超過(guò)剩余高度的節(jié)點(diǎn)被放置在下一頁(yè)造成大塊的空白
- 分割的閾值設(shè)置的比較大,而且沒(méi)有針對(duì)一些特殊元素(ol, table...)做判斷處理
最后
以上就是基于Vue3實(shí)現(xiàn)一個(gè)簡(jiǎn)歷生成工具的詳細(xì)內(nèi)容,更多關(guān)于Vue3簡(jiǎn)歷生成工具的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue之?dāng)?shù)據(jù)交互實(shí)例代碼
本篇文章主要介紹了vue之?dāng)?shù)據(jù)交互實(shí)例代碼,vue中也存在像ajax和jsonp的數(shù)據(jù)交互,實(shí)現(xiàn)向服務(wù)器獲取數(shù)據(jù),有興趣的可以了解一下2017-06-06Vue驗(yàn)證碼60秒倒計(jì)時(shí)功能簡(jiǎn)單實(shí)例代碼
這篇文章主要介紹了Vue驗(yàn)證碼60秒倒計(jì)時(shí)功能簡(jiǎn)單實(shí)例代碼,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2018-06-06vue-cli+webpack記事本項(xiàng)目創(chuàng)建
這篇文章主要為大家詳細(xì)介紹了vue-cli+webpack創(chuàng)建記事本項(xiàng)目,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-04-04vue+el-element中根據(jù)文件名動(dòng)態(tài)創(chuàng)建dialog的方法實(shí)踐
本文主要介紹了vue+el-element中根據(jù)文件名動(dòng)態(tài)創(chuàng)建dialog的方法實(shí)踐,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-12-12Vue渲染器如何對(duì)節(jié)點(diǎn)進(jìn)行掛載和更新
這篇文章主要介紹了Vue 的渲染器是如何對(duì)節(jié)點(diǎn)進(jìn)行掛載和更新的,文中通過(guò)代碼示例給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2024-05-05關(guān)于element-ui?單選框默認(rèn)值不選中的解決
這篇文章主要介紹了關(guān)于element-ui?單選框默認(rèn)值不選中的解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-09-09