我打造了一个简历在线生成应用 | 您所在的位置:网站首页 › 简历自动生成网站怎么做 › 我打造了一个简历在线生成应用 |
半个月前,我写了一篇文章如何书写一份好的互联网校招简历,目的是帮助即将开始投递校招的同学更好的完善自己的简历 在文章中也立下了一个Flag 看了一下Github的 commit记录,截止目前大概花了一周的时间,把心中所设想的方案做了出来,也许不完美,但我想应该也能帮助到部分同学 好东西当然展示三遍,O(∩_∩)O~~ 体验链接 体验链接 体验链接对模板样式(颜色,排版)不满意的,懂前端魔法的同学可以clone仓库,施展一下自己的魔法美化 对项目感兴趣的同学也欢迎贡献一下自己喜欢的简历模板(代码),理论上不限制开发技术栈,当然也欢迎提issues或者建议 本文主要讲一下此项目的设计思路,技术方案以及遇到的一些问题与解决思路(用了不少hack技巧),还有后续的规划 项目设计 布局整个应用的基本页面结构 可能有朋友在这里会疑惑为什么要用iframe? 这里先给大家简单介绍一下,后面在讲技术方案的时候会给大家解释 在我的设想中简历部分只有展示逻辑,可以看作是一个独立的纯静态页面 既然是只做展示,那么无论什么前端魔法都可以做这个工作,于是为了方便各种魔法师施法,就把这一块独立了出来,简历模板贡献者也只需要关心自己如何复原一个静态页面就行,其余的交互逻辑都交给父页面统一处理 技术选型Vanilla JS——世界上最轻量的JavaScript框架(没有之一) ---- 原生js 整个应用的主体部分采用原生js实现 简历展示部分理论上可以采用任意前端技术栈实现,与父页面低耦合 通信下面就介绍项目实现的关键部分内容 实现 项目目录结构 ./config webpack配置文件 ├── webpack.base.js -- 公共配置 ├── webpack.config.build.js -- 生产环境特有配置 ├── webpack.config.dev.js -- 开发环境特有配置 ├── webpack.config.js -- 引用的配置文件 │ ./public 公共静态资源 ├── css │ └── print.css 打印时用的样式 │ ./src 核心代码 ├── assets 静态资源css/img ├── constants 常量 │ ├── index.js 存放导航的名称映射信息 │ ├── schema 存放每个简历模板的默认JSON数据,与pages中的模板一一对应 │ └────── demo1.js ├── pages 简历模板目录 │ └── demo1 -- 其中的一个模板 │ ├── utils 工具方法 ├── app.js 项目的入口js ├── index.html 项目的入口页面 约定优于配置根据约定好的目录结构,通过自动化的脚本 所有模板都统一在 src/pages/xxx 目录下 页面模板约定为 index.html,该目录下的所有js文件将被自动添加到webpack的entry中,自动注入到 当前 页面模板中 例如 ./src ├── pages │ └── xxx │ └───── index.html │ └───── index.scss │ └───── index.js此处自动化生成entry/page配置代码可移步至这里查看 自动生成的结果如下 每个HTMLWebpackPlugin的内容格式如下 首页顶部有一个导航栏用于切换简历模板的路由 这部分的链接内容如果手动填写是很无趣的,如何实现自动生成的呢? 首先首页模板的header nav 部分内容为 htmlWebpackPlugin.options 表示 HTMLWebpackPlugin对象的的userOptions属性 咱们上面拿到了了所有Page的title,将所有title使用,连接拼接在一起,然后绑定到userOptions.pageNames上,则页面初次渲染结果就变成了 abc,demo1,vue1,react1,introduce有了初次渲染结果,接下来咱们写一个方法把这些内容转为a标签即可 const navTitle = { 'demo1': '模板1', 'react1': '模板2', 'vue1': '模板3', 'introduce': '使用文档', 'abc': '开发示例' } function createLink(text, href, newTab = false) { const a = document.createElement('a') a.href = href a.text = text a.target = newTab ? '_blank' : 'page' return a } /** * 初始化导航栏 */ function initNav(defaultPage = 'react1') { const $nav = document.querySelector('header nav') // 获取所有模板的链接---处理原始内容 const links = $nav.innerText.split(',').map(pageName => { const link = createLink(navTitle[pageName] || pageName, `./pages/${pageName}`) // iframe中打开 return link }) // 加入自定义的链接 links.push(createLink('Github', 'https://github.com/ATQQ/resume', true)) links.push(createLink('贡献模板', 'https://github.com/ATQQ/resume/blob/main/README.md', true)) links.push(createLink('如何书写一份好的互联网校招简历', 'https://juejin.cn/post/6928390537946857479', true)) links.push(createLink('建议/反馈', 'https://www.wenjuan.com/s/MBryA3gI/', true)) // 渲染到页面中 const t = document.createDocumentFragment() links.forEach(link => { t.appendChild(link) }) $nav.innerHTML = '' $nav.append(t) } initNav()这样导航栏就“自动“生成了 自动导出页面描述目录 ./src ├── constants │ ├── index.js │ ├── schema.js │ ├── schema │ ├────── demo1.js │ ├────── react1.js │ └────── vue1.js每个页面的默认数据从./src/constants/schema.js中读取 import abc from './schema/abc' import demo1 from './schema/demo1' import react1 from './schema/react1' import vue1 from './schema/vue1' export default{ abc,demo1,react1,vue1 }而每个模板的描述内容分布在 schema目录下,如果让每个开发者手动往schema.js添加自己模板,容易造成冲突,所以干脆自动生成 工具方法移步至这里查看 /** * 自动创建src/constants/schema.js 文件 */ function writeSchemaJS() { const files = getDirFilesWithFullPath('src/constants/schema') const { dir } = path.parse(files[0]) const targetFilePath = path.resolve(dir, '../', 'schema.js') const names = files.map(file => path.parse(file).name) const res = `${names.map(n => { return `import ${n} from './schema/${n}'` }).join('\n')} export default{ ${names.join(',')} }` fs.writeFileSync(targetFilePath, res) } 数据存取数据的存取操作在父页面和子页面都会用到,抽离为公共方法 数据存放于localStorage中,以每个简历模板的路由作为key ./src/utils/index.js import defaultSchema from '../constants/schema' export function getSchema(key = '') { if (!key) { // 默认key为路由 如 origin.com/pages/react1 // key就为 pages/react1 key = window.location.pathname.replace(/\/$/, '') } // 先从本地取 let data = localStorage.getItem(key) // 如果没有就设置一个默认的再取 if (!data) { setSchema(getDefaultSchema(key), key) return getSchema() } // 如果默认是空对象的则再取一次默认值 if (data === '{}') { setSchema(getDefaultSchema(key), key) data = localStorage.getItem(key) } return JSON.parse(data) } export function getDefaultSchema(key) { const _key = key.slice(key.lastIndexOf('/') + 1) return defaultSchema[_key] || {} } export function setSchema(data, key = '') { if (!key) { key = window.location.pathname.replace(/\/$/, '') } localStorage.setItem(key, JSON.stringify(data)) } json描述的展示需要在控制区域展示json的描述信息,展示部分采用 jsoneditor 当然jsoneditor也支持各种数据操作(CRUD)都支持,还提供了快捷操作按钮 这里采用cdn的方式引入jsoneditor 初始化 /** * 初始化JSON编辑器 * @param {string} id */ function initEditor(id) { let timer = null // 这里做了一个简单的防抖 const editor = new JSONEditor(document.getElementById(id), { // json内容改动时触发 onChangeJSON(data) { if (timer) { clearTimeout(timer) } // updatePage方法用于通知子页面更新 setTimeout(updatePage, 200, data) } }) return editor } const editor = initEditor('jsonEditor')展示效果 json数据展示/更新时机 因为每次切换路由都会触发iframe的onload事件 所以将获取editor更新json内容的时机放在这里 function getPageKey() { return document.getElementById('page').contentWindow.location.pathname.replace(/\/$/, '') } document.getElementById('page').onload = function (e) { // 更新editor中显示的内容 editor.set(getSchema(getPageKey())) } 编写模板页面下面提供了4种方式实现同一页面 期望的效果 描述文件 在schema目录下创建页面的json描述文件,如abc.js ./src ├── constants │ └── schema │ └────── abc.jsabc.js export default { name: '王五', position: '求职目标: Web前端工程师', infos: [ '1:很多文字', '2:很多文字', '3:很多文字', ] }期望的渲染结构 王五 求职目标: Web前端工程师 1:很多文字 2:很多文字 3:很多文字下面开始子编写代码 与父页面唯一相关的逻辑就是需要在子页面的window上挂载一个refresh方法,用于父页面主动调用更新 原生js import { getSchema } from "../../utils" window.refresh = function () { const schema = getSchema() const { name, position, infos } = schema // ... render逻辑 }vue import { getSchema } from '../../utils'; export default { data() { return { schema: getSchema(), }; }, mounted() { window.refresh = this.refresh; }, methods: { refresh() { this.schema = getSchema(); }, }, };react import React, { useEffect, useState } from 'react' import { getSchema } from '../../utils' export default function App() { const [schema, updateSchema] = useState(getSchema()) const { name, position, infos = [] } = schema useEffect(() => { window.refresh = function () { updateSchema(getSchema()) } }, []) return ( { /* 渲染dom的逻辑 */ } ) }为方便阅读,代码进行了折叠 首先是样式,这里选择sass预处理语言,当然也可以用原生css index.scss @import './../../assets/css/base.scss'; html, body, #resume { height: 100%; overflow: hidden; } // 上面部分是推荐引入的通用样式 // 下面书写我们的样式 $themeColor: red; #app { padding: 1rem; } header { h1 { color: $themeColor; } h2 { font-weight: lighter; } } .infos { list-style: none; li { color: $themeColor; } }其次是页面描述文件 index.html下面就开始使用各种技术栈进行逻辑代码编写 原生js目录结构 ./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.jsindex.js import { getSchema } from "../../utils" import './index.scss' window.refresh = function () { const schema = getSchema() const { name, position, infos } = schema clearPage() renderHeader(name, position) renderInfos(infos) } function clearPage() { document.getElementById('app').innerHTML = '' } function renderHeader(name, position) { const html = ` ${name} ${position} ` document.getElementById('app').innerHTML += html } function renderInfos(infos = []) { if (infos?.length === 0) { return } const html = ` ${infos.map(info => { return `${info}` }).join('')} ` document.getElementById('app').innerHTML += html } window.onload = function () { refresh() } Vue目录结构 ./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.js │ └───── App.vueindex.js import Vue from 'vue' import App from './App.vue' import './index.scss' Vue.config.productionTip = process.env.NODE_ENV === 'development' new Vue({ render: h => h(App) }).$mount('#app')App.vue {{ schema.name }} {{ schema.position }}{{ info }} import { getSchema } from '../../utils'; export default { data() { return { schema: getSchema(), }; }, mounted() { window.refresh = this.refresh; }, methods: { refresh() { this.schema = getSchema(); }, }, }; React目录结构 ./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.js │ └───── App.jsxindex.js import React from 'react' import ReactDOM from 'react-dom'; import App from './App.jsx' import './index.scss' ReactDOM.render( , document.getElementById('app') )App.jsx import React, { useEffect, useState } from 'react' import { getSchema } from '../../utils' export default function App() { const [schema, updateSchema] = useState(getSchema()) const { name, position, infos = [] } = schema useEffect(() => { window.refresh = function () { updateSchema(getSchema()) } }, []) return ( {name} {position} { infos.map((info, i) => { return {info} }) } ) } jQuery目录结构 ./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.jsindex.js import { getSchema } from "../../utils" import './index.scss' window.refresh = function () { const schema = getSchema() const { name, position, infos } = schema clearPage() renderHeader(name, position) renderInfos(infos) } function clearPage() { $('#app').empty() } function renderHeader(name, position) { const html = ` ${name} ${position} ` $('#app').append(html) } function renderInfos(infos = []) { if (infos?.length === 0) { return } const html = ` ${infos.map(info => { return `${info}` }).join('')} ` $('#app').append(html) } window.onload = function () { refresh() }如果觉得导航栏展示abc不友好,当然也可以更改 ./src ├── constants │ ├── index.js 存放路径与中文title的映射./src/constants/index.js 中加入别名 export const navTitle = { 'abc': '开发示例' }前面在实例化editor的时候有一个 updatePage 方法 如果子页面有refresh方法则直接 调用其进行页面的更新,当然在更新之前父页面会把最新的数据存入到localStorage中 这样页面之间实际没有直接交换数据,一个负责写,一个负责读,即使写入失败也不影响子页面读取原有的数据 function refreshIframePage(isReload = false) { const page = document.getElementById('page') if (isReload) { page.contentWindow.location.reload() return } if (page.contentWindow.refresh) { page.contentWindow.refresh() return } page.contentWindow.location.reload() } function updatePage(data) { setSchema(data, getPageKey()) refreshIframePage() } /** * 初始化JSON编辑器 * @param {string} id */ function initEditor(id) { let timer = null // 这里做了一个简单的防抖 const editor = new JSONEditor(document.getElementById(id), { // json内容改动时触发 onChangeJSON(data) { if (timer) { clearTimeout(timer) } // updatePage方法用于通知子页面更新 setTimeout(updatePage, 200, data) } }) return editor } const editor = initEditor('jsonEditor') 导出pdf PC端首先PC端浏览器支持打印导出pdf 如何触发打印呢? 鼠标右键选择打印 快捷键 Ctrl + P window.print()咱们这里代码里使用第三种方案 如何确保打印的内容只有简历部分? 这个就要用到媒体查询 方式一 @media print { /* 此部分书写的样式还在打印时生效 */ }方式二 只需要在打印样式中将无关内容进行隐藏即可 基本能做到1比1的还原 移动端采用jsPDF + html2canvas html2canvas 负责将页面转为图片 jsPDF负责将图片转为PDF function getBase64Image(img) { var canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; var ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, img.width, img.height); var dataURL = canvas.toDataURL("image/png"); return dataURL; } // 导出pdf // 当然这里确保图片资源被转为了base64,否则导出的简历无法展示图片 html2canvas(document.getElementById('page').contentDocument.body).then(canvas => { //返回图片dataURL,参数:图片格式和清晰度(0-1) var pageData = canvas.toDataURL('image/jpeg', 1.0); //方向默认竖直,尺寸ponits,格式a4[595.28,841.89] var doc = new jsPDF('', 'pt', 'a4'); //addImage后两个参数控制添加图片的尺寸,此处将页面高度按照a4纸宽高比列进行压缩 // doc.addImage(pageData, 'JPEG', 0, 0, 595.28, 592.28 / canvas.width * canvas.height); doc.addImage(pageData, 'JPEG', 0, 0, 595.28, 841.89); doc.save(`${Date.now()}.pdf`); });但目前此种导出方式还存在一些问题尚未解决,后续换用其它方案进行处理 不支持超链接 不支持iconfont 字体的留白部分会被剔除 小结到这里整个项目的雏形算完成了 导航栏切换简历模板 在JSON编辑器中改动json -> 页面数据更新 导出pdf 移动端 - jspdf 电脑 - 打印 高能操作 高亮变动的内容诉求:在json编辑器中进行了内容的更新,期望能在简历中高亮展示出变动的内容 转为技术需求就是期望能监听到变动的dom,然后高亮 这个地方就用到 MutationObserver了 它提供了监视对DOM树所做更改的能力 /** * 高亮变化的Dom */ function initObserver() { // 包含子孙节点 // 将监视范围扩展至目标节点整个节点树中的所有节点 // 监视指定目标节点或子节点树中节点所包含的字符数据的变化 const config = { childList: true, subtree: true, characterData: true }; // 实例化监听器对象 const observer = new MutationObserver(debounce(function (mutationsList, observer) { for (const e of mutationsList) { let target = e.target if (e.type === 'characterData') { target = e.target.parentElement } // 高亮 highLightDom(target) } }, 100)) // 监听子页面的body observer.observe(document.getElementById('page').contentDocument.body, config); // 因为 MutationObserver 是微任务,微任务后面紧接着就是页面渲染 // 停止观察变动 // 这里使用宏任务,确保此轮Event loop结束 setTimeout(() => { observer.disconnect() }, 0) } function highLightDom(dom, time = 500, color = '#fff566') { if (!dom?.style) return if (time === 0) { dom.style.backgroundColor = '' return } dom.style.backgroundColor = '#fff566' setTimeout(() => { dom.style.backgroundColor = '' }, time) }何时调用 initObserver 当然是在更新页面之前的时候注册事件,页面完成变动渲染后停止监听 function updatePage(data) { // 异步的微任务,本轮event loop结束停止观察 initObserver() // 同步 setSchema(data, getPageKey()) // 同步 + 渲染页面 refreshIframePage() }效果 期望效果
诉求: 点击需要修改的部分,就能进行修改操作 修改结果在简历上与json编辑器中进行内容同步下面阐述一下实现 1. 获取点击的Dom document.getElementById('page').contentDocument.body.addEventListener('click', function (e) { const $target = e.target })2. 获取dom内容在页面中出现的次数与相对位置 子页面只包含展示逻辑,所以需要父页面做hack操作才能在定位点击内容在json中对应位置 拥有相同内容的dom不止一个,所以需要全部找出来 /** * 遍历目标Dom树,找出文本内容与目标一致的dom组 */ function traverseDomTreeMatchStr(dom, str, res = []) { // 如果有子节点则继续遍历子节点 if (dom?.children?.length > 0) { for (const d of dom.children) { traverseDomTreeMatchStr(d, str, res) } // 相等则记录下来 } else if (dom?.textContent?.trim() === str) { res.push(dom) } return res } // 监听简历页的点击事件 document.getElementById('page').contentDocument.body.addEventListener('click', function (e) { const $target = e.target // 点击的内容 const clickText = $target.textContent.trim() // 只包含点击内容的节点 const matchDoms = traverseDomTreeMatchStr(document.getElementById('page').contentDocument.body, clickText) // 点击的节点在 匹配的 节点中的相对位置 const mathIndex = matchDoms.findIndex(v => v === $target) // 不包含则不做处理 if (mathIndex < 0) { return } })3. 获取jsoneditor中对应的节点 与上面逻辑类似 先过滤出只包含此节点内容的几个节点 然后根据点击dom在同内容节点列表中的相对位置进行匹配 // 监听简历页的点击事件 document.getElementById('page').contentDocument.body.addEventListener('click', function (e) { // ...省略上述列出的代码 // 解除上次点击的dom高亮 highLightDom($textarea.clickDom, 0) // 高亮这次的10s highLightDom($target, 10000) // 更新jsoneditor中的search内容 editor.searchBox.dom.search.value = clickText // 主动触发搜索 editor.searchBox.dom.search.dispatchEvent(new Event('change')) // 将点击内容显示在textarea中 $textarea.value = clickText // 自动聚焦输入框 if (document.getElementById('focus').checked) { $textarea.focus() } // 记录点击的dom,挂载$textarea上 $textarea.clickDom = e.target // jsoneditor 搜索过滤的内容为模糊匹配,比如搜索 a 会匹配 ba,baba,a,aa,aaa // 根据上面得到的matchIndex,进行精确匹配全等的json节点 let i = -1 for (const r of editor.searchBox.results) { // 全等得时候下标才变动 if (r.node.value === clickText) { i++ // 匹配到json中的节点 if (i === mathIndex) { // 高亮一下$textarea $textarea.style.boxShadow = '0 0 1rem yellow' setTimeout(() => { $textarea.style.boxShadow = '' }, 200) return } } // 手动触发jsoneditor的next search match 按钮, 切换jsoneditor中active的节点 editor.searchBox.dom.input.querySelector('.jsoneditor-next').dispatchEvent(new Event('click')) // active的节点可以通过下面方式获取 // editor.searchBox.activeResult.node } })4. 更新节点内容 上面两个步骤将简历中的dom与jsoneditor的dom都获取到了 通过textarea输入的内容 将输入的内容分别更新到这两个dom上,并把最新的json写入的localStorage中 // 监听输入事件,并做一个简单的防抖 $textarea.addEventListener('input', debounce(function () { if (!editor.searchBox?.activeResult?.node) { return } // 激活dom变动事件 initObserver() // 更新点击dom $textarea.clickDom.textContent = this.value // 更新editor的dom editor.searchBox.activeResult.node.value = this.value editor.refresh() // 更新到本地 setSchema(editor.get(), getPageKey()) }, 100))这样就完成了两侧(简历/jsoneditor)数据的更新 后续规划 接入更多的框架支持 优化pdf的导出 超链接 字体图标 优化用户体验 降低jsoneditor的存在感,当前的新增与删除操作依赖jsoneditor,对不懂前端魔法的同学不友好 优化移动端的交互 美化界面 加入自动生成代码模板指令 搬运更多的简历模板感谢你能坚持读到这里,谢谢捧场,如果你也感兴趣欢迎贡献代码(简历/功能) 相关链接 在线体验链接 代码仓库 贡献代码 招聘美团21年春季校招已经开启了,欢迎大家投递 笔者在到店事业群-平台技术部,欢迎大家加入 |
CopyRight 2018-2019 实验室设备网 版权所有 |