因公司业务需要,去往远方开发项目,大半年来除了加班还是加班,开发了三个后台系统,使用的vue-element-admin框架,该文章如标题,为个人记录。
vue-element-admin
该框架作者有写了两篇webpack教学文章,其中详细分析了为何该框架为何这么配置,两篇文章建议仔细阅读。得益于webpack 4+vue/cli3+作者做的配置,基本已经达到开箱即用了,不过多介绍
手摸手,带你用合理的姿势使用webpack4(上)
手摸手,带你用合理的姿势使用webpack4(下)
vue-cli不支持 eject 来弹出默认配置
当初刚开始看的时候想要看一下目前vue-cli究竟做了哪些默认配置(官网写的一般不会完全覆盖,所以想自己查看默认配置),可惜并没有类似 react 的 eject来弹出
关于为何没有可查看以下链接,比较赞同官方,为了一些特定场景而去加大他们的工作量,并且并不能带来实质收益,对到框架开发来讲不合理
Eject from Vue CLI / Export Webpack Config
查看vue/cli默认配置
如果不清楚vue/cli做的默认配置,那么遇到问题的时候其实只靠网上搜索很难找到自己遇到的问题,因此先了解vue/cli的默认配置,方便出现问题的时候查看是否有某些配置没更改,进入项目,在项目根目录下
- 运行命令,在终端输出:
开发环境:npx vue-cli-service inspect --mode development
生产环境:npx vue-cli-service inspect --mode production - 运行命令,将输出导入到 js 文件:
开发环境:npx vue-cli-service inspect --mode development >> webpack.config.development.js
生产环境:npx vue-cli-service inspect --mode production >> webpack.config.production.js
参考地址:
vue-cli-service-inspect
修改插件选项
动态路由无法分包
该问题直到项目后期才发现,因此已经没法追溯究竟什么时候开始构建的时候分包失败
由于项目中的菜单需要根据权限进行管理,角色权限没有的菜单栏不会显示到侧边栏上,所以由后台返回对应的页面级组件地址(字符串,例如'./view/order'
),前端使用require()来获取正式的component(其结果类似于route中component: './view/order'
)
参考地址:
Webpack-Vue 分片优化——为什么使用懒加载() => import() 里面的组件没有分片打包
后改为映射表,通过路径来映射前端component,再使用import来导入对应的component(并非真正原因,因为vue/cli3默认就是可以对动态路由进行分包的,改完之后依旧无效。自行配置spliteChunks后虽然成功分包了,但是动态路由也失效了,没法按需加载,直接一进首页全部请求下来了),该记录仅为让自己以后如果出现问题还能往这方面考虑
真实预发布环境增加了一个pre环境(也就是多了一个.env.preprod文件),虽然里面已经指定了NODE_ENV=production,然而发现一个奇怪的bug,就是不管怎样都无法分包(代码压缩等都正常),并且该bug表现为:
- 如果先执行build:prod,则正常分包,此时再执行build:pre,结果也会分包
- 如果先执行build:pre,则无法分包,此时再执行build:prod,结果也无法分包
- 如果更改一下chunkName,再执行build:prod,又会正常分包
- 删除node_modules中的缓存,执行build:prod,也会恢复正常分包
这里涉及知识盲区,不继续深究,以后技术深度有所突破能知道原因再来更新此文,后来与后台协定,预发布与正式均使用build:prod,不再区分环境(因为对到前端来讲其实预发布与正式环境代码都是同一份)
打包后的app.css巨大
构建后发现app.css包居然达到15M,饿了么样式被重复打包
优化:去除入口文件引入的饿了么UI默认样式(官网有说自定义样式不需要引入默认样式,仅作提示),将vue-element-admin中主题配置文件拆分(因为项目中可能会有很多需要引用主题色变量的,直接从该配置文件获取主题相关配置样式,会导致饿了么样式被重复打包)
参考链接:
自定义主题样式文件多次打包 ,解决方法为Tofandel这名用户所说的,不要将变量与自定义导入主题放在一块
icon-font打包后乱码,浏览器能正常显示
该问题表现为打包结果乱码(为一个空芯方块),但代码在浏览器中可正常显示iconf-font。打开控制台再刷新,则第一次icon-font必定展示乱码(也就是为什么Unicode明文在F12的时候会有一次无法识别)
百度出来的肯定是将dart-sass换成node-sass,但根据自行查看issue,dart-sass确实会导致编译为Unicode 明文,但貌似并非底层真正原因。由于时间也是换成node-sass来解决,先做记录,后续再看issue
用最新的框架,打包出来element的字体图标乱码了? #3526
页面刷新有时候elementui 的字体图标会乱码 #19247
现代浏览器type="password"自动填充密码
查看了很多文章,全体方案阵亡,包括:
- 设置autocomplete
- 通过动态设置readonly
- 动态更改type为password
- 增加两个autocomplete="off"的隐藏密码输入框
不管哪一个方案,在切换成password的时候,自动填充又会出现,通过stackoverflow了解到之前这些方案都行不通了,原本打算自行实行模态密码框来模拟密码输入框(本质还是text)
但通过stackoverflow了解到可以将type设置为text,通过css设置text-security: disc;
来模拟密码输入框,但这个时候下面也有人说了复制粘贴会复制到全都是小圆点,但密码本来就不该让用户复制粘贴,所以再将密码框的粘贴事件禁用即可onpaste="return false"
如何阻止浏览器自动填充账号密码
下面这篇文章我没尝试,稍微看了一下也是动态设置type和readonly这些方案,但是多管齐下,不确定是否有用,有兴趣的可以自行尝试
完美解决 element-ui input=password 在浏览器会自动填充密码的问题
封装后的v-password组件
<template>
<el-input
type="text"
:class="['model-password', {'is-show-password': isShowPassword}]"
v-bind="$attrs"
onpaste="return false"
v-on="$listeners"
>
<slot slot="prefix" name="prefix" />
<slot slot="suffix" name="suffix">
<span class="el-input__icon el-icon-circle-close el-input__clear" @click="clearValue" />
<span class="el-input__icon el-icon-view el-input__clear" @click="toggleType" />
</slot>
<slot slot="prepend" name="prepend" />
<slot slot="append" name="append" />
</el-input>
</template>
<script>
/**
* 现代浏览器type="password"自动填充密码,查看了很多文章,全体方案阵亡,方案包括:
* 设置autocomplete
* 通过动态设置readonly
* 动态更改type为password
* 增加两个autocomplete="off"的隐藏密码输入框
* -------------------------------------------------------------------
* 自用的模态密码输入框,组件特点:
* 1. 不出现自动填充账号密码弹出层
* 2. 密码不允许粘贴
* 3. 使用方式完全同 el-input
* 4. type固定为text,不可变更,因为password会导致自动填充
*/
export default {
name: 'VPassword',
data () {
return {
isShowPassword: true
}
},
computed: {
},
methods: {
toggleType () {
this.isShowPassword = !this.isShowPassword
},
clearValue () {
this.$emit('input', '')
}
}
}
</script>
<style lang="scss" scoped>
.model-password {
.el-icon-circle-close {
display: none;
}
&:hover {
.el-icon-circle-close {
display: inline-block;
}
}
}
.is-show-password {
::v-deep .el-input__inner {
text-security: disc;
-webkit-text-security:disc;
}
}
</style>
前端文件下载
前端文件下载最简单的方式是<a></a>
使用download属性
<a download href="文件下载地址">文件名</a>
但是这种下载方法不能跨域,非同域download会无效。而使用新开页面下载的方式会有一个弹窗闪一下影响体验。由于项目中有需要下载第三方文件的需求,还有私有桶提供的blob文件流,所以更改为使用文件id请求模拟点击下载,代码如下
// 工具函数
const Tool = {
_getBlob (ret, fileName, file) {
const type = ret.type
const blob = new Blob([ret], { type }) // type必须指定,即使时流文件,否则火狐下载无后缀
const ie = navigator.userAgent.match(/MSIE\s([\d.]+)/)
const ie11 = navigator.userAgent.match(/Trident\/7.0/) && navigator.userAgent.match(/rv:11/)
const ieEDGE = navigator.userAgent.match(/Edge/g)
const ieVer = (ie ? ie[1] : (ie11 ? 11 : (ieEDGE ? 12 : -1)))
if (ie && ieVer < 10) {
Message.error('您的浏览器版本过低,请切换到IE EDGE模式或更换浏览器')
}
if (ieVer > -1) {
window.navigator.msSaveBlob(blob, fileName)
} else {
const url = window.URL.createObjectURL(blob)
file.href = url
}
},
/**
* @description 文件下载,非流文件(如视频音频等)
* @param {String, Array} id 文件id,如果是批量下载必须传入数组id集合
* @param {String} fileName 保存时候的文件名
*/
async downLoad (id, fileName = '') {
const file = document.createElement('a')
const body = document.querySelector('body')
let ret = {}
if (!Array.isArray(id) || !id.length) {
Message('下载参数异常')
return
}
// 业务接口
ret = await fileModel.zipFile(fileName, id)
if (!ret.size || ret.size < 1024) {
Message.error('文件异常,该文件无法下载')
} else {
fileName = fileName || ret.fileName // 实现前端自定义文件名或使用后台返回的文件名,需要在request.js补充
this._getBlob(ret, fileName, file)
}
// IE和火狐必须制定下载的格式,否则下载后丢失文件后缀,目前来看会有类型,因为之前没有content-type?
file.download = fileName
file.style.display = 'none'
body.appendChild(file)
file.click()
body.removeChild(file)
window.URL.revokeObjectURL(file)
}
}
request.js
service.interceptors.response.use(
response => {
const contentType = response.headers['content-type'].toLowerCase()
// 返回请求体是流文件
if (contentType.includes('octet-stream') || contentType.includes('vnd.ms-excel') || contentType.includes('zip')) {
if (response.status === 200) {
// 将后台返回的晴天球头文件名填充到响应体中
response.data.fileName = window.decodeURI(response.headers['content-disposition'].split('=')[1])
return Promise.resolve(response.data)
} else {
Message({
message: '文件下载失败,请稍后尝试',
type: 'error',
duration: 2 * 1000
})
}
}
}
)
vue-echarts
项目中使用了百度可视化插件ECharts,本来想直接使用vue-echarts,但用vue-charts一直偶现实例化时必要数据无数据(options中的数据),导致一直报错,github有同样的issuse但作者并无理会,所以直接放弃,自行封装了一个简易版的v-charts,options为echarts实例所需的对象
<template>
<div ref="echarts" class="echarts" />
</template>
<script>
/**
* 简易版 vue-echarts,用vue-charts一直偶现实例化时必要数据无数据(options中的数据),导致一直报错,github有同样的issuse但作者并无理会,所以直接放弃
* 该组件不支持响应式数据,响应式数据实现后发现性能损耗很大(触发极为频繁),不想跟vue-charts一样提供props来让用户决定,因为大部分使用者将会忽略文档提示
* 所以直接不支持响应式数据,重新渲染的时机需用户自行调用render方法
* echart整体包过大,使用按需引入,否则整体echarts就九百多K,优化后可减少五百多K,自行根据需要自行选用按需导入或全量导入
*/
import echarts from 'echarts/lib/echarts'
import 'echarts/lib/chart/bar'
import 'echarts/lib/chart/pie'
import 'echarts/lib/chart/line'
import 'echarts/lib/component/tooltip'
import 'echarts/lib/component/title'
import 'echarts/lib/component/legend'
import 'echarts/lib/component/grid'
import resize from './resize'
export default {
mixins: [resize],
props: {
options: {
type: Object,
default: () => ({})
}
},
data () {
return {
echarts: null
}
},
mounted () {
this.initEcharts()
},
// 暂时留着,不是很确定vue销毁是否能让ECherts也销毁
destroyed () {
this.echart.dispose()
this.echart = null
},
methods: {
initEcharts () {
const echartsDom = this.$refs.echarts
this.echart = echarts.init(echartsDom)
this.echart.setOption(this.options)
},
// 重新绘制图表
render () {
this.$nextTick(() => {
this.echart.setOption(this.options)
})
}
}
}
</script>
<style lang="scss" scoped>
.echarts {
width: 100%;
height: 100%;
}
</style>
resize.js
import { debounce } from '@/utils'
export default {
data () {
return {
$_sidebarElm: null,
$_resizeHandler: null
}
},
mounted () {
this.initListener()
},
// 假如页面走缓存,则离开页面销毁
activated () {
if (!this.$_resizeHandler) {
this.initListener()
}
this.resize()
},
deactivated () {
this.destroyListener()
},
beforeDestroy () {
this.destroyListener()
},
methods: {
$_sidebarResizeHandler (e) {
if (e.propertyName === 'width') {
this.$_resizeHandler()
}
},
initListener () {
this.$_resizeHandler = debounce(() => {
this.resize()
}, 100)
window.addEventListener('resize', this.$_resizeHandler)
// 侧边导航条因为不会触发浏览器resize,所以需要进行事件监听
this.$_sidebarElm = document.getElementsByClassName('sidebar-container')[0]
this.$_sidebarElm && this.$_sidebarElm.addEventListener('transitionend', this.$_sidebarResizeHandler)
},
destroyListener () {
window.removeEventListener('resize', this.$_resizeHandler)
this.$_resizeHandler = null
this.$_sidebarElm && this.$_sidebarElm.removeEventListener('transitionend', this.$_sidebarResizeHandler)
},
resize () {
const { echart } = this
echart && echart.resize()
}
}
}
富文本编辑器Tinymce
网上评测文章很多,不再赘述。主要强调的是版本,网上有一篇比较通用的文章,但版本是4x的,下载的时候已经是5x,并且tinymce-vue也已经到2x,因此不能跟着4x的方式来做,本人使用的版本如下:
"tinymce": "5.0.3", "@tinymce/tinymce-vue": "2.0.0"
vue-element-admin原作者里面也有封装了一个tinymce插件,但使用的CDN的方式通过script插入。由于为真实项目,使用这种方式不太合适,因此自行封装了一版tinymceEditor组件,图片自动上传(可截图)
完整步骤:
- 下载合适的版本(大部分报错都是因为tinymce与tinymce-vue不匹配,如果有问题可固定为上述版本)
- public文件夹中新增tinymce文件夹,将node_modules中
tinymce
里的skins
复制进去,可删除多余的文件,仅剩所需的css文件即可
-| public
-| tinymce
-| skins
-| ui
-| oxide
-| content.min.css
-| skin.min.css
- 下载中文包(看个人需要),下载链接tinymce语言包
- 导入tinymce、tinymce-vue、zh_CN.js中文包与所需的插件,语言包我是放在资源目录中的,具体根据项目规范自行放置导入即可
- 如果项目中tinymce是在弹层当中,下拉菜单会因为层级过低导致不可见,需要自行调整z-index,没有什么好办法,本人解决方案是通过!important来调整层级的
.tox-tinymce-aux {
z-index: 2021 !important;
}
完整代码:
<template>
<div class="custom-tinymce">
<tinymceEditor ref="tinymce_editor" v-model="tinyContent" :init="initParams" />
</div>
</template>
<script>
import fileModel from '@/api/file'
import 'tinymce/tinymce'
import tinymceEditor from '@tinymce/tinymce-vue'
// 语言包需要自行下载,https://liubing.me/goto/https://www.tiny.cloud/get-tiny/language-packages/
import CN from '@/assets/tinymceLangs/zh_CN.js'
import 'tinymce/themes/silver'
import 'tinymce/plugins/lists'// 列表
import 'tinymce/plugins/image'// 插入上传图片
import 'tinymce/plugins/media'// 插入视频
import 'tinymce/plugins/table'// 表格
import 'tinymce/plugins/wordcount'// 字数统计
import 'tinymce/plugins/quickbars' // 快捷工具栏
import 'tinymce/plugins/fullscreen' // 全屏编辑
import 'tinymce/plugins/preview' // 预览
import 'tinymce/plugins/autolink' // 自动识别链接
import 'tinymce/plugins/charmap' // 特殊字符
import 'tinymce/plugins/searchreplace' // 搜索替换
import 'tinymce/plugins/link'
import 'tinymce/icons/default'
// import 'tinymce/skins/ui/oxide/skin.css' // 虽然能正常运行,但插件底层还会去请求静态目录下的css文件,因此不能采用该做法
import './custom.scss' // 调整层级的css
export default {
components: {
tinymceEditor
},
props: {
plugins: {
type: [String, Array],
default: 'wordcount quickbars fullscreen preview lists autolink link charmap searchreplace table'
},
toolbar: {
type: [String, Array],
default: 'undo redo | alignleft aligncenter alignright| bullist numlist | charmap bold italic underline strikethrough forecolor backcolor | quickimage link | fullscreen preview table | fontsizeselect | formatselect | fontselect | lineheight'
},
value: {
type: String,
default: ''
}
},
data () {
return {
tinyContent: this.value
}
},
computed: {
// 不直接用tinymce,防止后续多个实例的场景出现bug
editor () {
return this.$refs.tinymce_editor.editor
},
initParams () {
return {
language_url: CN,
language: 'zh_CN',
height: 500,
plugins: this.plugins, // 菜单栏
toolbar: this.toolbar, // 工具栏
branding: false,
object_resizing: false,
quickbars_insert_toolbar: '', // 这个不配为空,每次换行都会出现快捷工具栏,影响操作
elementpath: false,
// 解决粘贴图片后,不自动上传,而是使用base64编码。
urlconverter_callback: (url, node) => {
if (node === 'img' && url.startsWith('blob:')) {
this.editor.uploadImages()
console.log(url, node)
}
return url
},
// 按照文档 issue中的说法,但是怎么设置都没法阻止默认图片变成bolb或base64
// images_upload_url: fileModel.uploadToPublicStatic,
// automatic_uploads: false,
// 插件会将bolb的文件替换未上传成功的文件,但是失败的时候会遗留bolb图片文件,导致文章请求体过大,所以需要删除
images_upload_handler: async (blobInfo, succFun) => {
const file = blobInfo.blob()
const formData = new FormData()
formData.append('file', file)
const { flag, data } = await fileModel.uploadToPublicStaticMethod(formData)
if (flag) {
succFun(data)
} else {
// 这里如果用Undo返回上一步不太合理,考虑到网络延迟,上传图片后继续打字什么的,可能无法将图片回退,所以还是走遍历删除
const imgElms = Array.from(this.editor.dom.doc.getElementsByTagName('img'))
imgElms.forEach(item => {
if (item.getAttribute('src').startsWith('blob:')) {
this.editor.execCommand('delete', false, item)
}
})
}
},
// 走import或require,线上依然会去请求静态目录中的css文件,导致有404请求(虽然已经打包成bundle,功能样式都正常,找不到该插件底层代码时哪里写了)
// 静态文件均放在public中,并且content_css不指定,线上还会再去请求一个content.css,只能写死
skin_url: '/tinymce/skins/ui/oxide',
content_css: '/tinymce/skins/ui/oxide/content.min.css',
menu: {
file: { title: 'File', items: ' preview ' },
view: { title: 'View', items: 'preview fullscreen' },
insert: { title: 'Insert', items: 'image link media template codesample inserttable | charmap emoticons hr | pagebreak nonbreaking anchor toc | insertdatetime' },
format: { title: 'Format', items: 'bold italic underline delectline strikethrough superscript subscript codeformat | formats blockformats fontformats fontsizes align lineheight | forecolor backcolor | removeformat' },
tools: { title: 'Tools', items: 'spellchecker spellcheckerlanguage | code wordcount' },
table: { title: 'Table', items: 'inserttable | cell row column | tableprops deletetable' }
},
default_link_target: '_blank',
font_formats: "微软雅黑='微软雅黑';宋体='宋体';黑体='黑体';仿宋='仿宋';楷体='楷体';隶书='隶书';幼圆='幼圆';Andale Mono=andale mono,times;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;Comic Sans MS=comic sans ms,sans-serif;Courier New=courier new,courier;Georgia=georgia,palatino;Helvetica=helvetica;Impact=impact,chicago;Symbol=symbol;Tahoma=tahoma,arial,helvetica,sans-serif;Terminal=terminal,monaco;Times New Roman=times new roman,times;Trebuchet MS=trebuchet ms,geneva;Verdana=verdana,geneva;Webdings=webdings;Wingdings=wingdings",
fontsize_formats: '12px 14px 16px 18px 24px 36px 48px',
visual: false,
toolbar_mode: 'sliding'
}
}
},
watch: {
tinyContent () {
this.$emit('input', this.tinyContent)
}
},
mounted () {
// tinymce.init({})
}
}
</script>
使用方法
<tinymce v-model="formData.content" />
跨组件,将按钮插入到头部navbar中
由于后期设计导致的交互变更如上图所示,一开始原型所有按钮都在下方红框当中,根据逻辑展示对应按钮(因此开发的时候都写在业务组件之中)。
后来设计出视觉的时候将所有区块按钮转移到头部navbar当中(出现该问题的原因很多,从流程到相关人员专业度等等,这里就不吐槽了)。此时有一个致命的问题:超级多页面都有该功能,且都有不同的逻辑在其中,如果从框架上进行更改,工作量极大。
这里跨越的组件层级已经无法通过常规的传输方式来解决(不仅是跨组件,而且是跨了N多个组件文件)。要解决这个问题的前提是必须保证现有逻辑的运行,原本的v-if或v-show也能正常判断
因此开发了一个指令与一个组件,用于该场景,解决思路是将通过指令,将原有的节点插入到navbar中去。由于navbar在系统中仅有一个,所以写了一个带ID的空白节点作为落脚点,具有该指令的节点初始化则直接不可见。
/**
* @author yose
* @description 该指令仅适用于页面没走缓存(非keep-alive),否则需要使用组件sysbtn
*/
const install = function (Vue) {
Vue.directive('sysbtn', {
bind (el) {
el.style.display = 'none'
appendToSysbtn(el, Vue)
},
update (el) {
appendToSysbtn(el, Vue)
},
unbind (el) {
const sysbtnDom = document.getElementById('sys_btn')
if (!sysbtnDom) { return } // 路由路径为退出/404等非layout界面,无法获取到该dom节点
const childrenNodes = Array.from(sysbtnDom.childNodes)
childrenNodes.forEach(item => el.appendChild(item))
}
})
}
function appendToSysbtn (elm, vue) {
vue.prototype.$nextTick().then(() => {
const childrenNodes = Array.from(elm.children)
const sysBtnNode = document.getElementById('sys_btn') // navbar中被插入的节点,上图最后插入的位置
childrenNodes.forEach(item => sysBtnNode.appendChild(item))
})
}
export default install
如果页面走缓存(也就是keep-alive),该指令在离开页面后也无法释放按钮(因为不会触发unbind),所以需要一个功能一致的组件来监听页面的进入activated
与离开deactivated
<template>
<div v-if="!isLeavePage" v-sysbtn>
<slot />
</div>
</template>
<script>
/**
* @author yose
* @description 由于页面走缓存,因此不会执行unbind,指令中无法得知是否离开当前页面,如果是没有走缓存的页面级组件,则直接使用v-sysbtn指令即可
*/
export default {
name: 'SysBtn',
data () {
return {
isLeavePage: false
}
},
activated () {
this.isLeavePage = false
},
deactivated () {
this.isLeavePage = true
}
}
</script>
使用方式示例(主要是想说明原来的代码除了增加v-sysbtn指令,其余都无需更改就能完成新的视觉需求):
<div v-sysbtn>
<el-button
v-if="isShowBalance"
id="btn_add_deduct"
v-permission="'DrawingOrder-2-orderBaseInformation-addDeductOrAddMoney'"
class="btn-w120 main-plain-btn"
plain
@click="handleAddDeduct"
>订单加/扣款</el-button>
<el-button
v-if="isWaiting"
id="btn_submit_drawing"
v-permission="'DrawingOrder-2-quotationProcurementPlanDetailList-submitCompleted'"
:loading="submitLoading"
class="btn-w84"
type="primary"
@click="handleSubmit"
>提交完成</el-button>
</div>
组件上
<template slot="buttons">
<el-button type="primary" class="btn-w120 check-btn" @click="getUsers">查询</el-button>
<sys-btn>
<el-button type="primary" class="btn-w120" @click="handleUser({})">新增</el-button>
</sys-btn>
</template>
主要为提供一个思路,不要仅限于组件间传值,遇到部分场景不剑走偏锋,会导致需求难度极度复杂,并且耗费大量的无意义时间
网友评论