实现
项目目录结构
./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 部分内容为
<header> <nav id="nav"> <%= htmlWebpackPlugin.options.pageNames %> </nav> </header>
htmlWebpackPlugin.options
表示 HTMLWebpackPlugin
对象的的userOptions
属性
咱们上面拿到了了所有Page的title,将所有title使用,
连接拼接在一起,然后绑定到userOptions.pageNames
上,则页面初次渲染结果就变成了
<header> <nav id="nav"> abc,demo1,vue1,react1,introduce </nav> </header>
有了初次渲染结果,接下来咱们写一个方法把这些内容转为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
<link rel="stylesheet" href="https://img.cdn.sugarat.top/css/jsoneditor.min.css"> <script src="https://img.cdn.sugarat.top/js/jsoneditor.min.js"></script>
初始化
/** * 初始化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.js
abc.js
export default { name: '王五', position: '求职目标: Web前端工程师', infos: [ '1:很多文字', '2:很多文字', '3:很多文字', ] }
期望的渲染结构
<div id="resume"> <div id="app"> <header> <h1>王五</h1> <h2>求职目标: Web前端工程师</h2> </header> <ul class="infos"> <li>1:很多文字<li> <li>2:很多文字<li> <li>3:很多文字<li> </ul> </div> </div>
下面开始子编写代码
与父页面唯一相关的逻辑就是需要在子页面的window上挂载一个refresh方法,用于父页面主动调用更新
原生js
import { getSchema } from "../../utils" window.refresh = function () { const schema = getSchema() const { name, position, infos } = schema // ... render逻辑 }
vue
<script> import { getSchema } from '../../utils'; export default { data() { return { schema: getSchema(), }; }, mounted() { window.refresh = this.refresh; }, methods: { refresh() { this.schema = getSchema(); }, }, }; </script>
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 ( <div> { /* 渲染dom的逻辑 */ } </div> ) }
为方便阅读,代码进行了折叠
首先是样式,这里选择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
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title> <%= htmlWebpackPlugin.options.title %> </title> </head> <body> <div id="resume"> <div id="app"> </div> </div> </body> </html>
下面就开始使用各种技术栈进行逻辑代码编写
原生js
目录结构
./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.js
index.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 = ` <header> <h1>${name}</h1> <h2>${position}</h2> </header>` document.getElementById('app').innerHTML += html } function renderInfos(infos = []) { if (infos?.length === 0) { return } const html = ` <ul class="infos"> ${infos.map(info => { return `<li>${info}</li>` }).join('')} </ul>` document.getElementById('app').innerHTML += html } window.onload = function () { refresh() }
Vue
目录结构
./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.js │ └───── App.vue
index.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
<template> <div id="app"> <header> <h1>{{ schema.name }}</h1> <h2>{{ schema.position }}</h2> </header> <div class="infos"> <p v-for="(info, i) in schema.infos" :key="i" > {{ info }} </p> </div> </div> </template> <script> import { getSchema } from '../../utils'; export default { data() { return { schema: getSchema(), }; }, mounted() { window.refresh = this.refresh; }, methods: { refresh() { this.schema = getSchema(); }, }, }; </script>
React
目录结构
./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.js │ └───── App.jsx
index.js
import React from 'react' import ReactDOM from 'react-dom'; import App from './App.jsx' import './index.scss' ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, 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 ( <div> <header> <h1>{name}</h1> <h2>{position}</h2> </header> <div className="infos"> { infos.map((info, i) => { return <p key={i}>{info}</p> }) } </div> </div> ) }
jQuery
目录结构
./src ├── pages │ └── abc │ └───── index.html │ └───── index.scss │ └───── index.js
index.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 = ` <header> <h1>${name}</h1> <h2>${position}</h2> </header>` $('#app').append(html) } function renderInfos(infos = []) { if (infos?.length === 0) { return } const html = ` <ul class="infos"> ${infos.map(info => { return `<li>${info}</li>` }).join('')} </ul>` $('#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 { /* 此部分书写的样式还在打印时生效 */ }
方式二
<!-- 引入的css资源只在打印时生效 --> <link rel="stylesheet" href="./css/print.css" 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
- 电脑 - 打印