基于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ù)的配置,類(lèi)似在Vitepress中我們可以在MD前用YAML語(yǔ)法編寫(xiě)一些配置??梢允褂胓ray-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',
}
// 模板名(組件名稱(chēng))
export const name = 'SimpleTemplate'
// 模板樣式 類(lèi)名
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)文章
element-ui tooltip修改背景顏色和箭頭顏色的實(shí)現(xiàn)
這篇文章主要介紹了element-ui tooltip修改背景顏色和箭頭顏色的實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-12-12
詳解基于vue-cli配置移動(dòng)端自適應(yīng)
本篇文章主要介紹了詳解基于vue-cli配置移動(dòng)端自適應(yīng),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-01-01
利用Vue.js+Node.js+MongoDB實(shí)現(xiàn)一個(gè)博客系統(tǒng)(附源碼)
本文主要介紹了利用Vue.js+Node.js+MongoDB實(shí)現(xiàn)一個(gè)博客系統(tǒng),這個(gè)博客使用Vue做前端框架,Node+express做后端,數(shù)據(jù)庫(kù)使用的是MongoDB。實(shí)現(xiàn)了用戶注冊(cè)、用戶登錄、博客管理、文章編輯、標(biāo)簽分類(lèi)等功能,需要的朋友可以參考學(xué)習(xí)。2017-04-04
Vue常見(jiàn)錯(cuò)誤Error?in?mounted?hook解決辦法
這篇文章主要給大家介紹了關(guān)于Vue常見(jiàn)錯(cuò)誤Error?in?mounted?hook的解決辦法,出現(xiàn)這樣的問(wèn)題,會(huì)發(fā)現(xiàn)跟聲明周期鉤子有關(guān)系,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考下2023-07-07
解決在vue+webpack開(kāi)發(fā)中出現(xiàn)兩個(gè)或多個(gè)菜單公用一個(gè)組件問(wèn)題
這篇文章主要介紹了在vue+webpack實(shí)際開(kāi)發(fā)中出現(xiàn)兩個(gè)或多個(gè)菜單公用一個(gè)組件的解決方案,需要的朋友可以參考下2017-11-11
Vue+ElementUI技巧之自定義表單項(xiàng)label的文字提示方法
這篇文章主要給大家介紹了關(guān)于Vue+ElementUI技巧之自定義表單項(xiàng)label文字提示的相關(guān)資料,文中通過(guò)圖文以及代碼示例介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2024-02-02

