前言
嗯,,,跟之前封装“全局 Loading”的出发点基本一样,因为产品觉得 ElementUI 提供的默认上传组件,使用“照片墙”或者“缩略图”模式都需要去改动原本的组件样式,并且缩略图尺寸也不能调整,预览模式也会对原始图片进行缩放和处理,不符合系统本身的样式规范。
最离谱的是,预览模式居然有背景色,但是背景色又没有填满整个 model 的背景区域,,,甚至还出现了滚动条!!!
所以,为了更好的配合产品和UI,特地重新编写了一个图片上传组件。
1. 功能设计
嗯,既然是图片上传,那么肯定只支持图片文件了。因为是内部项目,所以也保留了 http 上传部分,大家可以参照 ElementUI 适当修改。
修改后的上传组件支持以下功能:
- 上传(基础中的基础)
- 实现 v-model 语法糖绑定上传数据列表(嗯,,,也很基础)
- 需要支持大图预览
- 不能换行,超出宽度显示滚动条且支持鼠标控制(不用 shift 的那种)
功能设计完成之后,大致的页面样式就是这样的:
后面会补上[码上掘金]的地址
2. 实现
为了能够适应更多的场景,我决定把预览部分也直接提取出来。
2.1 图片预览 PicturePreviewer
这里的图片预览是基于 ElDialog 开发的,支持翻页、循环等。
本身不依赖外部的图片元素,可以和 ElDialog 一样直接使用 visible 属性来控制显示和隐藏。
<template> <el-dialog :title="title" :visible="visible" :close-on-click-modal="false" width="1000px" destroy-on-close append-to-body @close="closeDialog" > <div class="q-picture__img-box"> <div class="q-picture__prev-btn" @mouseover="leftBtnStatus = true" @mouseleave="leftBtnStatus = false" > <transition name="btn-fade"> <el-button v-show="leftBtnStatus && isNeeding" circle icon="el-icon-arrow-left" @click="lastImage()" /> </transition> </div> <img v-show="visible" :src="currentSrc" alt="" v-loading="loading" @load="loading = false" /> <div class="q-picture__next-btn" @mouseover="rightBtnStatus = true" @mouseleave="rightBtnStatus = false" > <transition name="btn-fade"> <el-button v-show="rightBtnStatus && isNeeding" circle icon="el-icon-arrow-right" @click="nextImage()" /> </transition> </div> </div> </el-dialog> </template> <script> export default { name: "PicturePreviewer", props: { visible: { type: Boolean, default: false }, pageable: { type: Boolean, default: true }, recyclable: { type: Boolean, default: true }, src: { type: [String, Array], required: true }, title: { type: String, default: "图片预览" }, current: { type: Number, default: 0 } }, data() { return { currentKey: -1, leftBtnStatus: false, rightBtnStatus: false, loading: false }; }, computed: { isNeeding: function () { return typeof this.src === "object" && this.pageable && this.src && this.src.length > 1; }, currentSrc: function () { if (typeof this.src === "string") return this.src; if (this.src && this.src.length) { return this.src[this.currentKey] || ""; } return ""; } }, methods: { closeDialog() { this.$emit("update:visible", false); }, lastImage() { if (this.currentKey - 1 === -1) { if (this.recyclable) this.currentKey = this.src.length - 1; else this.$message.info("当前已经是第一张图片"); } else { this.currentKey = this.currentKey - 1; } }, nextImage() { if (this.currentKey + 1 === this.src.length) { if (this.recyclable) this.currentKey = 0; else this.$message.info("当前已经是最后一张图片"); } else { this.currentKey = this.currentKey + 1; } } }, watch: { current: { handler: function (val) { if (val) this.currentKey = val; else this.currentKey = 0; }, immediate: true } } }; </script>
样式部分就放到 马上掘金 了。
Markup:
<div id="app"> <div class="img-box"> <img v-for="(img, key) in images" :key="img" :src="img" @click="openPreviewer(key)" /> </div> <el-button size="small" type="primary" @click="openPreviewer(0)">查看大图</el-button> <picture-previewer :visible.sync="dialogImageVisible" :src="images" :current="current" /> </div>
Style:
.img-box { display: inline-flex; width: 100%; img { width: 100px; max-width: 100px; height: 64px; max-height: 64px; &:hover { cursor: pointer; } & + img { margin-left: 16px; } } } .q-picture__img-box { display: inline-flex; width: 100%; height: 552px; // 600px - 48px overflow: hidden; justify-content: center; align-items: center; position: relative; img { position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%); max-height: 100%; max-width: 100%; } .q-picture__prev-btn, .q-picture__next-btn { position: absolute; top: 0; height: 100%; width: 240px; z-index: 10; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #909399; box-sizing: border-box; .control-btn { box-sizing: border-box; border-radius: 50%; width: 36px; height: 36px; border: 1px solid #909399; } } .q-picture__prev-btn { left: 0; padding: 0 120px 0 0; } .q-picture__next-btn { right: 0; padding: 0 0 0 120px; } } .btn-fade-enter, .btn-fade-leave-to { opacity: 0; } .btn-fade-enter-to, .btn-fade-leave { opacity: 1; } .btn-fade-enter-active, .btn-fade-leave-active { transition: opacity 0.5s; }
Script:
const PicturePreviewer = Vue.component("picture-previewer", { template: ` <el-dialog :title="title" :visible="visible" :close-on-click-modal="false" width="1000px" destroy-on-close append-to-body @close="closeDialog" > <div class="q-picture__img-box"> <div class="q-picture__prev-btn" @mouseover="leftBtnStatus = true" @mouseleave="leftBtnStatus = false" > <transition name="btn-fade"> <el-button v-show="leftBtnStatus && isNeeding" circle icon="el-icon-arrow-left" @click="lastImage()" /> </transition> </div> <img v-show="visible" :src="currentSrc" alt="" v-loading="loading" @load="loading = false" /> <div class="q-picture__next-btn" @mouseover="rightBtnStatus = true" @mouseleave="rightBtnStatus = false" > <transition name="btn-fade"> <el-button v-show="rightBtnStatus && isNeeding" circle icon="el-icon-arrow-right" @click="nextImage()" /> </transition> </div> </div> </el-dialog>`, name: "PicturePreviewer", props: { visible: { type: Boolean, default: false }, pageable: { type: Boolean, default: true }, recyclable: { type: Boolean, default: true }, src: { type: [String, Array], required: true }, title: { type: String, default: "图片预览" }, current: { type: Number, default: 0 } }, data() { return { currentKey: -1, leftBtnStatus: false, rightBtnStatus: false, loading: false }; }, computed: { isNeeding: function () { return typeof this.src === "object" && this.pageable && this.src && this.src.length > 1; }, currentSrc: function () { if (typeof this.src === "string") return this.src; if (this.src && this.src.length) { return this.src[this.currentKey] || ""; } return ""; } }, methods: { closeDialog() { this.$emit("update:visible", false); }, lastImage() { if (this.currentKey - 1 === -1) { if (this.recyclable) this.currentKey = this.src.length - 1; else this.$message.info("当前已经是第一张图片"); } else { this.currentKey = this.currentKey - 1; } }, nextImage() { if (this.currentKey + 1 === this.src.length) { if (this.recyclable) this.currentKey = 0; else this.$message.info("当前已经是最后一张图片"); } else { this.currentKey = this.currentKey + 1; } } }, watch: { current: { handler: function (val) { if (val) this.currentKey = val; else this.currentKey = 0; }, immediate: true } } }) const app = new Vue({ el: "#app", name: "App", components: { "picture-previewer": PicturePreviewer }, data() { return { dialogImageVisible: false, current: 0, images: [ "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1fe3be07700d4377ba581bd9aa8b59b7~tplv-k3u1fbpfcp-zoom-1.image", "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/453ff3d20fac45abae53d03973e12e4a~tplv-k3u1fbpfcp-zoom-1.image", "https://www.logosc.cn/uploads/resources/2018/11/28/1543389900_thumb.jpg", "https://www.logosc.cn/uploads/resources/2018/11/24/1543048917.jpg", "https://www.logosc.cn/uploads/resources/2018/11/06/1541472520.jpg", "https://www.logosc.cn/uploads/resources/2018/11/28/1543377592.jpg" ], } }, methods: { openPreviewer(key) { this.dialogImageVisible = true; this.current = key; } } })
运行:
2.2 图片上传 ImageUpload
图片预览处理完成够,就可以处理图片上传了。
<template> <div class="q-upload__preview" ref="pictures" :title="messageInfo" @mouseenter="horizontalRolling" > <slot name="preSlot"></slot> <input class="q-upload__file-input" type="file" ref="fileInput" name="fileInput" @change="fileChange" :accept="accept" /> <div class="q-upload__file-label" v-loading="fileLoading" @click="selectFile" v-show="fileLists.length < limitNum && !disabled" > <i class="el-icon-plus"></i> </div> <slot name="middle"></slot> <div class="q-upload__pre-img" v-for="(i, k) in fileLists" :key="i.smallUrl"> <img class="q-upload__img" :src="i.smallUrl ? i.smallUrl : i.url" /> <div class="q-upload__mask"> <i v-if="prev" class="el-icon-zoom-in" @click="imgPreview(i, k)"></i> <i class="el-icon-delete" v-if="!disabled" @click="imgRemove(k)"></i> </div> </div> <slot name="endSlot"></slot> <picture-previewer :visible.sync="dialogImageVisible" :src="imageUrls" :current="currentImage" /> </div> </template> <script> import Utils from "../../src/utils/commonUtils"; export default { name: "ImageUpload", props: { active: { type: String, default: "/api/file/upload" }, accept: { type: String, default: "" }, limitNum: { type: Number, default: 9 }, size: { type: Number, default: 10 }, prev: { type: Boolean, default: true }, disabled: { type: Boolean, default: false }, value: { type: Array, default: () => [] } }, data() { return { fileLoading: false, dialogImageVisible: false, dialogImageUrl: "", currentImage: 0, fileLists: [], messageInfo: "" }; }, computed: { imageUrls: function () { return this.fileLists.map(o => o.url); } }, methods: { async validateImage(file) { const isJPEG = file.type === "image/jpeg"; const isJPG = file.type === "image/jpg"; const isPNG = file.type === "image/png"; const isBMP = file.type === "image/bmp"; const isLtSize = file.size / 1024 / 1024 < this.size; if (!(isJPEG || isJPG || isPNG || isBMP)) { return { status: false, message: `上传图片必须是${this.accept}格式!` }; } if (!isLtSize) { return { status: false, message: "上传图片大小不能超过 " + this.size + " MB!" }; } return { status: true, message: "" }; }, // 选择文件 selectFile() { this.$refs.fileInput.value = null; // 置空,防止删除后无法再次选择 this.$refs.fileInput.click(); return true; }, // 文件选取之后· async fileChange(el) { const file = [...el.target.files][0]; let { status, message } = await this.validateImage(file); if (status) { this.fileLoading = true; await this.customHttpRequest(file); } else { this.$message.error(message); return false; } }, // 上传 async customHttpRequest(file) { try { let fData = Utils.createUploadForm(file); let { data: { data, status, message } } = await this.$http.post(this.active, fData.formData, fData.config); if (status) { this.fileLists.unshift(data); this.$emit("success", data); } else { this.$message.error(message); this.$emit("error"); } } finally { this.fileLoading = false; } }, imgPreview(file, k) { this.dialogImageUrl = file.url; this.dialogImageVisible = true; this.currentImage = k; }, imgRemove(index) { this.fileLists.splice(index, 1); this.$emit("input", this.fileLists); this.$emit("change", this.fileLists); this.$emit("blur", this.fileLists); }, horizontalRolling() { if (this.$refs["pictures"].clientWidth < this.$refs["pictures"].scrollWidth) { this.messageInfo = "滚动滚轮查看所有信息"; } else { this.messageInfo = ""; } this.$refs["pictures"].addEventListener("mousewheel", this.$_scrollEvent); this.$once("hook:beforeDestroy", () => { this.$refs["pictures"].removeEventListener("mousewheel", this.$_scrollEvent); }); }, $_scrollEvent(e) { let left = this.$refs["pictures"].scrollLeft; this.$refs["pictures"].scrollLeft = e.deltaY > 0 ? left + 40 : left - 40; } }, watch: { value: { deep: true, immediate: true, handler: function () { this.fileLists = typeof this.value === "string" ? JSON.parse(this.value) : this.value; if (!this.fileLists) this.fileLists = []; } }, fileLists: { deep: true, immediate: false, handler: function () { if (this.value && this.value.length > this.limitNum) { this.$message.warning(`最多可以上传【 ${this.limitNum} 】张图片!!`); } this.$emit("input", this.fileLists); this.$emit("change", this.fileLists); } } } }; </script>
Markup:
<div id="app"> <h2>图片上传</h2> <image-upload v-model="imgList" /> </div>
Style:
.img-box { display: inline-flex; width: 100%; img { width: 100px; max-width: 100px; height: 64px; max-height: 64px; &:hover { cursor: pointer; } & + img { margin-left: 16px; } } } .q-picture__img-box { display: inline-flex; width: 100%; height: 552px; // 600px - 48px overflow: hidden; justify-content: center; align-items: center; position: relative; img { position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%); max-height: 100%; max-width: 100%; } .q-picture__prev-btn, .q-picture__next-btn { position: absolute; top: 0; height: 100%; width: 240px; z-index: 10; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #909399; box-sizing: border-box; .control-btn { box-sizing: border-box; border-radius: 50%; width: 36px; height: 36px; border: 1px solid #909399; } } .q-picture__prev-btn { left: 0; padding: 0 120px 0 0; } .q-picture__next-btn { right: 0; padding: 0 0 0 120px; } } .btn-fade-enter, .btn-fade-leave-to { opacity: 0; } .btn-fade-enter-to, .btn-fade-leave { opacity: 1; } .btn-fade-enter-active, .btn-fade-leave-active { transition: opacity 0.5s; } .q-upload__preview { display: flex; flex-direction: row; padding-bottom: 6px; width: 100%; overflow-x: auto; overflow-y: hidden; .el-loading-spinner { margin-top: -26px !important; } .q-upload__pre-img { width: 70px; min-width: 70px; height: 70px; margin-right: 12px; position: relative; display: flex; align-items: center; justify-content: center; transition: all ease-in-out 0.2s; border: 1px dashed #c0ccda; border-radius: 4px; /*overflow: hidden;*/ img { max-width: 68px !important; max-height: 68px !important; border-radius: 4px; } .q-upload__mask { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border-radius: 4px; z-index: -1; display: flex; justify-content: center; transition: all ease-in-out 0.2s; i { transition: all ease-in-out 0.2s; font-size: 20px; color: #ffffff; padding: 0 6px; line-height: 70px; &:hover { cursor: pointer; } } } &:hover { .q-upload__mask { z-index: 2; background: rgba(0, 0, 0, 0.4); } } } } .q-upload__file-input { display: none !important; width: 0; height: 0; margin: 0; padding: 0; } .q-upload__file-label { width: 70px; min-width: 70px !important; height: 70px; margin-right: 12px; border-radius: 4px; border: 1px dashed lightgray; line-height: 70px; font-size: 20px; color: lightgray; transition: all ease-in-out 0.2s; text-align: center; position: relative; &:hover { border-color: #2d8cf0; color: #2d8cf0; cursor: pointer; } }
Script:
const PicturePreviewer = Vue.component("picture-previewer", { template: ` <el-dialog :title="title" :visible="visible" :close-on-click-modal="false" width="1000px" destroy-on-close append-to-body @close="closeDialog" > <div class="q-picture__img-box"> <div class="q-picture__prev-btn" @mouseover="leftBtnStatus = true" @mouseleave="leftBtnStatus = false" > <transition name="btn-fade"> <el-button v-show="leftBtnStatus && isNeeding" circle icon="el-icon-arrow-left" @click="lastImage()" /> </transition> </div> <img v-show="visible" :src="currentSrc" alt="" v-loading="loading" @load="loading = false" /> <div class="q-picture__next-btn" @mouseover="rightBtnStatus = true" @mouseleave="rightBtnStatus = false" > <transition name="btn-fade"> <el-button v-show="rightBtnStatus && isNeeding" circle icon="el-icon-arrow-right" @click="nextImage()" /> </transition> </div> </div> </el-dialog>`, name: "PicturePreviewer", props: { visible: { type: Boolean, default: false }, pageable: { type: Boolean, default: true }, recyclable: { type: Boolean, default: true }, src: { type: [String, Array], required: true }, title: { type: String, default: "图片预览" }, current: { type: Number, default: 0 } }, data() { return { currentKey: -1, leftBtnStatus: false, rightBtnStatus: false, loading: false }; }, computed: { isNeeding: function () { return typeof this.src === "object" && this.pageable && this.src && this.src.length > 1; }, currentSrc: function () { if (typeof this.src === "string") return this.src; if (this.src && this.src.length) { return this.src[this.currentKey] || ""; } return ""; } }, methods: { closeDialog() { this.$emit("update:visible", false); }, lastImage() { if (this.currentKey - 1 === -1) { if (this.recyclable) this.currentKey = this.src.length - 1; else this.$message.info("当前已经是第一张图片"); } else { this.currentKey = this.currentKey - 1; } }, nextImage() { if (this.currentKey + 1 === this.src.length) { if (this.recyclable) this.currentKey = 0; else this.$message.info("当前已经是最后一张图片"); } else { this.currentKey = this.currentKey + 1; } } }, watch: { current: { handler: function (val) { if (val) this.currentKey = val; else this.currentKey = 0; }, immediate: true } } }) function createUploadForm(file, name = "file", scale = "0.5", width = "", height = "") { let res = { config: { headers: { "Content-Type": "multipart/form-data" } }, formData: {} }; //添加请求头 let formData = new FormData(); formData.append(name, file); formData.append("scale", scale); formData.append("width", width); formData.append("height", height); res.formData = formData; return res; } const ImageUpload = Vue.component("image-upload", { template: ` <div class="q-upload__preview" ref="pictures" @mouseenter="horizontalRolling" :title="messageInfo" > <slot name="preSlot"></slot> <input class="q-upload__file-input" type="file" ref="fileInput" name="fileInput" @change="fileChange" :accept="accept" /> <div class="q-upload__file-label" v-loading="fileLoading" @click="selectFile" v-show="fileLists.length < limitNum && !disabled" > <i class="el-icon-plus"></i> </div> <slot name="middle"></slot> <div class="q-upload__pre-img" v-for="(i, k) in fileLists" :key="i.smallUrl"> <img class="q-upload__img" :src="i.smallUrl ? i.smallUrl : i.url" /> <div class="q-upload__mask"> <i v-if="prev" class="el-icon-zoom-in" @click="imgPreview(i, k)"></i> <i class="el-icon-delete" v-if="!disabled" @click="imgRemove(k)"></i> </div> </div> <slot name="endSlot"></slot> <picture-previewer :visible.sync="dialogImageVisible" :src="imageUrls" :current="currentImage" /> </div>`, name: "ImageUpload", components: { "picture-previewer": PicturePreviewer }, props: { active: { type: String, default: "/api/file/upload/thumbnail" }, accept: { type: String, default: "" }, limitNum: { type: Number, default: 9 }, size: { type: Number, default: 10 }, prev: { type: Boolean, default: true }, disabled: { type: Boolean, default: false }, value: { type: Array, default: () => [] } }, data() { return { fileLoading: false, dialogImageVisible: false, dialogImageUrl: "", currentImage: 0, fileLists: [], messageInfo: "" }; }, computed: { imageUrls: function () { return this.fileLists.map(o => o.url); } }, methods: { async validateImage(file) { const isJPEG = file.type === "image/jpeg"; const isJPG = file.type === "image/jpg"; const isPNG = file.type === "image/png"; const isBMP = file.type === "image/bmp"; const isLtSize = file.size / 1024 / 1024 < this.size; if (!(isJPEG || isJPG || isPNG || isBMP)) { return { status: false, message: `上传图片必须是${this.accept}格式!` }; } if (!isLtSize) { return { status: false, message: "上传图片大小不能超过 " + this.size + " MB!" }; } return { status: true, message: "" }; }, // 选择文件 selectFile() { this.$refs.fileInput.value = null; // 置空,防止删除后无法再次选择 this.$refs.fileInput.click(); return true; }, // 文件选取之后· async fileChange(el) { const file = [...el.target.files][0]; let { status, message } = await this.validateImage(file); if (status) { this.fileLoading = true; await this.customHttpRequest(file); } else { this.$message.error(message); return false; } }, // 上传 async customHttpRequest(file) { try { let fData = createUploadForm(file); let { data: { data, status, message } } = await this.$http.post(this.active, fData.formData, fData.config); if (status) { this.fileLists.unshift(data); this.$emit("success", data); } else { this.$message.error(message); this.$emit("error"); } } finally { this.fileLoading = false; } }, imgPreview(file, k) { this.dialogImageUrl = file.url; this.dialogImageVisible = true; this.currentImage = k; }, imgRemove(index) { this.fileLists.splice(index, 1); this.$emit("input", this.fileLists); this.$emit("change", this.fileLists); this.$emit("blur", this.fileLists); }, horizontalRolling() { if (this.$refs["pictures"].clientWidth < this.$refs["pictures"].scrollWidth) { this.messageInfo = "滚动滚轮查看所有信息"; } else { this.messageInfo = ""; } this.$refs["pictures"].addEventListener("mousewheel", this.$_scrollEvent); this.$once("hook:beforeDestroy", () => { window.removeEventListener("resize", this.$_scrollEvent); }); }, $_scrollEvent(e) { let left = this.$refs["pictures"].scrollLeft; this.$refs["pictures"].scrollLeft = e.deltaY > 0 ? left + 40 : left - 40; } }, watch: { value: { deep: true, immediate: true, handler: function () { this.fileLists = typeof this.value === "string" ? JSON.parse(this.value) : this.value; if (!this.fileLists) this.fileLists = []; } }, fileLists: { deep: true, immediate: false, handler: function () { if (this.value && this.value.length > this.limitNum) { this.$message.warning(`最多可以上传【 ${this.limitNum} 】张图片!!`); } this.$emit("input", this.fileLists); this.$emit("change", this.fileLists); this.$emit("blur", this.fileLists); } } } }) const app = new Vue({ el: "#app", name: "App", components: { "picture-previewer": PicturePreviewer }, data() { return { list: [ "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1fe3be07700d4377ba581bd9aa8b59b7~tplv-k3u1fbpfcp-zoom-1.image", "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/453ff3d20fac45abae53d03973e12e4a~tplv-k3u1fbpfcp-zoom-1.image", "https://www.logosc.cn/uploads/resources/2018/11/28/1543389900_thumb.jpg", "https://www.logosc.cn/uploads/resources/2018/11/24/1543048917.jpg", "https://www.logosc.cn/uploads/resources/2018/11/06/1541472520.jpg", "https://www.logosc.cn/uploads/resources/2018/11/28/1543377592.jpg" ], imgList: [], } }, created() { this.imgList = this.list.map(i => ({ smallUrl: i, url: i })); } })
运行:
因为是内部项目,所以上传方法还是使用的实例上的 axios 方法来发送上传请求的;在独立组件库中依然应该通过 props 的方式传递项目中定义的 http 请求方法。
组件接收一个最大张数限制 limitNum 和文件大小限制 size,以及预览控制 prev 和禁用状态 disabled。
在选择文件之后会立即上传、点击已上传文件则是预览当前文件;当前内部也依赖了 ElementUI 的 Message 组件,用来显示提示信息。
在预览区域前后也增加了一个插槽,用来插入开发者需要的其他信息。
在整个组件的 Dom 节点上,会添加一个鼠标的 mouseenter 事件,当鼠标在组件内部的时候,则计算内部的缩略图区域与外层节点的大小进行比较,如果大于外层父节点的宽度的话,则提示用户通过鼠标滚轮来控制缩略图区域的滚动。
3. 后记
整个组件虽然可以满足当时的系统的一个需求,但是仔细研究代码的话会发现依然有很多细节的地方需要修复。例如:
- 组件的 mouseenter 事件,每次被触发时都会给 dom 添加一个鼠标监听事件,而没有在鼠标移出时及时销毁监听
- 没有增加自定义 http 配置
- 没有控制预览组件的配置项
- 缩略图区域没有尺寸控制
等等一系列的问题,所以我们在抽离组件、公共逻辑的时候,还是需要尽可能的保留以后扩展的可能性,减少与外界逻辑或者业务的关联。