记一次项目总结

前言

前段时间做了一个页面,做的是个人云盘的业务,操作功能上类似于百度网盘和windows文件管理。这个业务本身没有称得上是亮点的地方,但是当中有很多地方值得总结,无论是技术上还是感悟上。

我的感悟首先在产品上,作为一名前端,要不断地站在用户的角度上去感受它,一定有一些可以做的更友好、更人性化的地方。比如在移动复制文件/文件夹的操作中,原来只能通过右键菜单操作,现在可以通过键盘ctrl + vc/x/v,也可以直接拖动(移动)。

其次在本次编码中,我有以下意识和习惯:

  • 代码的解耦(合理拆分:分为函数、组件/类、文件三个维度上的解耦)
  • 当前技术栈下的代码可优化点和优雅、正确的编程方式
  • 代码的复用性和可扩展性
  • 过程记录、事后总结、API文档书写

然后,还有几个感悟:

  1. 当使用新的标准API、开源项目时,要先进行考察。考察点除了功能上能否满足外,还要着重看成熟度与活跃度,更重要的是要看它的问题列表,有没有得到足够的、及时地解决和回复。
  2. 对于某些具体的技术问题,只要肯思考、敢啃硬骨头,大部分问题都是能解决的
  3. 作为前端从业人员,位于数据链的最下游,受制于后端人员的时间和精力等因素,很容易受影响拖慢开发进度,所以最好还是要拓宽自己的技术栈

最后,还有一些收获:

  • 学习到的具体的技术点若干
  • 技术解决方案若干
  • 公共组件、公共方法的开发经验(开始尝试造轮子)

接下来,我把所有相关的技术点整理在这里,巩固学习。清单如下:

  • 技术点
    • HTML5 Observer API
    • React props派生
    • 滚动事件和滚轮事件
    • 事件委托的原生封装
    • 在线图片转化为base64编码
    • 浏览器(内核)及版本的判断
    • 兼容Linux、Windows、Mac的文件命名规则的方案
    • React组件的props控制(破坏性魔改)
    • IE中使用base64报错“传递给系统调用的数据区域太少”的问题
  • 技术方案
    • HTML5拖拽API的兼容性处理方案
    • web大文件分片上传和断点续传的实现(只有思路,没有成熟方案)
    • 下载异常、错误的友好提示处理
    • 多行文本省略号效果,在系统字体可变化的情况下,能够合理展示的解决方案
  • 关于公共组件
    • 什么时候需要公共组件
    • 公共组件的作用、特点
    • 内部的运作方式(公共组件与外界的交流方式、内部的状态管理)
    • 公共组件是如何在业务中实现功能的

技术点

HTML5 Oberver API

HTML5 增加了一批 Oberver API ,包括 MutationObserver, PerformanceObserver, IntersectionOberver, ResizeObserver 等,目的是针对一些目标进行监控。这些 API 中只有 MutationObserver (针对DOM结构的监控)进入了正式标准,PerformaneObserver 进入候选阶段,IntersectionObserver 和 ResizeObserver 目前在草案阶段。所以这里讲解一下 MutationObserver,它有一个构造函数 MutationObserver() 和 三个方法 disconnect()、observe()、takeRecords()

MutationObserver(callback)    构造函数,返回一个监听DOM变化的MutationObserver对象
   回调函数:当指定的被监控DOM节点发生变动时执行。有两个参数:第一个是 MutationRecord 对象数组,即描述所有被触发改动的对象,详细的变动信息存储在这些对象中。第二个是当前调用该函数的 mutationObserver 对象

.observe(target, opinions)    开始监控DOM节点
    target是被监控的DOM节点
    opinions可选,是一个对象,属性有:
        attributeFilter     要监控的DOM属性,若无此属性,默认监控所有属性。无默认值
        attributeOldValue     当被监控节点的属性改动时,将次属性置为true将记录任何有改动属性的上一个值。无默认值
        attributes        置为true以观察受监视元素的属性值变更。默认值为false
        characterData  置为true以观察受监视元素的属性值变更。默认值为false
        characterDataOldValue  置为true以在文本在受监视节点上发生更改时记录节点文本的先前值。
        childList            置为true以监视目标节点(如果subtree为true,则包含子孙节点)添加或删除新的子节点。默认值为false。
        subtree            置为true以扩展监视范围到目标节点下的整个子树的所有节点。MutationObserverInit的其他值都会作用于此子树下的所有节点,而不仅仅只作用于目标节点。默认值为false。
    
.disconnect()        此方法告诉观察者停止监控

.takeRecords()      此方法返回已检测到但尚未由观察者的回调函数处理的所有匹配DOM更改的列表,使变更队列保持为空。 此方法最常见的使用场景是在断开观察者之前立即获取所有未处理的更改记录,以便在停止观察者时可以处理任何未处理的更改。

React props 派生

何为 props 派生?比如现在有这样的需求,子组件中来自父组件的 props 数据,并不是直接使用,而是在其基础上进行更改过后才会使用,因此需要 props 变化时更新 state 的操作,可以通过生命周期函数实现。

在react16.4版本之前通过 componentWillReceiveProps 来实现,16.4之后还可以通过 getDerivedStateFromProps 来实现。另外,在具体情况下是否真的需要 props 派生、注意事项及可能出现的bug官网博客总结的很详细

你可能不需要派生state

滚动事件和滚轮事件

滚动事件 onscroll,滚轮事件 onwheel。在PC端一般容易被认为没什么区别,但还是有些细微的差别。无论通过何种方式(鼠标滚轮、键盘方向键、触摸板)滚动页面,只要有滚动发生都会触发滚动事件。而滚轮事件无论页面有无发生滚动,只要滚轮被触动,都会发生该事件。大部分时候只需要滚动事件即可,个别时候滚轮事件配合使用。比如想页面已经滚动到底部,仍在滚动滑轮时,只发生滚轮事件不发生滚动事件,有这个需求可以配合使用。注意,滚轮事件要使用onwheel,onmousewheel已被废弃。

事件委托的原生封装

在封装事件委托之前,有几个问题需要明白:

为什么需要事件委托?

  1. 提高页面性能
  2. 有时候想要为某元素绑定监听事件,但无法获取其DOM元素,这个时候可以获取其祖先元素,利用事件委托即可绑定事件监听

jQuery不是有 on() 方法来实现事件委托吗?为什么还要自己封装?

进入React、Vue、Angular的前端组件化 + 前端工程化时代,我们应该改变思维,在开发中尽量不要使用jQuery。你应该首选使用React提供的事件处理机制,尽量不要使用原生JS处理事件。当你确认React的事件处理无法满足你的需求、或者不方便实现时,可以使用addEventListener()。虽然这里封装了 onDelegate(),但还是建议你不在万不得已的情况下不要使用。

使用文档

记一次项目总结_第1张图片记一次项目总结_第2张图片

实现源码:

import cloneDeep from "lodash/cloneDeep";

const throwError = (message) => { throw new Error(message) };

// 判断是否是DOM元素
const isDOM = (obj) => typeof HTMLElement === 'object' ?
      obj instanceof HTMLElement
      :
      obj && typeof obj === 'object' && obj.nodeType === 1 && typeof obj.nodeName === 'string';

// 检查selector的有效性
const checkSelector = (parent, selector) => {
   try{
      parent.querySelector(selector);
   }catch (e) {
      return `参数 selector 无效`
   }
};


// 参数检测
const paramCheck = (type, events, parent, selector, func, data, reverseScope, capture) => {
   let baseMsg = `Document模块 ${type}Delegate()方法调用错误:`;

   if (type === "on")
   {
      typeof events !== "string" && throwError(`${baseMsg}参数 events 必须是 string 类型,现在是${typeof events}!`);
      events.length === 0 && throwError(`${baseMsg}参数 events 不能为空!`);
      !isDOM(parent) && throwError(`${baseMsg}参数 parent 必须是 DOM 元素!`);
      typeof selector !== "string" && throwError(`${baseMsg}参数 selector 必须是 string 类型,现在是${typeof selector}!`);
      let selectRes = checkSelector(parent, selector); // 检测selector的有效性
      typeof selectRes === "string" && throwError(`${baseMsg}${selectRes}!`);
      typeof func !== "function" && throwError(`${baseMsg}参数 func 必须是 function 类型,现在是${typeof func}!`);
      typeof reverseScope !== "boolean" && throwError(`${baseMsg}参数 reverseScope 必须是 boolean 类型,现在是${typeof reverseScope}!`);
      typeof capture !== "boolean" && throwError(`${baseMsg}参数 capture 必须是 boolean 类型,现在是${typeof capture}!`);
      Object.prototype.toString.call(data).slice(8, -1) !== "Object" && throwError(`${baseMsg}参数 data 必须是 object 类型!`); // 判断data数据类型
   }else if(type === "off")
   {
      typeof events !== "string" && throwError(`${baseMsg}参数 events 必须是 string 类型,现在是${typeof events}!`);
      events.length === 0 && throwError(`${baseMsg}参数 events 不能为空!`);
      let selectRes = checkSelector(parent, selector); // 检测selector的有效性
      typeof selectRes === "string" && throwError(`${baseMsg}${selectRes}!`);
      !isDOM(parent) && throwError(`${baseMsg}参数 parent 必须是 DOM元素!`);
      typeof selector !== "string" && throwError(`${baseMsg}参数 selector 必须是 string 类型,现在是${typeof selector}!`);
      typeof func !== "function" && throwError(`${baseMsg}参数 func 必须是 function 类型,现在是${typeof func}!`);
      typeof reverseScope !== "boolean" && throwError(`${baseMsg}参数 reverseScope 必须是 boolean 类型,现在是${typeof reverseScope}!`);
   }
};


let EventHandles = [];

// 事件委托
const onDelegate = (events = "", parent, selector = "",  func, data = {}, reverseScope = false, capture = false) => {
   data = cloneDeep(data);
   paramCheck("on", events, parent, selector, func, data, reverseScope, capture);  // 参数检测

   const already = EventHandles.find(f => f.events === events && f.parent === parent && f.selector === selector && f.func === func && f.reverseScope === reverseScope);
   if(!already)
   {
      const handler = (e) => {
         let flag = false, target = e.target, selectList = Array.from(parent.querySelectorAll(selector));
         while (target.tagName !== "BODY")
         {
            if (selectList.includes(target))
            {
               let event = { delegateTarget: parent, currentTarget: target, data: data, originalEvent: e };
               !reverseScope && func(event);
               flag = true;
               break;
            }
            target = target.parentNode ? target.parentNode : "";
         }
         let event = { delegateTarget: parent, currentTarget: e.target, data, originalEvent: e };
         reverseScope && !flag && func(event);
      };
      parent.addEventListener(events, handler, capture);
      EventHandles.push({ events, parent, selector, func, reverseScope, handler });
   }
};

// 解除由onDelegate()绑定的事件监听
const offDelegate = (events = "", parent, selector = "", func, reverseScope = false) => {
   paramCheck("off", events, parent, selector, func, {}, reverseScope);
   let hands = EventHandles.filter(f => f.events === events && f.parent === parent && f.selector === selector && f.func === func && f.reverseScope === reverseScope);
   hands.forEach(i => {
      parent.removeEventListener(events, i.handler);
      EventHandles.splice(EventHandles.indexOf(i), 1);
   });
};


export { onDelegate, offDelegate };

在线图片转化为base64编码

这个需求可能不太常见

export const convertImgUrlToBase64 = (url, outputFormat) => new Promise((resolve, reject) => {
   let img = document.createElement("img"); img.crossOrigin = 'Anonymous';
   let canvas = document.createElement('CANVAS');
   let ctx = canvas.getContext('2d');
   img.src = url;
   img.addEventListener("load", () => {
      canvas.height = img.height;
      canvas.width = img.width;
      ctx.drawImage(img, 0, 0);
      let dataURL = canvas.toDataURL(outputFormat || 'image/png');
      resolve(dataURL);
      canvas = null;
   });
});

浏览器内核(版本)的判断

这里判断浏览器外壳没有太大的意义,重要的是判断内核。

export const judgeBrowserType = () => {
   const agent = navigator.userAgent;
   let browser = "", version = "-1", ver;
   switch (true) {
      case agent.includes("Opera"):   // Opera浏览器(非Chromium内核, 老版本)
         browser = "opera"; ver = agent.match(/Version\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`;
         break;
      case agent.includes("Trident") || agent.includes("MSIE"): // IE浏览器 或 IE内核
         browser = "ie";
         agent.includes("MSIE") && (ver = agent.match(/MSIE\/([\d.]+)/)[1].split("."));
         !agent.includes("MSIE") && (ver = ["11", "0"]);
         version = `${ver[0]}.${ver[1]}`;
         break;
      case agent.includes("Edge"):    // Edge浏览器
         browser = "edge"; ver = agent.match(/Edge\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`;
         break;
      case agent.includes("Firefox"): // Firefox浏览器
         browser = "firefox"; ver = agent.match(/Firefox\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`;
         break;
      case agent.includes("Gecko") && !agent.includes("like Gecko"): // 非Firefox的Gecko内核, 无法判断版本
         browser = "firefox";
         break;
      case agent.includes("Safari") && !agent.includes("Chrome"):    // Safari浏览器
         browser = "safari"; ver = agent.match(/Version\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`;
         break;
      case agent.includes("Chrome") && agent.includes("Safari"):      // Google Chrome 或 Chromium内核
         browser = "chrome"; ver = agent.match(/Chrome\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`;
         break;
      default:
         browser = "others";
         break;
   }
   return { browser, version }
};

兼容Windows、Mac、Linux的命名规则

各平台的文件命名规则:

  • Windows
    • 不能超过 255 个字符(含扩展名)或 127 个中文字符
    • 文件名可以包含除 ? " " / \ < > * | : 之外的大多数字符
    • 除了开头之外任何地方都可以使用空格
    • 保留大小写格式,但不区分大小写
  • Mac
    • 不能包含冒号 : 不能以句点 . 开头
    • 部分 APP 可能不允许使用斜杠 / 
  • Linux
    • 大小写敏感
    • 不允许使用 / 
    • 不允许将 . 和 .. 当做文件名
// turn === "turn"表示,不合规的字符会被修改成下划线(超出长度的字符被剪掉)
const nameRule_compatible = (fullName, name, turn) => {
   let flag = true;
   let errorMsg = "";
   const forbid = `?"/\\<>*|:`;

   fullName = fullName.trim(); name = name.trimLeft();

   if(fullName.length > 255)
   {
      errorMsg = getLabel(513983, '名称不得超过255个字符');
      if (turn === "turn")
      {
         name = name.substring(0, 216);
         fullName = `${name}.${fullName.split(".").pop()}`;
      }
   }

   for(let i=0; i | `;
         if (turn === "turn")
         {
            let regExp = new RegExp(`\\${forbid[i]}`, "g");
            name = name.replace(regExp, "_");
            fullName = fullName.replace(regExp, "_");
         }
      }
   }

   if(name[0] === ".")
   {
      errorMsg = getLabel(513985, '不能以 . 开头');
      if (turn === "turn")
      {
         fullName = "_" + fullName.substring(1);
         name = "_" + name.substring(1);
      }
   }

   if(errorMsg)
   {
      flag = false;
      message.warn(errorMsg);
   }

   return [flag, fullName, name]
};


export {
   nameRule_compatible,
}

React 组件的 props 使用

这里”组件 props 使用“指的是:当使用某个组件时,无法直接接触到其内部使用的某组件,而这时希望改变该某组件的 props 传参。这里有两个方法,一是获取目标组件的ref,可以直接修改值;二是直接获取目标组件的变量(或其父组件、祖先组件,可以顺着找到目标组件)来操作。需要指出的是,第二种方法具有破坏性,可以在实在没办法的情况下使用。

IE 中使用 base64 时报错

报错“传递给系统调用的数据区域太小”。是由于 IE 浏览器中对 href、src 这些属性对 url 长度有限制,而 base64 一般都比较长。原理上讲,先将 base64 转 blob 再生成 url,但由 blob 生成 url 这部分操作(HTML标准)的结果,在IE下会报错。怎么解决呢?使用IE自己的API: window.navigator.msSaveOrOpenBlob(blob, fileName);

技术方案整理

HTML5 拖拽 API 的兼容处理方案(除了拖拽上传)

本业务中的功能是:拖拽文件图标至文件夹中,完成文件移动的功能。此功能在开发中依照 HTML5 标准 API 编写,基于最新的稳定版Google Chrome(78),并未发现任何兼容性问题。

1.Firefox 存在打开新标签页的问题:拖拽释放在目标元素上时会打开新标签页

  解决方法:drop 事件中阻止默认行为

2.IE 某些版本有兼容性问题:

  IE11:在 dataTransfer.setData() 时,键不能自定义,只能是标准规定的如 Text

  IE10、IE9:支持 HTML5 标准,但本人未作测试

  IE9 以下:不支持标准 API

3.Edge:

  旧版本的 KTHML 内核:测试版本是16,遇到的问题与 IE11 相同,处理方式相同

  新版本的 Chromium 内核:无兼容性问题

4.国产浏览器拖拽释放会打开新标签页:

  IE 内核:不要使用 dataTransfer 对象来传递数据,可以使用共享的变量(如全局变量、store、类属性this.xxx),需要该数据去维护

  Chromium 内核:原因在于 e.dataTransfer.setData() 中的 key (貌似需要使用自定义key)

5.父元素允许拖拽时,子元素想要被选中文本(子元素自动被允许拖拽):

  如果子元素是 input,子元素 draggable=true,dragstart 事件阻止默认事件即可

  如果子元素是普通元素,使用 mousedown/mouseup 事件 或 mouseenter/mouseleave事件 相互配合,改变父元素的 draggable 属性

6.拖动元素在目标元素上晃悠,目标容器元素表现异常:

  期望效果是在 dragenter 事件(进入目标元素时)改变背景颜色,dragleave 事件(离开目标元素时)恢复背景颜色。现实情况是:进入目标元素后离开目标元素前不断闪烁(多次交替发生 dragenter/dragleave),并且时长无法恢复背景颜色。

  原因:如果目标元素内部没有子元素,不会出现上述异常。如果内部有多个子元素(及后代元素),那么拖动元素在目标元素上经过子元素时会有上述异常。明明是在目标元素上绑定的这两个事件,却在其所有的后代元素上都会触发(并非冒泡)

  解决方法:设置一个缓存变量(布尔值),标记当前是否进入/离开目标元素,排除子元素的干扰,即可。

7.拖拽下载:只有 Chrome 支持,暂没测试 Chromium 内核其它浏览器

只需将 dataTransfer 对象设置 DownloadURL 即可。

Web大文件分片上传和断点续传(没有具体方案,但有整体思路)

断点续传必然要分片上传,前端将文件分片上传,后端一个一个地接收分片并存储,当全部接收完毕后再合并。因此,在分片上传时,需要前后端协商好文件名、任务ID、分片个数、当前分片索引等信息。

分片上传建议一个一个地上传(串行上传),当用户暂停上传时,当前正在上传的分片中断,下次继续上传时,从此分片开始上传。

前端的核心问题是如何实现文件分片,后端的核心问题是如何将文件合并、何时合并。

前端分片通过 HTML5 FILE API 的 slice() 方法,可将文件分片。后端在全部分片接收完毕时即可开始合并,合并思路:新建二进制文件,按顺序读取分片,将读取的二进制流依次写入新文件,正确命名特别是扩展名,即可完成合并。

前端实验代码:

function SliceUploadFile() {
  let fileObj = document.getElementById("file").files[0];  // js 获取文件对象

   const itemSize = 8 * 1024 * 1024;    // 分片大小:8M
   const number = Math.ceil(fileObj.size / itemSize);   // 分片数量
  let prev = 0;
  for(let i=0; i {
               progress[0] = {
                 last_laoded: 0,
                 last_time: e.timeStamp,
              };
               console.log("开始上传",progress);
             };
            xhr.upload.onloadend = () => {
               delete progress[0];
               console.log("结束上传",progress);
             };
            return xhr;
         },
         success: function (data) {
            data = JSON.parse(data);
            data.forEach((i) => {
               console.log(i.code, i.file_url);
             });
         },
         error: function () {
            alert("aaa上传失败!");
         },
       });
      prev = end
  }
}

后端分片上传代码:

        try:
            resList, fileList = [], request.FILES.getlist("file")
            msg = json.loads(request.POST.get("msg"))
            print(f"msg: {msg['type']}, count: {msg['count']}, current: {msg['current']}")

            dir_path = 'static/files/{0}/{1}/{2}'.format(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
            if os.path.exists(dir_path) is False:
                os.makedirs(dir_path)
            for file in fileList:
                filename = f"{msg['current']}_{msg['task']}" if msg['type'] == "slice" else file.name
                file_path = '%s/%s' % (dir_path, filename)
                file_url = '/%s/%s' % (dir_path, filename)
                res = {"code": 0, "file_url": ""}
                with open(file_path, 'wb') as f:
                    if f == False:
                        res['code'] = 1
                    for chunk in file.chunks():  # chunks()代替read(),如果文件很大,可以保证不会拖慢系统内存
                        f.write(chunk)
                res['file_url'] = file_url
                resList.append(res)
            return HttpResponse(json.dumps(resList))
        except:
            return HttpResponse("error")

后端分片合并代码:

def mergeFiles():
    "合并分片文件"
    path = "../static/files/2019/11/26"

    fileList = [file for file in os.listdir(path)]
    fileList.sort(key=lambda x: int(x.split("_")[0]))
    maxIndex = int(fileList[-1].split("_")[0])
    mergeName = "企业应用-部署介绍和nginx安装.mp4"

    with open(f"{path}/{mergeName}", "wb") as f:
        for fileName in fileList:
            print("正在合并", fileName)
            with open(f"{path}/{fileName}", "rb") as file:
                # f.write(file.read())
                for line in file:
                    f.write(line)
下载异常、错误时的友好提示方案

在整个系统中常见文件下载,下载本身的实现也很简单,但下载如果有异常可能会导致前端页面报错、白屏、错误页等问题,也就是提示不友好的问题。

经过思考,我认为这需要前后端的配合才可以做到友好提示,如下:

  • 首先对下载地址发送 HEAD 请求,探测应用层面是否能走通,如果返回状态码 200 说明网络是没有问题的,开始下载
  • 在 iframe 中的 a 标签开始下载(不要 download 属性),如果下载发生异常,首先排除网络问题,可以确定是服务端有错误。这时需要服务端做异常处理,捕获异常后响应给前端,返回提示字符串
  • 前端接收到字符串,会将字符串直接呈现在 iframe 中,可以通过 onload 事件监控到,将内容读取可以呈现给用户
  • 如果下载过程中出现网络异常,浏览器会自动处理(中断下载),页面不会有问题
多行文本省略号效果在系统字体变化的情况下能够合理展示的解决方案

多行文本省略号,目前 CSS 没有正式的标准方案,webkit 内核的浏览器(Chromium内核【Chrome、Edge、Opera、国产浏览器】、Firefox68+、Safari)有非标准的 CSS 方案可以实现。但是对于低版本火狐、旧版Edge、IE、旧版Opera等,无法只通过 CSS 实现。以前的处理办法是 overflow: hidden,再设置 max-height、固定 width。虽然没有省略号效果,但是也能看得过去。

现在的情况不同了,系统的字体可以随时变化:“大”、“中”、“默认”。导致在 overflow: hidden 时,max-height 的值无法固定,此方案行不通。因此,在这种情况下,经过我的摸索找到了两种方法:

  • 经过研究发现,切换系统字体时,其实是切换了一套 css 文件,通过 MutationObserver 可以监控 中