# 项目总结
# 自我介绍
面试官您好,我叫杨志豪,是21届毕业生,我主要擅长的前端开发,技术栈主要是 Vue 相关 上个学期几乎一整个学期都在杭州政采云交易平台前端实习,主要和导师一起负责政采云订单后台模块的需求开发,然后后期负责了一个独立业务模块的维护。然后平时在学校也比较喜欢写代码,写过几个项目,参加过一些比赛,拿到过国二和一个省三奖项。然后我目前是在学校处理毕业设计相关的东西,然后最近在学习一些新的技术,比如我最近在学习vite。然后字节跳动一直都是我梦想的去的地方,我很想很想去这里写代码,然后为团队技术建设做出贡献。
# 项目阐述
- 商场首页的一个动态多栏布局
- 基于vue-router和vuex实现了一套较为完善的登录到动态路由分配方案
- 实现了真正意义上的按钮基本权限控制指令
- 多钟场景下的文件上传功能的一个实现 ,多文件上传进度监控,拖拽上传,大文件分片上传,大文件断点上传等
- 小程序端封装二次重刷机制
- 巧妙使用keep-alive 优化列表页跳转
# 主体
首先讲下写项目一般会做的一些优化手段,也就是一般写项目都会提前做好的事情
# 悦读ECUT
- 背景:
那我介绍一下悦读ECUT这个项目吧,这个项目是我和一位后端同学在19年12月合作完成的一个项目,这个项目集合了书城,阅读器,音乐版块为一体的微信小程序,前端主要的技术栈是mpvue,后端的技术栈使用到了SpringBoot和SpringCloud,然后这个项目也是设计之初也是作为参赛项目的。
难点:
- 解决小程序原生wx.request不支持promise化的问题
首先第一个难点就是微信小程序原生的wx.request并不支持promise,在异常拦截方面比较难处理,然后因为平时写多了Vue的项目,对reuqest库的使用也不太习惯,然后我就去查找了一下如何将原生wx.request改造成支持promise的方法。然后进行分析对比找到一个类库,FLYIO,不仅可以兼容多种跨段框架,并且支持promise。然后我就在项目中引入了这个库,并且结合promise进行封装,进而支持异常拦截。
function promisify(fn) { return function () { let args = [...arguments]; return new Promise((resolve, reject) => { fn(...args, (err, data) => { if (err) reject(err); resolve(data); }) }) } }
function createFly () { // 判断平台 if (mpvuePlatform === 'wx') { const Fly = require('flyio/dist/npm/wx') return new Fly() } else { return null } } function hanldeError (err) { console.log(err) } // 封装get请求 export function get (url, params = {}) { const fly = createFly() if (fly) { return new Promise((resolve, reject) => { fly.get(url, params).then(response => { if (response && response.data) { resolve(response) } else { const msg = (response && response.data && response.data.msg) || '请求失败' mpvue.showToast({ title: msg, duration: 2000 }) reject(response) } }).catch(err => { hanldeError(err) reject(err) }) }) } }
- 开发书城首页的图书卡片版块,有的卡片一行三列,有的卡片多行多列 - 维护一个组件传入,mode,col,row,变换数据+flex布局
然后第二个难点是当时在开发书城首页的版块卡住了,因为设计图里面有三种版块,一种免费阅读一种本周热读还有一种你最近阅读,但是这三个版块的布局截然不同,一种是一行展示四本图书,书名展示在图书盒下方,一种是两行,每行展示两本图书,然后书名和作者展示在图书盒的右侧,还有一种是三行两列。当时我是这样处理的,我单独将这几种模块抽成了一个组件。组件需要传入的参数有行和列,当前组件展示模式(mode=col,多列展示,mode=row 多行展示),然后就是数据。在组建内部首先将传入的图书数据根据传入的行和列,转为对应的多为数组,以方便展示。比如免费阅读版块是一行四列展示,我就传入row=1,col=4,mode=row,组件内部写一段逻辑将数据转为一行四列的数组之后,然后就是利用flex布局判断mode是col还是row进而进行一系列的布局。
- 阅读器换肤功能实现
接着第三部分也就是最重要的部分,阅读器开发,阅读器部分是利用epujs开发的web页面,也就是通过web-view嵌入小程序实现阅读书籍效果,这里开发了目录换肤,进度控制还有切换字体。我主要讲下换肤这一块吧,换肤这一部分也是开发的时间稍微长的一部分。整体来说思路 初始化阅读器注册主题文件(css文件注册到epub的theme实例) ==> 编写唤起面板选择主题事件 ==> 动态添加css文件到头部,每次切换主题都需要删除之前 插入的文件
首先是初始化实例时注册主题,这里我使用到了Vuex来记录当前主题,并且每次切换主题都会保存到缓存当中,初始化时先读取是否缓存中有,然后再进行注册并且保存到Vuex
动态添加css文件到头部是这样实现的,我编写了两个方法,一个是在头部添加css文件方法,一个是删除之前添加的css文件方法,在切换主题事件最前面先调用删除头部css文件方法,然后通过策略模式根据当前点击的主题更新Vuex的主题,然后调用头部添加css文件方法,更新css文件。这两个方法主要是些dom操作,都是去操作link这个标签
// 头部动态添加css文件 function addLink(href){ const link = document.createElement("link") link.setAttribute('rel','stylesheet') link.setAttribute('type','text/css') link.setAttribute('href',href) document.getElementBtTagName['head'][0].appendChild(link) } // 删除之前添加的css文件 function removeAllCss(){ removeLink('https://store.yangxiansheng.top/theme/theme_default.css') ... } function removeLink(href){ // 遍历所有的link节点数组,link[i].getAttribute('href')就删除 const link = document.getElementBtTagName('link') for(let i = link.length;i>=0;i--){ if(link[i] && link[i].getAttribute('href').indexOf(href)!== -1){ link[i].parentNode.removeChild(link[i]); } } }
- 列表页缓存 - keep-alive组件
abababab
收获
然后这个项目的主要成果:首先参加过两个比赛,一个是中国计算机设计大赛,拿了国家二等奖 还有一个是江西省计算机作品大赛,拿了三等奖,另外对于我本人,这个项目因为是合作开发,也是我第一次独立完成前后端对接分离的项目,极大的促进了我的自信心,然后对我的不管是编码能力,编写css,思考问题能力,解决兼容性问题都有了极大的帮助。
# student-admin
- 背景:
这个项目是我大三上学期课程设计时所作,之所以把这个项目写在简历上,是因为我觉得他算得上一个值得写的项目,因为这是一个独立完成的前后端的Vue技术栈的PC端项目,并且使用了现在主流的一些框架,Vue-element-admin,element-ui等,比较综合并且具有代表性,任务是完成一个学生,教师,管理员共同维护的课程成绩,发布查看修改的一个管理系统。
难点
- 前端利用vue-router实现权限校验,路由分配
router.beforeEach((to,from,next)=>{ // 本地判断是否有token const hasToken = getToken() if(hasToken){ // 判断当前页是否是登录页面 if(to.path === '/login'){ // 跳转至首页 next({path:'/'}) }else{ // 不是登录页,判断当前Vuex是否保存了角色信息 const hasRoles = store.getters.roles && store.getters.roles.length > 0 // 如果有用户角色信息,可直接访问 if(hasRoles){ next() }else{ try{ // 调用获取用户信息action,然后将新生村的动态路由添加进入全局路由表 const { roles } = await store.dispatch('user/getInfo') const accessRoutes = await store.dispatch('permisoon',roles) router.addRoutes(accessRoute) // replace方式访问路由 next({...to,replace:true}) }catch (error){ next({path:'/login'}) } } } }else{ if(whilePathList.includes(to.path)){ next() }else{ next(`/login?redirect=${to.path}`) } } })
动态路由是怎么过滤出来的
- 首先拿到了角色信息之后,定义一个获取权限路由action,传入roles
- 如果当前角色为管理员则全部放行,如果不是的话去过滤出具有权限的路由。传入提前在路由表定义的权限路由,然后通过
meta.roles
提前设置的roles判断当前角色是否被包含在内,然后取出之中符合条件的路由表,这一步也使用到了递归 - 最后同步保存在Vuex之中,通过
addRoutes
添加路由表完成封装
const actions = { generatorRoute({commit},roles){ return new Promise(resolve=>{ let accessRoutes // 管理员全部放行 if(roles.includes('admin')){ accessRoutes = asyncRoutes || [] }else{ accessRoutes = filterAsyncRoutes(asyncRoutes,roles) commit('SET_ROUTES',accessRoutes) resolve(accessRoutes) } }) } } filterAsyncRoutes(routes,roles){ const res = [] routes.forEach(route=>{ const tmp = {...route} // 如果该条路由具有权限 if(hasPermission(roles,tmp)){ // 如果children,递归过滤 if(tmp.children){ tmp.children = filterAsyncRoutes(tmp.children,roles) } res.push(tmp) } }) } hasPermission(roles,route){ if(route.meta && route.meta.roles){ return roles.some(role => route.meta.roles.includes(role)) }else{ return true } }
- 实现按钮级别的权限指令 Vue使用自定义指令实现按钮级别的权限控制,需要设置两个值,一个是该按钮需要的权限,一个是当前用户用户角色
directives: { // 指令名 'permission':{ //dom被插入元素时执行的钩子,el获取dom,binding.value拿到指令绑定的值,vnode.context可以拿到实例 inserted:(el,binding,vnode)=>{ // 获取绑定的值 const userRoles = bing.value // 获取按钮需要的权限 const btnRole = el.getAttribute('data-rule') // 判断是否该角色是否有权限,无权限移除元素 if(!userRoles.includes(btnRole)){ el.parentNode.removeChild(el) } } } }
<template> <div class="test"> {{ userInfo.name }}拥有的按钮权限: <el-button data-rule="add" v-permission="userInfo.roles">新增</el-button> <el-button data-rule="delete" v-permission="userInfo.roles">删除</el-button> <el-button data-rule="update" v-permission="userInfo.roles">修改</el-button> </div> </template>
# 解忧杂货铺
背景: bababa
难点:
- 改造原生请求库,统一异常处理和实现二次重刷机制
- 原生微信程序使用JWT令牌方式校验身份
首先需要明确一点,小程序是不需要微信密码登录的,因为这一点验证已经在微信登录做了。我们要做的就是去验证该用户到底有没有授权使用这个小程序,使用JWT的的作用也是为了保护用户的一些隐私功能。
具体的实现流程:
- 全局
app.js
onlaunch
时,首先校验当前token是否过期。这一步其实包含两个步骤- 如果本地缓存没有token,则去获取最新的token
- 有缓存,则调用校验方法,如果过期则获取最新的token
- 每次获取服务器最新token的步骤:1. 通过
wx.login()
获取code码 2. 根据code码获取最新的token
# 可能联想到的一些问题
# Vue-router 的三种路由方式区别和原理
- hash 模式:利用浏览器原生
hashchange
监听事件,通过window.location.hash
拿到哈希值然后通过构造Router
类匹配出对应的组件,再渲染到页面上
缺点: 不是很美观,每当url完全相同时才会将这次记录添加到栈当中,每一次路由的改变并不是一次 HTTP 请求,对 SEO 不太友善
优点: 浏览器的兼容性强,hashChange能够监听浏览器的前进和后退,哈希值的变化不会重新加载页面,哈希值不会包含在请求当中
- history 模式:利用
HTML5
的pushstate
和replacestate
事件,这两个api
可以在浏览器不刷新的条件下操作栈记录,前一个是新增一条记录,后一个是替换当前的记录。并且浏览器的前进和后退是会触发popstate
事件的,每当地址发生变化,通过window.location.pathname
拿到pathname
然后也是到Router
匹配出相应的组件,然后进行渲染
缺点:首先需要前端和服务端配合,因为通过 pushstate
方法实现无刷新跳转会发出一次请求,如果前端请求 URL 和服务端配置不同,会404,还有就是兼容性差
- abstract模式 检测不到浏览器的 API,强制进入此模式
# 聊一聊 Vue 的自定义指令
directives
自定义指令对象里面包含一些钩子
- inserted: 插入父节点时调用
- bind:只会调用一次,第一次插入父节点的时候就会调用
- update :指令所在组件的
VNode
更新时调用 - componentUpdated:指令所在的组件
VNode
全部更新并且其他子VNode
也完成更新时调用 - unbind:指令解绑时调用
然后这些钩子函数接受的参数有
参数名称 | 含义 |
---|---|
el | 指令所绑定的元素,可以用来直接操作 DOM 。 |
binding | 绑定的对象,上面有 name ,value ,oldValue |
VNode | Vue编译生成的虚拟节点 |
oldVnode | 上一个虚拟节点 |
# Keep-alive 为什么能够实现缓存效果
需要去看源码,主要核心部分就是 render函数
# 传统 session 登录和 JWT 登录 有什么区别,各自的优缺点
传统的 session
登录,大致就是客户端完成认证之后,服务端将cookie返回给客户端,然后在服务端自己保存一份登录的用户信息。接着客户端每次都会携带上 cookie
发出请求,服务端解析进行身份验证
该方式存在一系列的缺点:
每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
然后就是现在都在用的JWT方式
认证流程
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。
- 后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名,形成一个JWT(Token)。形成的JWT就是一个形同xxx.yyy.zzz的字符串。
- 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage上,退出登录时前端删除保存的JWT即可。
- 前端在每次请求时将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题)
- 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。
- 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。
JWT的优势
- 简洁(Compact):可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快。
- 自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库。
- 因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
- 不需要在服务端保存会话信息,特别适用于分布式微服务。
# 小程序的原理
这部分比较复杂,建议讲一下三种环境的内核和视图渲染
# 小程序和Vue的区别
- 生命周期
- 数据绑定
- 列表渲染
- 显示和隐藏元素
- 绑定事件
- 双向绑定,取值
- 传值方面
# 提升写Vue项目效率的一些手段
样式方面
- 选择css预处理器,方便模块化开发,方便使用函数,mixin,变量,全局变量等等
- 体验优化: 使用nprogress优化载入进度条、添加loading效果、
- 移动端: 首先使用rem搭配vieport解决移动端适配问题,然后使用fastclick解决300毫秒问题
组件库方面
- 部分组件按需加载
- 干掉无用的图标等的
异步请求
- 封装axios,利用拦截器封装,处理非200状态码异常,搭配UI组件库做出相应交互
- 解决异步问题,配置devServer
- 利用mock.js 解决mock问题
路由
- 建立路由表,分为权限表 + 白名单表 + 404、403
- 做好路由拦截处理角色路由分配问题
构建优化
- 加速webpack构建速度的常规的一些优化手段
- 开启GZIP
- 路由懒加载
引入Vuex,并按照官网方式模块化封装
合理使用过滤器
合理使用自定义指令
引入 ESLINT 约束代码规范等
# 实习收获
# 人际交往
说实话,应届生多少都还带一些书生气,尤其是本科生。如何尽快地融入一个大集体,融入社会,是我最先面临的问题。
大学入学,大家会通过军训的方式相互了解认识,帮助我们尽快融入集体。
到了公司,大家都有自己的工作,每天都是忙碌充实的,如何相互了解认识,去建立良好的人际关系是首当其冲需要解决的问题,也是最重要的问题。
于我而言,我个人的做法就是珍惜每一次表达自我的机会,无论是周会还是团建活动,有表达自我的机会就好好把握在手。
# 技术能力
前期:熟悉环境+总结调试方法
首先是基本上入门react生态,包括政采云前端项目主要的框架dva等,这中间还夹杂这一些客户端框架,比如说electron
,不过也只是初步上手
前期其实大部分时间是在熟悉环境,熟悉项目,熟悉我和导师负责的业务线逻辑
然后慢慢地,从前期的小需求的开发,到后面和导师一起完成需求拆分和分工,完成一次一次需求成功上线,独立完成一个业务组件的编写,并上传私服npm。
前期在写项目的过程中,由于不熟悉项目,经常在调试页面的时候找不到组件代码。在这里要感谢@九渊,全程手把手教导我,教我怎么使用vscode调试项目,并且准确定位代码位置。
后面经过一段时间的学习,我也总结出了解决开发过程中bug的思路
首先也是最重要的,检查自己的代码逻辑 ===> Debug定位问题 ===> 查看文档能否解决 ===> 不能解决的话定位到框架或者引用的类库 ===> 首先考虑引用是否出错,然后阅读文档检查兼容性 ===> 以上问题还是无法解决,就去和框架/组件库相关同学交流
这个流程我一直到现在都遵守,大大的减少了处理问题时浪费的时间
中后期:利用所学开发需求和维护组件
我发现写需求不仅可以帮助快速入手项目,并且可以磨练对业务的理解能力。不管所处的角色是什么,理解业务都是一步至关重要的步骤。
下面主要介绍下支付记录详情组件开发中遇到的一些难题
Situation
:
记得在写一个支付组件的时候碰到点问题,需求记得不是很清,反正我这边的需求是通过判断当前的支付情况和当前用户身份去控制当前订单的待支付步骤条的气泡文案。
task
:
看了一下交互,发现文案通过三个状态控制,一个是当前用户是否是经办人,一个是当前订单的支付状态,一个是当前用户是否是审核
心里一想这不是挺简单的吗,不就是通过几个条件去控制文本吗,然后写着写着第一套代码就写了出来。写着写着,发现我这个函数中存在了大量的条件判断,大量的if-else,总共有三十多种情况吧,反正代码堆得跟屎山一样
讲道理这代码我看不下去了,于是乎我用switch-case改了一下,返现写不下去了,因为动态的状态有三个,咋写呢
然后在掘金上看到了一篇策略模式文章,里面详细的讲解了一下如何优化多层嵌套条件代码,于是乎我就照猫画虎的改造了一下,首先我定义了一个map,每个元素的key是一个对象,对象里面就是三种动态变化的状态的取值,然后value是一个文本的映射。最后执行函数大概是这样的,首先将整个map解构出来,也是一个数组,然后找到满足条件的元素,然后调用call方法这行整个元素的value。但是还是发现整个Map比较臃肿,代码还是很多。
然后想着想着,灵机一动,我把这三个状态通模板字符串拼在一起,不就成了一个状态了吗。然后只需要定义一个对象,对象的key是这么多情况的 三个状态值的字符串拼接而成,然后对应的value直接取对应的文本映射,然后大功告成,只需要维护这个对象即可
result
于是乎好像领略到了优化多条件嵌套语句的终极方法,总结如下,并不是所有的情况都能套用策略模式模板去优化的,可以采用映射的角度去优化问题,代码从一百行优化到20行不到,很是欣慰
这里可以讲一讲自己对文件上传的一些理解,比如文件上传进度+拖拽上传+大文件分片上传+大文件断点上传
后期也是做需求做需求,然后后面有同学离职,一部分业务交给我单独负责,那段时间压力也挺大的,一方面要做自己和mentor这边的需求,另一方面要先理解熟悉新业务的逻辑,然后去熟悉代码,然后快速进入开发阶段,一开始是有点懵的,但是后面花了两个下午我找了后端同学一起对下逻辑,后端同学也是很配合,就差不多上手了
# 讲讲Vite
# Vite的认识
一个基于浏览器原生 ES Modules 的开发服务器。利用浏览器去解析模块,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。个人认为 Vite 目前更像是一个类似于 webpack-dev-server 的开发工具
# Vite的基本原理
首先 Vite
分为开发模式和生产模式
开发模式: **Vite提供了一个开发服务器,然后结合原生的ESM,当代码中出现import的时候,发送一个资源请求,Vite服务器拦截请求,根据不同文件类型,在服务端完成模块的改写(比如单文件的解析编译等)和请求处理,实现真正的按需编译,然后返回给浏览器。**请求的资源在服务器端按需编译返回,完全跳过了打包这个概念,不需要生成一个大的bundle。服务器随起随用,所以开发环境下的初次启动是非常快的。而且热更新的速度不会随着模块增多而变慢,因为代码改动后,并不会有bundle的过程。
Vite拦截请求之后会做一下事情:
- 处理 ESM 语法,比如将业务代码中的 import 第三方依赖路径转为浏览器可识别的依赖路径
- 对 .ts、.vue 等文件进行即时编译
- 对 Sass/Less 的需要预编译的模块进行编译
- 和浏览器端建立 socket 连接,实现 HMR
生产模式: 利用 Rollup
来构建源代码。
Rollup
: 可以理解为插件,就是 Rollup 对外提供一些时机的钩子,还有一些工具方法,让用户去写一些配置代码,以此介入 Rollup 运行的各个时机之中。比如在打包之前注入某些东西,或者改变某些产物结构。
Vite 将需要处理的代码分为了两类
第三方依赖:这类代码大部分都是纯JavaScript,而且不会怎么经常变化,Vite会通过
pre-bundle
的方式来处理这部分代码。 Vite2使用esbulid
来构建这部分代码,esbuild是基于go的,处理速度会比用JavaScript写的打包器要快10-100倍,这也是Vite为什么在开发阶段很快的一个原因。业务代码:通常这部分代码,都不是纯的JavaScript(例如:JSX,Vue等),经常会被修改,而且也不需要一次性全部加载(可以根据路由,做代码分割加载)
# Vite vs Webpack
- 冷启动速度对比
从理论上讲 Vite 是ES module 实现的。随着项目的增大启动时间也不会因此增加。而 Webpack 随着代码体积的增加启动时间是要明显增加的。
- 热更新速度对比
使用了 esbuild
这种理论上快webpack打包几十倍的工具。所以相比于webpack这种每次修改都需要重新打包 bundle 的项目是能明显提升热更新速度的。
ESbuild 快的原因
:
- js是单线程串行,esbuild是新开一个进程,然后多线程并行,充分发挥多核优势
- go是纯机器码,肯定要比JIT快
- 不使用 AST,优化了构建流程
# 讲讲对大前端的看法
- 向服务端进发
从传统的服务端+ 客户端架构,到现在的服务端 + BFF +客户端
BFF: 也就是服务于前端的后端开发模式。也就是服务端设计 API 时会考虑前端的使用,比如在服务端直接进行业务逻辑的处理、渲染 HTML 页面、合并接口请求和数据缓存等等。
- 向泛客户端进发
多端开发
- PC 端:Web 应用和桌面应用
- 移动端:Web 应用、App、小程序等
- 前端到大前端
想要从前端向大前端过渡的话,前端程序员需要从以下三个方面进行提升和扩展:
- BFF(中间层)开发
- 泛客户端开发
- 传统 Web 技术的深入
# 讲讲学习前端的历程和方法
18年开始自学 ==> 19年接触Vue等框架,前期都是杂七杂八的学习,没有系统学习 ==> 19年下半年系统性学习前端 ==> 19年底到20年,主要在写项目,参加比赛,实习
学习方式:
初学: 观看视频 + 总结视频笔记 + 搭配文档
后来: 阅读官网文档 + 掘金博文 + 自己上手实践 + 建立知识体系 + 输出文章 + 记录博客
# npm包发包的流程和命令
注册账号
初始化项目
npm init
编写
package.json
内容,并完成轮子的编写登录账号
npm adduser
发包
npm publish
# 文件上传
# 文件上传原理
原理: 完成消息体封装和消息体的额解析,然后将二进制内容保存到文件。
人话: 把 form
标签的 enctype
设置为 multipart/form-data
,同时 method
必须为 post
方法
# 单文件上传和上传进度
使用form表单上传文件,原来都是用form表单,但是现在一般使用 input
+ xhr
来实现比较好
HTML
<form method="post" action="http://localhost:8100" enctype="multipart/form-data">
选择文件:
<input type="file" name="f1"/> input 必须设置 name 属性,否则数据无法发送<br/>
<br/>
标题:<input type="text" name="title"/><br/><br/><br/>
<button type="submit" id="btn-0">上 传</button>
</form>
# 多文件上传和上传进度
html5
只需要一个标签加个属性就搞定了,file 标签开启 multiple
HTML
<input type="file" name="f1" multiple/>
NODE
//二次处理文件,修改名称
app.use((ctx) => {
var files = ctx.request.files.f1;// 多文件, 得到上传文件的数组
var result=[];
//遍历处理
files && files.forEach(item=>{
var path = item.path;
var fname = item.name;//原文件名称
var nextPath = path + fname;
if (item.size > 0 && path) {
//得到扩展名
var extArr = fname.split('.');
var ext = extArr[extArr.length - 1];
var nextPath = path + '.' + ext;
//重命名文件
fs.renameSync(path, nextPath);
//文件可访问路径放入数组
result.push(uploadHost+ nextPath.slice(nextPath.lastIndexOf('/') + 1));
}
});
//输出 json 结果
ctx.body = `{
"fileUrl":${JSON.stringify(result)}
}`;
})
局部刷新的做法:
页面内放一个隐藏的 iframe
,或者使用 js 动态创建,指定 form
表单的 target
属性值为 iframe
标签 的 name
属性值,这样 form
表单的 submit
行为的跳转就会在 iframe
内完成,整体页面不会刷新。
然后为 iframe
添加 load
事件,得到 iframe
的页面内容,将结果转换为 JSON 对象,这样就拿到了接口的数据
<iframe id="temp-iframe" name="temp-iframe" src="" style="display:none;"></iframe>
<form method="post" target="temp-iframe" action="http://localhost:8100" enctype="multipart/form-data">
选择文件(可多选):
<input type="file" name="f1" id="f1" multiple/><br/> input 必须设置 name 属性,否则数据无法发送<br/>
<br/>
标题:<input type="text" name="title"/><br/><br/><br/>
<button type="submit" id="btn-0">上 传</button>
</form>
<script>
var iframe = document.getElementById('temp-iframe');
iframe.addEventListener('load',function () {
var result = iframe.contentWindow.document.body.innerText;
//接口数据转换为 JSON 对象
var obj = JSON.parse(result);
if(obj && obj.fileUrl.length){
alert('上传成功');
}
console.log(obj);
});
</script>
无刷新上传,借助XHR
<div>
选择文件(可多选):
<input type="file" id="f1" multiple/><br/><br/>
<button type="button" id="btn-submit">上 传</button>
</div>
function submitUpload() {
//获得文件列表,注意这里不是数组,而是对象
var fileList = document.getElementById('f1').files;
if(!fileList.length){
alert('请选择文件');
return;
}
var fd = new FormData(); //构造FormData对象
fd.append('title', document.getElementById('title').value);
//多文件上传需要遍历添加到 fromdata 对象
for(var i =0;i<fileList.length;i++){
fd.append('f1', fileList[i]);//支持多文件上传
}
var xhr = new XMLHttpRequest(); //创建对象
xhr.open('POST', 'http://localhost:8100/', true);
xhr.send(fd);//发送时 Content-Type默认就是: multipart/form-data;
xhr.onreadystatechange = function () {
console.log('state change', xhr.readyState);
if (this.readyState == 4 && this.status == 200) {
var obj = JSON.parse(xhr.responseText); //返回值
if(obj.fileUrl.length){
alert('上传成功');
}
}
}
}
//绑定提交事件
document.getElementById('btn-submit').addEventListener('click',submitUpload);
监控上传进度
- 添加显示进度的标签
div.progress
- 编写处理进度的监听函数,
xhr.onprogress
还有xhr.upload.onprogress
- 通过监控
event.lengthComputable
是否变化,event.loaded
表示发送了多少字节,event.total
表示文件总大小计算出进度,然后来控制样式。
xhr.onprogress=updateProgress;
xhr.upload.onprogress = updateProgress;
function updateProgress(event) {
console.log(event);
if (event.lengthComputable) {
var completedPercent = (event.loaded / event.total * 100).toFixed(2);
progressSpan.style.width= completedPercent+'%';
progressSpan.innerHTML=completedPercent+'%';
if(completedPercent>90){//进度条变色
progressSpan.classList.add('green');
}
console.log('已上传',completedPercent);
}
}
PS: xhr.upload.onprogress
要写在 xhr.send
方法前面
# 拖拽上传
- 首先定义一个允许拖放文件的区域
drop
事件一定要阻止事件的默认行为e.preventDefault()
,不然浏览器会直接打开文件- 为拖拽区域绑定事件,鼠标在拖拽区域上
dragover
,鼠标离开拖拽区域dragleave
,在拖拽区域上释放文件drop
drop
事件内获取文件信息e.dataTransfer.files
- 组装formData,xhr发送ajax
<div class="drop-box" id="drop-box">
拖动文件到这里,开始上传
</div>
<button type="button" id="btn-submit">上 传</button>
<script>
var box = document.getElementById('drop-box');
//禁用浏览器的拖放默认行为
document.addEventListener('drop',function (e) {
console.log('document drog');
e.preventDefault();
});
//设置拖拽事件
function openDropEvent() {
box.addEventListener("dragover",function (e) {
console.log('elemenet dragover');
box.classList.add('over');
e.preventDefault();
});
box.addEventListener("dragleave", function (e) {
console.log('elemenet dragleave');
box.classList.remove('over');
e.preventDefault();
});
box.addEventListener("drop", function (e) {
e.preventDefault(); //取消浏览器默认拖拽效果
var fileList = e.dataTransfer.files; //获取拖拽中的文件对象
var len=fileList.length;//用来获取文件的长度(其实是获得文件数量)
//检测是否是拖拽文件到页面的操作
if (!len) {
box.classList.remove('over');
return;
}
box.classList.add('over');
window.willUploadFileList=fileList;
}, false);
}
openDropEvent();
function submitUpload() {
var fileList = window.willUploadFileList||[];
if(!fileList.length){
alert('请选择文件');
return;
}
var fd = new FormData(); //构造FormData对象
for(var i =0;i<fileList.length;i++){
fd.append('f1', fileList[i]);//支持多文件上传
}
var xhr = new XMLHttpRequest(); //创建对象
xhr.open('POST', 'http://localhost:8100/', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
var obj = JSON.parse(xhr.responseText); //返回值
if(obj.fileUrl.length){
alert('上传成功');
}
}
}
xhr.send(fd);//发送
}
//绑定提交事件
document.getElementById('btn-submit').addEventListener('click',submitUpload);
</script>
# 大文件分片上传
file 继承了 blob ,表示原始数据和二进制数据,提供 slice
方法进行截取
大概是一个这样的过程
- 把大文件进行分段 比如2M一片,发送到服务器携带一个标志,可以暂时用当前的时间戳,用于标识一个完整的文件
- 服务端保存各段文件
- 浏览器端所有分片上传完成,发送给服务端一个合并文件的请求
- 服务端根据文件标识、类型、各分片顺序进行文件合并
- 删除分片文件
分片如何做到,其实就像是操作字符串一样
var start=0,end=0;
while (true) {
end+=chunkSize;
var blob = file.slice(start,end);
start+=chunkSize;
if(!blob.size){//截取的数据为空 则结束
//拆分结束
break;
}
chunks.push(blob);//保存分段数据
}
前端过程,尽量会手写最好
function submitUpload() {
var chunkSize=2*1024*1024;//分片大小 2M
var file = document.getElementById('f1').files[0];
var chunks=[], //保存分片数据
token = (+ new Date()),//时间戳
name =file.name,chunkCount=0,sendChunkCount=0;
//拆分文件 像操作字符串一样
if(file.size>chunkSize){
//拆分文件
var start=0,end=0;
while (true) {
end+=chunkSize;
var blob = file.slice(start,end);
start+=chunkSize;
if(!blob.size){//截取的数据为空 则结束
//拆分结束
break;
}
chunks.push(blob);//保存分段数据
}
}else{
chunks.push(file.slice(0));
}
chunkCount=chunks.length;//分片的个数
//没有做并发限制,较大文件导致并发过多,tcp 链接被占光 ,需要做下并发控制,比如只有4个在请求在发送
for(var i=0;i< chunkCount;i++){
var fd = new FormData(); //构造FormData对象
fd.append('token', token);
fd.append('f1', chunks[i]);
fd.append('index', i);
xhrSend(fd,function () {
sendChunkCount+=1;
if(sendChunkCount===chunkCount){//上传完成,发送合并请求
console.log('上传完成,发送合并请求');
var formD = new FormData();
formD.append('type','merge');
formD.append('token',token);
formD.append('chunkCount',chunkCount);
formD.append('filename',name);
xhrSend(formD);
}
});
}
}
function xhrSend(fd,cb) {
var xhr = new XMLHttpRequest(); //创建对象
xhr.open('POST', 'http://localhost:8100/', true);
xhr.onreadystatechange = function () {
console.log('state change', xhr.readyState);
if (xhr.readyState == 4) {
console.log(xhr.responseText);
cb && cb();
}
}
xhr.send(fd);//发送
}
//绑定提交事件
document.getElementById('btn-submit').addEventListener('click',submitUpload);
# 大文件断点上传
大致过程:
在分片上传前提下
- 为每个分段生成 hash 值,使用
spark-md5
库为每个文件分片打一个哈希 - 将上传成功的分段信息保存到本地,(为了简单,其实要用hash,比如我这里记录文件索引的形式记录已上传还是未上传)
- 重新上传时,都会拿当前上传的分段和本地分段 hash 值的对比,如果相同的话则跳过,继续下一个分段的上传
//获得本地缓存的数据
function getUploadedFromStorage(){
return JSON.parse( localStorage.getItem(saveChunkKey) || "{}");
}
//写入缓存
function setUploadedToStorage(index) {
var obj = getUploadedFromStorage();
obj[index]=true;
localStorage.setItem(saveChunkKey, JSON.stringify(obj) );
}
//分段对比
var uploadedInfo = getUploadedFromStorage();//获得已上传的分段信息
for(var i=0;i< chunkCount;i++){
console.log('index',i, uploadedInfo[i]?'已上传过':'未上传');
if(uploadedInfo[i]){//对比分段
sendChunkCount=i+1;//记录已上传的索引
continue;//如果已上传则跳过
}
var fd = new FormData(); //构造FormData对象
fd.append('token', token);
fd.append('f1', chunks[i]);
fd.append('index', i);
(function (index) {
xhrSend(fd, function () {
sendChunkCount += 1;
//将成功信息保存到本地
setUploadedToStorage(index);
if (sendChunkCount === chunkCount) {
console.log('上传完成,发送合并请求');
var formD = new FormData();
formD.append('type', 'merge');
formD.append('token', token);
formD.append('chunkCount', chunkCount);
formD.append('filename', name);
xhrSend(formD);
}
});
})(i);
}
← 实习心得