非 Vue 相关技术总结

文章目录

  • 优秀参考资料
  • 非 Vue 相关技术总结
    • JS文件操作
      • 参考
      • 上传
      • 下载
      • 预览
    • JS操纵剪切板
      • 文本
      • 文件(图片)
    • js 拖拽上传文件
    • ngrok 内网穿透
    • 两个 iFrame 标签包裹 Vue 项目互相传值
    • 使用 element-resize-detector 监控 div 的 resize
    • 正则校验
      • webStorm 全局搜索显示全部匹配结果
      • 邮箱校验
      • 电话校验
      • 正则匹配非注释非console的中文
      • 转载: [正则表达式匹配"非",以及"非"字符串的匹配](https://blog.csdn.net/xuyangxinlei/article/details/81359366)
      • 匹配符合要求的最短匹配字符 / 或者包括换行
    • 使用 flat 函数平铺复杂数组
    • JS 异步
    • 使用 sortable.js 时固定列表顶部和底部(不能修改顺序)
    • sortable.js 多层级排序互不影响
    • echarts dataZoom 属性不生效解决办法
    • @功能的实现❌
    • webpack 打包静态资源到 dll
    • scroll事件的capture和内存溢出, 节流和防抖❌
    • 层叠上下文和z-index❌
    • Content-Disposition❌
    • unbeforeunload的return null可执行之前的操作, 注意不要被覆盖了, 可以用addEventListener
    • 踩坑:Notification 不显示
    • 事件循环, 微任务, 宏任务
    • pointer-events: none 属性让鼠标无法选中元素
    • echarts 柱状图 label 自适应切换柱内柱外功能
    • 隐藏的空格: unicode 8236 8237
    • Object.freeze 让对象的属性无法变动
    • 模拟下拉区域点击空白处消失解决方案
    • jquery 同步请求: \$.when 和 \$.deferred
    • 手动让遮罩比最外层 z-index 更高
    • js 创建 style 标签并插入到页面
    • 渲染进程❌
    • 用"一个空格 + 一个 \ "正则替换两个空格, 避免全是 \  无法显示换行效果
    • onclick = null 和 on('click') 和 addEventListener('click') 的关系❌
    • 阻止 chrome "[保存并填写地址](chrome://settings/addresses)" 导致输入框聚焦时出现候选项
    • ios new Date(str) 不支持 yyyy-mm-dd hh:mm:ss.ms,只支持 yyyy/mm/dd hh:mm:ss
    • echarts 注意清除画布
    • 微前端思想 - qiankun 框架
    • element.scrollIntoView(true) 滚动视图到指定位置, Drawer 内可用
    • git submodule 代码复用
    • scss: bem
    • CSS 文字背景图片
    • 创建和触发自定义事件
    • after 的 content 为中文冒号时显示成乱码 :
    • Chrome 80 之后的版本, 跨域 set-cookie 失败, 发送请求时带不上 cookie
    • 修改客户网站和聊天埋点冲突的方法
    • 跨 Tab 页通信
      • Broadcast Channel API
      • window postMessage
      • websocket
      • storage event
      • SharedWorker
    • 多个页面 websocket 接收消息重复提醒问题
    • iframe 渲染请求到的 html (邮件预览), 避免样式污染
    • 接上一条, 打印 iframe 邮件详情
    • 接上一条, iframe 预览邮件时, 要求固定水平滚动条在视口底部
    • iconfont 图标库引入(@font-face)后不生效问题
    • 单行文本多行文本尾部追加元素样式问题
    • HTML 跨平台使用同一套 emoji (twemoji)
    • 带有 sandbox 属性的 iframe 打开 wa.me (whatsapp 网页版聊天)界面被拒绝 ❌
    • 转换网络文件 url 为 blob/File 对象
    • 禁止浏览器自动填充密码
    • 使用浏览器指纹做唯一标志
  • 设备/浏览器唯一标志 ID 调研
    • 背景:
    • 方案:
      • 浏览器本地存储直接设置标识
      • 使用浏览器音频 API 标识客户
      • 使用开源库
      • 使用前者的升级版
    • 在可编辑 div (contentEditable)末尾插入换行符(\n 或 \)无效的解决办法
    • 全局捕获异常
    • JS 实现逻辑同或异或
    • CSS:左侧一个全选框, 右侧按钮数量不定, 要求按钮数量多时直接另起一行, 全选框单独一行
    • ios 上 chrome 浏览器第一次点击按钮/链接无反应
    • 不使用 unload 和 beforeunload , 避免影响页面性能, 用 pagehide 代替
    • HTML 页面的生命周期
    • 定义变量时使用[variable], 直接将变量作为对象 key
    • 使用 importance 和 fetchpriority 提高/降低静态资源加载优先级
    • chrome 插件:content-script 部分逻辑在页面无法生效,可考虑插入 script 到页面上
    • 百度地图埋点动态插入到页面不生效, document.write 缘故,不能异步引入该埋点 js

优秀参考资料

  1. 壹题汇总: 前端面试题汇总, 非常优秀

非 Vue 相关技术总结

JS文件操作

参考

HTML5 进阶系列:文件上传下载

如何用 JavaScript 下载文件

vue+axios上传文件

axios全攻略

小tips: 纯前端JS读取与解析本地文本类文件


上传

  1. 通常思路

    隐藏掉很丑的 input type="file" ,在自定义的上传按钮上绑定点击事件,通过 id 调用这个 input ,然后 .click() ,在这个 input 的 change 事件内获取到 event.target.file 做各种操作

  2. axios

    let param = new FormData(); // 创建form对象
    param.append('后台要你传的文件参数属性名', file, fileName); // 通过append向form对象添加数据
    param.append('其他参数属性名', '其他参数数据');
    // console.log(param.get('file')); // FormData私有类对象,访问不到,可以通过get判断值是否传进去
    let config = {
        headers: {'Content-Type': 'multipart/form-data'}
    };
    axios.post(url, param, config);
    
  3. 上传进度条

    1. 原生 Ajax 有 progress 事件
    2. axios 有 onUploadProgress 事件,在 config 里定义
  4. 中断上传

    1. 原生 Ajax 有 abort 方法
    2. axios 有 cancelToken 属性,在 config 里定义
  5. 七牛文件上传重名文件

    1. 七牛本身可以设置同名文件上传时的操作(上传策略 , scope 属性),可以设置同名而内容不一样的文件上传时是覆盖还是不允许上传.
    2. 可以在上传时不带上文件名,使用七牛返回的 hash 值存地址,这样相同内容的文件 hash 值一致,不会重复存储,而真正的文件名可以上传成功后在自己的服务器上再保存一次
    3. 七牛可以加一个参数 ?attname= 后面加指定的文件名, 把未传 key 的文件下载成指定名称的文件
  6. 上传前检测图片的宽高,大小

    let file = event.target.files[0];
    let reader = new FileReader();
    reader.onload = (e) => {
        let img = new Image();
        img.src = e.target.result;
        img.onload = () => {
            if (img.width >= 300 && img.height >= 300) {
                if (file.size <= Math.pow(1024, 2)) {
                    // 大小,宽高都符合要求,可以上传了
                } else {
                    // 报错提示图片最大 1M
                }
            } else {
                // 报错提示图片宽高不符合要求(300x300)
            }
        }
    }
    reader.readAsDataURL(file);
    

下载

  1. fetch

    fetch(path).then(response => {
        return response.blob();
    }).then(blob => {
        let a = document.createElement('a');
        let url = window.URL.createObjectURL(blob);
        a.href = url;
        a.download = name;
        a.click();
        window.URL.revokeObjectURL(url);
    });
    
  2. download

    <a href="url" download="文件名.后缀">文字</a>
    
  3. 后台设置了打开即下载

    window.open(`地址[?参数]`, '_parent');
    
  4. form 提交法

  5. **content-disposition:attachment **

  6. content-type: application/octet-stream

  7. 七牛云直接设置 content-disposition

    window.open(`${URL}?attname=${name}`);
    
  8. 使用 axios 库, 请求时设置

    responseType: 'blob',
    withCredentials: false,
    

    返回的数据就是文件的 blob 数据, 要想下载, 可以 URL.createObjectURL() 生成 url , 点击下载
    或者不使用 axios 自己原生 XMLHttpRequest 实现


预览

  1. 图片类
    1. 直接添加 img 标签显示
    2. FileReader 对象 + readAsDataURL 方法
    3. 直接在浏览器打开
      1. document.createElement创建 a 标签, display: none, 设置好 hreftarget , document.appendChild , .click() 后就 removeChild
      2. window.open 方法
  2. 文本类
    1. FileReader 对象 + readAsText 方法
    2. 同上,浏览器打开
  3. Office文件
    1. 联机查看 Office 文档 ,可以直接 https://view.officeapps.live.com/op/view.aspx?src=${文件路径} 获取预览 URL ,在新页面打开

JS操纵剪切板

文本

  1. 如果是 textArea 或 input Text 之类的可以使用 select 方法选中文本的 DOM 元素

    DOM元素.select();
    if (document.execCommand('Copy')) {
        // 提示已经复制
    } else {
        // 提示不能复制
    }
    
  2. 如果不是

    设置一个隐藏的可以 select() 的 DOM 元素,用户点击复制按钮或者其他操作时,动态把目标文字复制给隐藏 DOM 元素的 value ,随后同上.

  3. DOM 事件


文件(图片)

监控 paste 事件, 从 event.clipboardData.files (或 event.clipboardData.items) 中取文件, clipboardData 是一个 DataTransfer 对象

注意:

  1. 兼容性问题
  2. 无法粘贴本地文件到浏览器, 一般用来粘贴复制自网页的文件

参考:

ClipboardEvent.clipboardData

拖拽献祭中的黑山羊-DataTransfer对象

js 拖拽上传文件

监控 drop 事件, 从 event.dataTransfer.files (或 event.dataTransfer.items) 中取文件

注意:

  1. 兼容性问题
  2. 拖拽网页文件上传时, 要注意是否正确取到了文件

参考:

DataTransfer

拖拽献祭中的黑山羊-DataTransfer对象

ngrok 内网穿透

  1. 访问网站 ngrok.cc , 按着网站上的教程来,选择免费服务器,一般来说就可以了

  2. 但是这次用的时候报错了

    Webpack 出现 Invalid Host header 错误 ,可将 webpack-dev-server  disableHostCheck 设置为true

    直接在 webpack 配置文件中 module.exports 的对象中加上 devServer 属性,值是对象,在里面设置 true

  3. 另有其他方法

    vue-cli 新建的项目, 运行时因为 vue-cli-service server , 运行成功后会有

    App running at:
      - Local:   http://localhost:8080/...
      - Network: http://xxxxxxx:8080/...
    

    Network 的地址可以给同一局域网下的其他用户看, 比如前端改了个页面显示效果, 发这个网址让同一公司网络下的 UI 验收

两个 iFrame 标签包裹 Vue 项目互相传值

  1. 参考文章:

    浏览器同源政策及其规避方法

  2. 场景描述:

    两个

  3. 请求到邮件内容, 并对邮件内容做了处理, 调用方法渲染邮件内容到 iframe 中

    // 处理邮件详情代码并渲染到iframe中
    renderCodeToIframe(mailContent) {
        /** handleMailHTML方法 和 renderMailHTML方法都来源mixins(mailContent) */
        // 生成 iframe documentElement 代码
        this.content = this.handleMailHTML(mailContent);
        // 渲染代码到 iframe 中
        this.renderMailHTML(this.mailFrameName, this.content);
    },
    
  4. 渲染相关方法, from mixins(mailContent), 可以理解为提取出公共的方法到某处, 方便其他地方复用

    1. 对原始邮件内容做一些处理, 返回 html 字符串

      handleMailHTML(mailContent) {
          // 解析邮件内容为 Document 对象
          const parser = new DOMParser();
          const doc = parser.parseFromString(mailContent, 'text/html');
      
          // 邮件内容中的 base 标签会导致页面跳转时,指向 base 标签指定的地址,而非当前系统的页面,因此去除全部的 base 标签
          Array.from(doc.querySelectorAll('base')).forEach(node => {
              node.remove();
          });
      
          // 邮件详情页的正文中超链接更改为新标签窗口打开
          Array.from(doc.querySelectorAll('a')).forEach(node => {
              if (node.target && node.target !== '_blank') {
                  node.target = '_blank';
              }
          });
      
          // 设置 body margin 默认为 0 , 避免浏览器默认样式给 body 加上 margin
          doc.body.style.margin = '0';
          // 设置 body overflow-x hidden , 不允许出现横向滚动条 --- 外部模拟水平滚动条
          doc.body.style['overflow-x'] = 'hidden';
          // 设置 body overflow-y hidden + 去掉 body 的高度限制, 避免出现右侧滚动条
          doc.body.style['overflow-y'] = 'hidden';
          doc.body.style['min-height'] = 'auto';
          doc.body.style['max-height'] = 'auto';
          doc.body.style.height = 'auto';
      
          // 添加高度自适应 script
          const heightWatcher = doc.createElement('script');
          heightWatcher.type = 'text/javascript';
          heightWatcher.innerHTML = `
      // 监听元素高度变化(200ms 定时查询元素 offsetHeight 是否发生变化)
      // 注意, 不同浏览器, 不同版本, 对各种 height 实现不同, documentElement 和其他元素也有区别
      // 这里是用 documentElement.offsetHeight 来获取整个文档高度, 别的元素的行为不确定, 可能要用 scrollHeight 来获取高度 
      function onElementHeightChange(elm, callback){
          var lastHeight = elm.offsetHeight, newHeight;
          (function run(){
              newHeight = elm.offsetHeight;
              if( lastHeight != newHeight ) {
                  callback(newHeight, lastHeight);
              }
              lastHeight = newHeight;
      
              if( elm.onElementHeightChangeTimer ) {
                  clearTimeout(elm.onElementHeightChangeTimer);
              }
      
              // 更新 hash 值, 供外部监听获取相应传参
              // iframe document 实际宽度
              var hashStr = 'documentWidth=' + elm.scrollWidth + ';'
              // iframe 元素宽度
              hashStr += 'iframeWidth=' + window.frameElement.clientWidth + ';'
              // 转码, 赋值
              location.hash = encodeURIComponent(hashStr);
      
              elm.onElementHeightChangeTimer = setTimeout(run, 200);
          })();
      }
      
      // 监听 documentElement offsetHeight 变化, 变化后设置父页面 frame 元素 height 属性为变化后的高度
      onElementHeightChange(document.documentElement, function(newHeight, oldHeight){
          console.error('onElementHeightChange', newHeight, oldHeight)
          if (window.frameElement) {
              // 设置 frame height 为变化后的新高度 + 滚动条高度, 以避免元素底部出现水平滚动条时垂直方向不能占满
              window.frameElement.height = (newHeight || 50) + (window.innerWidth - document.documentElement.clientWidth);
          }
      });
      
      // 初次加载完成时, 设置父页面 frame 元素 height 属性为 documentElement.offsetHeight
      window.addEventListener('DOMContentLoaded', function(e) {
          console.error('DOMContentLoaded');
          if (window.frameElement) {
              // 设置 frame height 为页面高度 + 滚动条高度, 以避免元素底部出现水平滚动条时垂直方向不能占满
              window.frameElement.height = document.documentElement.offsetHeight + (window.innerWidth - document.documentElement.clientWidth);
          }
      })`;
          doc.body.append(heightWatcher);
      
          // 设置 DOCTYPE 以避免页面内容缩小时, iframe 高度不变, 导致多出空白区域(参考 https://segmentfault.com/a/1190000014586956#item-3)
          const docType = '';
      
          // 返回最终的 HTML 字符串
          return docType + doc.documentElement.outerHTML;
      }
      
    2. 渲染 html 到 iframe 中

      renderMailHTML(frameName, strHTML, callBack) {
          // 不加 $nextTick 或 $nextTick 位置放错(参见 git 文件提交日志), 可能导致内容不显示 --- 实际原因是多了一个 iframe , 不清楚咋出现的
          this.$nextTick(() => {
              // 获取指定 iframe 的 window
              let ifr = window.frames[frameName];
              if (!ifr) {
                  return;
              }
      
              // 清除原有 iframe , 避免其内容对新 iframe 造成影响, 同时也避免原有 iframe 中的各种监听之类的残留
              const ifrElm = ifr.frameElement;
              const newIfrElm = ifr.frameElement.cloneNode();
              ifrElm.parentElement.replaceChild(newIfrElm, ifrElm);
      
              // 写入新 iframe 内容
              ifr = window.frames[frameName];
              if (ifr) {
                  // 写入 HTML
                  ifr.document.open();
                  ifr.document.write(strHTML);
                  ifr.document.close();
      
                  // 触发回调函数
                  if (callBack) {
                      callBack();
                  }
              }
          });
      }
      

接上一条, 打印 iframe 邮件详情

  1. 新增一个打印用的 iframe , 隐藏不显示

    
    <iframe id="printf" name="printf" style="display: none;">iframe>
    
  2. 调用下方打印方法打印

    // strHTML: 原始邮件内容
    // containerNode: 邮件完整内容(包括 iframe 和其他信息如收件人发件人等)所在的 node
    // frameName: 邮件详情页 iframe 的 name
    // printFrameName: 之前初始化的打印用 iframe 的 name
    printMailHtml(strHTML, containerNode, frameName, printFrameName) {
        // 初始化打印 Document
        const parser = new DOMParser();
        // 指定打印样式和 onload 打印
        const doc = parser.parseFromString(`${containerNode.innerHTML}`, 'text/html');
    // 替换邮件内容 iframe 为 iframe 内部文档
    const ifr = doc.querySelector(`iframe[name=${frameName}]`);
    if (ifr) {
    ifr.outerHTML = strHTML;
    }
    // 写入数据到打印 iframe 中, 打印
    const printWin = window.frames[printFrameName];
    if (printWin) {
    printWin.document.write(doc.documentElement.outerHTML);
    printWin.document.close();
    }
    }
    

接上一条, iframe 预览邮件时, 要求固定水平滚动条在视口底部

背景: 产品提出, 邮件过长时, 页面要滚动到最底部才能拖拽 iframe 的水平滚动条, 操作不方便; 因此希望水平滚动条固定显示在视口底部, 用户可以直接拖拽查看详情; 并且, 在 iframe 垂直方向滚动到底后, 水平滚动条应取消固定, 随着 iframe 继续向上移动

解决:

做一个模拟滚动条满足此需求, 其原理为:

  1. 邮件详情 iframe 本身不显示水平滚动条, 在 iframe 底部新增一个两层 div , 外部 div 宽度与 iframe 保持一致, 内部 div 宽度为 iframe 内部文档实际宽度
  2. 监听模拟滚动条容器(外层 div)的 scroll 事件, 同步将内部 div 的 scrollLeft 赋值给 iframe documentElement 的 scrollLeft
  3. 模拟滚动条容器默认设置为 position: absolute , 监听 iframeElement 的 offsetParent (其所在的 overflow div)的 scroll 事件, 当 iframe 的边界进入视口后, 设置 position 为 relative

具体代码如下:

  1. 滚动条 div

    
    
    
    <div @scroll="handleMailHorizontalScroll"
         ref="mailIframeScroll"
         :style="mailScrollContainerStyleObj">
        <div :style="mailScrollInnerStyleObj">div>
    div>
    
  2. 相关变量/滚动监听器定义

    data() {
        return {
            // 邮件自定义水平滚动条样式 -- 外部与 iframe 等宽 div 的样式
            mailScrollContainerStyleObj: {
                // 固定属性
                // 允许出现水平滚动条, 此水平滚动条即为最终显示的水平滚动条
                'overflow-x': 'auto',
                // 尽量减少滚动条占位高度
                'line-height': '0',
                // 背景透明
                'background-color': 'transparent',
    
                // 变动属性
                // 控制鼠标穿透, 确保滚动条不显示时鼠标不会误触滚动条
                'pointer-events': 'none',
                // 滚动条外部宽度, 因为显示区域和 offsetParent 不一定等宽, 这个也是要调整的, 避免滚动条从固定变为正常时宽度发生变化
                width: '100%',
                // 固定显示时 absolute, 正常显示时 relative
                position: 'relative',
                bottom: '0'
            },
            // 邮件自定义水平滚动条样式 -- 内部与 iframe documentElement 等宽 div 的样式
            mailScrollInnerStyleObj: {
                // 固定属性
                // 高度尽可能小
                height: '1px',
                // 背景透明
                'background-color': 'transparent',
    
                // 变动属性
                // 模拟 iframe 内部文档宽度, 保证外部 div 滚动条显示逻辑和 iframe 系统水平滚动条逻辑一致
                width: '0'
            },
            // 监听: iframe 所在 overflow div 发生垂直滚动 ($debounce 是自己写的防抖方法)
            handleMailVerticalScroll: this.$debounce(() => {
                // 暂存 iframe 元素
                const ifrEle = document.querySelector(`iframe[name="${this.mailFrameName}"]`);
    
                if (ifrEle) {
                    // 获取 iframe rect.bottom 和其 offsetParent rect.bottom , 以判断 iframe 底部是否在其 offsetParent 下方(还要算上指定 bottom , 避免 offsetParent 和滚动容器位置不一致)
                    const ifrRec = ifrEle.getBoundingClientRect();
                    const scrollRec = ifrEle.offsetParent.getBoundingClientRect();
    
                    if (ifrRec.bottom > scrollRec.bottom + this.scrollBarBottom) {
                        // iframe 底部在其 offsetParent 下方
    
                        // 固定显示水平滚动条在 offsetParent 底部
                        this.mailScrollContainerStyleObj.position = 'absolute';
                        this.mailScrollContainerStyleObj.bottom = `${this.scrollBarBottom}px`;
                    } else {
                        // iframe 底部不在其 offsetParent 下方
    
                        // 水平滚动条正常显示在原位置(iframe 之下)
                        this.mailScrollContainerStyleObj.position = 'relative';
                        this.mailScrollContainerStyleObj.bottom = '0';
                    }
                }
            }, 10),
            // 监听: iframe 下方模拟水平滚动条 发生水平滚动
            handleMailHorizontalScroll: this.$debounce((e) => {
                // 暂存 iframe window
                const ifr = window.frames[this.mailFrameName];
    
                // 控制 iframe documentElement 左偏移量
                if (ifr && ifr.document && ifr.document.documentElement) {
                    ifr.document.documentElement.scrollLeft = e.target.scrollLeft;
                }
            }, 10)
        };
    }
    
  3. 监听 iframe hash 值变化(之前的渲染方法里写了, 文档宽度变化时更新数据到 hash 中), 调整自定义滚动条和其容器的 width

    // 之前 renderMailHtml 方法预留有参数 callBack , 调用时给此参数传入下面的方法就行了
    
    // 监听: iframe hash 值变化
    handleIframeHashChange() {
        setTimeout(() => {
            // 暂存 iframe window
            const ifr = window.frames[this.mailFrameName];
    
            ifr.onhashchange = () => {
                // hash 值解码
                const hashVal = decodeURIComponent(ifr.location.hash);
    
                if (hashVal) {
                    // 取到 iframe clientWidth
                    let temp = hashVal.match(/iframeWidth=(.*?);/);
                    // 设置模拟滚动条外部 div 宽度为 iframe clientWidth
                    if (temp[1]) {
                        this.mailScrollContainerStyleObj.width = `${temp[1]}px`;
                    }
    
                    // 取到 iframe document scrollWidth
                    temp = hashVal.match(/documentWidth=(.*?);/);
                    // 设置模拟滚动条内部 div 宽度为 iframe documentElement scrollWidth
                    if (temp[1]) {
                        this.mailScrollInnerStyleObj.width = `${temp[1]}px`;
                    }
                }
            };
        }, 100);
    }
    
  4. 监听滚动条的宽度, 避免页面宽度足够, 不用显示水平滚动条时, 水平滚动条仍然占位, 导致底部无法点击

    watch: {
        'mailScrollInnerStyleObj.width': {
            handler(val) {
                // 获取模拟滚动条容器
                const scrollBarDiv = this.$refs.mailIframeScroll;
    
                if (scrollBarDiv) {
                    // 模拟滚动条内部 div 宽度大于容器宽度时, 才允许鼠标点击滚动条区域(避免用户想点击邮件内容却点中滚动条, 导致点击无效)
                    this.mailScrollContainerStyleObj['pointer-events'] = parseFloat(val) > scrollBarDiv.clientWidth ? 'auto' : 'none';
    
                    // 主动触发垂直滚动方法, 判断当前滚动条应该固定显示还是正常显示
                    this.handleMailVerticalScroll();
                }
            }
        }
    }
    

iconfont 图标库引入(@font-face)后不生效问题

背景: 埋点代码埋到客户网站后, 新引入的字体文件始终不显示

解决过程:

  1. 查看接口请求发现, 只引入了 css 文件, 未引入 @font-face 指定的字体文件
  2. 以为是打包错误, @font-face 在指定路径请求不到字体文件, 但实际上字体文件就在那儿, 没问题
  3. 于是直接引入 iconfont 的 cdn 链接, 结果发现还是不行, 初步排除是字体文件路径问题
  4. 尝试直接在客户网站显示图标, 结果发现, 不加埋点代码到客户网站, 直接在客户网站显示图标就没问题, 加了之后, 埋点代码生成的 div 显示不了图标, 其他地方可以(一步步从外层到里层, 发现到某一层后图标显示不了)
  5. 查看控制台, 发现有一处样式写的 .className *::before { display: none; }, 在结合之前搜索到的font-face 指定多个字体文件, 未用到的字体文件不加载, 推测是因为该样式导致字体不显示, 于是 font-face 不引入文件

结论:

  1. font-face 指定多个字体文件, 未用到的字体文件不加载
  2. 确保自己的样式没有被别的代码影响导致图标不显示

单行文本多行文本尾部追加元素样式问题

背景: 参照网页版 whatsapp 做聊天消息已读未读状态显示, UI 给的需求是, 单行文本显示在最右侧, 多行文本显示在文本底部

解决过程: 参照网页版 WhatsApp , 在文本后面加一个隐形的有宽度无高度 div , 来确保文本不和 float 的已读未读状态元素重叠

HTML 跨平台使用同一套 emoji (twemoji)

背景:

现在在做的网站支持同步 whatsApp(之后简称 wa) 消息实现消息收发, 要求接收到 wa 带 emoji 表情的消息时, 网站能正常显示 emoji , 同时, 网站发出去的带 emoji 的消息, wa 也能正常显示 emoji

概述:

  1. 引入 twemoji 库文件
  2. 把 wa 的表情全部爬下来
  3. 新增 emoji 组件, 点击表情图标弹出表情框, 框内显示与 wa 一致
  4. 点选框中表情, 根据点击前光标在输入框(contentEditable 的 div)的位置, 插入 twemoji.parse 转换过的表情(图片)
  5. 给各处可能显示 twemoji 的 div 加上特定 class(比如 twemoji-convert), 在程序主界面(Main.vue)新增 MutationObserver , 在 DOM 变化时选取此类 class 元素, 使用 twemoji.parse 转换元素, 使显示 emoji

实现过程:

  1. 引入 twemoji

    
    <script src="https://twemoji.maxcdn.com/v/13.1.0/twemoji.min.js" integrity="sha384-gPMUf7aEYa6qc3MgqTrigJqf4gzeO6v11iPCKv+AP2S4iWRWCoWyiR+Z7rWHM/hU" crossorigin="anonymous">script>
    
    
  2. 爬取 wa 表情

    在网页版 whatsapp 上聊天, 一栏栏地点选表情, 发送, 在网站处接收, 此时接收到的内容已经是字符了, 把这些字符按顺序提取为数组;

    这个需要耐心, 这些个字符千奇百怪, 有的字符电脑系统不支持不能渲染出来, 有的字符后面需要接一个空格, 有的字符看上去只有一位但实际占了多位, 最多的还是由多种字符组合显示成一个表情的(字符人, 可加修饰字符: 性别, 发型, 职业, with another one …), 千万别弄错了

  3. 新增 emoji 组件

    渲染表情部分由全局的 MutationObserver 负责(twemoji.parse)

    选中表情部分如下:

    // 点击选中 emoji
    handleClickEmoji(e) {
        // 取选中的 emoji DOM 标签
        let emojiImg;
        if (e.target.classList.contains('emoji-item')) {
            emojiImg = e.target.querySelector('img.emoji');
        } else if (e.target.classList.contains('emoji')) {
            emojiImg = e.target;
        }
    
        // 取标签上的 alt (实体字符, twemoji 转换后自带)传给外部
        if (emojiImg) {
            this.$emit('checkEmoji', emojiImg.getAttribute('alt'));
        }
    }
    
  4. 输入框接收选中表情, 加入到输入框中

    输入框 div

    
    <div :contentEditable="true"
         ref="sendMsg"
         @click="save_range"
         @keyup="save_range"
         @keydown="inputOnKeyDown"
         @paste="handlePaste"
         :placeholder="$t('chat.inputbox')"
         :class="{'waInputDiv__disabled': inputDisabled}"
         class="waInputDiv">div>
    

    输入框 div 相关事件

    // inputOnKeyDown 处理回车, ctrl 等事件, 与表情主逻辑无关, 略过
    
    // 离开焦点时先保存状态(光标等信息)
    save_range() {
        let range = null;
        if (window.getSelection) {
            const sel = window.getSelection();
            if (sel.getRangeAt && sel.rangeCount) {
                range = sel.getRangeAt(0);
            }
        } else if (document.selection && document.selection.createRange) {
            range = document.selection.createRange();
        }
        this.lastEditRange = range;
    }
    
    // 粘贴内容到可编辑 div (参考 https://www.zhangxinxu.com/wordpress/2016/01/contenteditable-plaintext-only/)
    handlePaste(e) {
        e.preventDefault();
        let text;
    
        if (window.clipboardData && window.clipboardData.setData) {
            // IE
            text = window.clipboardData.getData('text');
        } else {
            text = (e.originalEvent || e).clipboardData.getData('text/plain');
        }
        if (document.body.createTextRange) {
            let textRange;
            if (document.selection) {
                textRange = document.selection.createRange();
            } else if (window.getSelection) {
                const sel = window.getSelection();
                const range = sel.getRangeAt(0);
    
                // 创建临时元素,使得TextRange可以移动到正确的位置
                const tempEl = document.createElement('span');
                tempEl.innerHTML = '&#FEFF;';
                range.deleteContents();
                range.insertNode(tempEl);
                textRange = document.body.createTextRange();
                textRange.moveToElementText(tempEl);
                tempEl.parentNode.removeChild(tempEl);
            }
            textRange.text = text;
            textRange.collapse(false);
            textRange.select();
        } else {
            // Chrome之类浏览器
            document.execCommand('insertText', false, text);
        }
    }
    

    选中表情相关事件

    // 接收"选中 emoji 表情"事件
    handleCheckEmoji(val) {
        // 获取待插入表情 Node
        let dom_insert = document.createElement('span');
        dom_insert.innerHTML = twemoji.parse(val);
        dom_insert = dom_insert.childNodes[0];
    
        // 插入 Node 到输入框
        this.insertInputMsg(dom_insert);
    }
    
    // 插入 emoji 表情到输入框
    insertInputMsg(val) {
        // 获取待插入结点
        let dom_insert;
        if (val instanceof Node) {
            // 是 Node 结点, 不用做处理
    
            dom_insert = val;
        } else {
            // 否则当做文本结点处理
    
            dom_insert = document.createTextNode(String(val || ''));
        }
    
        // 获取编辑框对象
        const dom_input = this.$refs.sendMsg;
    
        // 编辑框设置焦点
        dom_input.focus();
    
        // 获取选定对象
        let selection = null;
        if (window.getSelection) {
            selection = window.getSelection();
        } else if (window.document.getSelection) {
            selection = window.document.getSelection();
        } else if (window.document.selection) {
            selection = window.document.selection.createRange().text;
        }
        // 如果获取不到, 退出流程
        if (!selection) {
            this.$Message.error(this.$t('whatsapp_manage.browserError'));
            return false;
        }
    
        // 判断是否有最后光标对象存在
        if (this.lastEditRange) {
            // 存在最后光标对象,选定对象清除所有光标并添加最后光标还原之前的状态
            selection.removeAllRanges();
            selection.addRange(this.lastEditRange);
        }
    
        // 根据所在位置的不同以不同的方式插入结点
        if (this.lastEditRange) {
            // 有光标对象, 直接插入
            this.lastEditRange.insertNode(dom_insert);
        } else if (selection.anchorNode == dom_input) {
            // 焦点就在文本框, 则直接 append node 到最后
            dom_input.appendChild(dom_insert);
        } else if (selection.anchorNode.nodeName != '#text') {
            // 焦点在非文本结点, 则插入到焦点节点后面
            dom_input.insertBefore(dom_insert, selection.anchorNode.nextSibling);
        }
    
        // 创建新的光标对象
        const range = document.createRange();
        // 光标对象的范围界定为新建的内容节点
        range.setStartAfter(dom_insert);
        // 插入空格, 否则光标可能不显示
        // dom_input.insertBefore(document.createTextNode(' '), dom_insert.nextSibling);
        // range.setStart(dom_insert.nextSibling, 1);
        // 使光标开始和光标结束重叠
        range.collapse(true);
        // 清除选定对象的所有光标对象
        selection.removeAllRanges();
        // 插入新的光标对象
        selection.addRange(range);
        // 无论如何都要记录最后光标对象
        this.lastEditRange = selection.getRangeAt(0);
    }
    
  5. 主界面监听 DOM 变动, twemoji.parse 转化指定 class 元素内部的实体字符为表情

    mounted() {
        // 监听 DOM 变化, 变化时使用 twemoji 库转化 emoji 实体字符为 twemoji emoji
        this.observer = new MutationObserver(function(mutations, observe) {
            const domList = document.querySelectorAll('.twemoji-convert');
            for (let i = 0; i < domList.length; i++) {
                twemoji.parse(domList[i]);
            }
        });
        this.observer.observe(document.body, {
            'childList': true,
            'characterData': true,
            'subtree': true
        });
    }
    

补充:

配合 Vue 使用时, 表情和 emoji 混杂的文本, 使用 twemoji.parse 后会破坏 vue 的响应式监听, 导致视图不随数据的更新而更新; 解决方法 — 给需要更新的地方加上 key , key 上绑定原数据, 这样, 当原数据变化时, 组件会重新渲染

带有 sandbox 属性的 iframe 打开 wa.me (whatsapp 网页版聊天)界面被拒绝 ❌

概述:

背景:

解决过程:

https://www.bookstack.cn/read/html-tutorial/spilt.2.docs-iframe.md allow-popups-to-escape-sandbox 属性解决

补充:

转换网络文件 url 为 blob/File 对象

概述:

给定一个网络文件 url , 下载文件到浏览器缓存, 完毕后转换其为 blob / File 对象

背景:

项目中现有的 whatsApp 发送文件接口, 以及云信 web 端发送文件, 不支持发文件 url , 于是想办法把文件 url 转换为对象

解决过程:

用的 axios (也可以用别的库, 或自己原生 XMLHttpRequest 实现), 设置

responseType: 'blob',
withCredentials: false,

之后

补充:

直接用 blob 文件上传, 服务器如果取文件名, 取到的就是 “blob”

如果要加名字, 网上搜了下, 带上 name 转换成 File 文件就行了 — new File([tmpBlob], name)

复制 blob 和 File , 直接 .slice() 复制就行, 复制 File 后重命名 — new File([oldFile.slice()], name);

禁止浏览器自动填充密码

概述:

  1. autocomplete 设为 off , 如果不行, 转 2
  2. autocomplete 设为 new-password , 如果不行, 转 3
  3. 放弃 autocomplete 方案, 使用 readonly 方案

背景:

项目登录页需要加上"记住密码"功能, 取消勾选"记住密码"时, 要求输入框置空, 与浏览器自动填充冲突了

解决过程:

off 和 new-password 都不行, 找到文章 https://www.cnblogs.com/chenzeyongjsj/p/7115285.html , 使用 readonly 方法实现

使用浏览器指纹做唯一标志

背景:

需要做一个登录安全校验功能, 非常用设备登录网站时, 弹出安全验证框进行验证

调研

设备/浏览器唯一标志 ID 调研

背景:

产品要求新登录设备弹出安全验证框, 因此需要标识每一台设备, 但是前端做不到设备级, 只能尝试标识浏览器

方案:

浏览器本地存储直接设置标识

往 localStorage 中存入一个新变量, 值为登录时间 + 随机数, 之后始终不清除此数据, 每次登录时取该数据一起发给后端, 后端判断与数据库中数据是否一致

优点:

  1. 最简单

缺点:

  1. 用户可以通过浏览器控制台自己修改数据
  2. 隐身模式需要重新校验
  3. 本地存储被清除需要重新校验

使用浏览器音频 API 标识客户

https://audiofingerprint.openwpm.com/

搜集浏览器音频播放的特征值, 计算得到唯一标志

优点:

  1. 纯前端实现, 前端直接使用页面上的代码即可实现
  2. 不同版本的 chrome 浏览器生成的标识相同
  3. 隐身模式/清除本地存储, 标识不变

缺点:

  1. 是否有版权问题?
  2. 公司内同一批同配置电脑 + 驱动 + 同一款浏览器, 可能会重复(再加个随机数? 比如时间之类的?)

使用开源库

https://github.com/fingerprintjs/fingerprintjs/

查询浏览器属性(包括并不限于音频, 画布, 字体, 屏幕, 操作系统, 设备名称等信息), 从中计算出标识

优点:

  1. 纯前端实现, 前端直接引用库即可实现
  2. 隐身模式/清除本地存储, 标识不变

缺点:

  1. 不同版本的 chrome 浏览器生成的标识不同
  2. 还是可能会重复(再加个随机数? 比如请求时间之类的?)

使用前者的升级版

开源版和专业版对比: https://dev.fingerprintjs.com/docs/pro-vs-open-source

demo: https://fingerprintjs.com/demo/

优点:

  1. 在服务器端处理所有信息,将浏览器指纹识别与大量辅助数据(IP 地址、访问时间模式、URL 更改等)相结合,能够可靠地对拥有相同设备的不同用户进行重复数据删除,从而实现 99.5% 的识别准确率

缺点:

  1. 调用 api 次数超过 20K/月, 则开始收费

最终代码:

// 生成浏览器指纹, 目前仅使用了 audio api 生成指纹, 参考 https://audiofingerprint.openwpm.com/ 网站源码
export const gen_fingerPrint = (useFullPrint = true) => {
    return new Promise((resolve, reject) => {
        try {
            let shortPrint = '';
            let fullPrint = '';

            const context = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 44100, 44100);

            const pxi_oscillator = context.createOscillator();
            pxi_oscillator.type = 'triangle';
            pxi_oscillator.frequency.value = 1e4;

            // Create and configure compressor
            const pxi_compressor = context.createDynamicsCompressor();
            pxi_compressor.threshold && (pxi_compressor.threshold.value = -50);
            pxi_compressor.knee && (pxi_compressor.knee.value = 40);
            pxi_compressor.ratio && (pxi_compressor.ratio.value = 12);
            pxi_compressor.reduction && (pxi_compressor.reduction.value = -20);
            pxi_compressor.attack && (pxi_compressor.attack.value = 0);
            pxi_compressor.release && (pxi_compressor.release.value = 0.25);

            // Connect nodes
            pxi_oscillator.connect(pxi_compressor);
            pxi_compressor.connect(context.destination);

            // Start audio processing
            pxi_oscillator.start(0);
            context.startRendering();
            context.oncomplete = function (evnt) {
                let pxi_output = 0;
                const sha1 = CryptoJS.algo.SHA1.create();
                for (let i = 0; i < evnt.renderedBuffer.length; i++) {
                    sha1.update(evnt.renderedBuffer.getChannelData(0)[i].toString());
                }
                const hash = sha1.finalize();
                // Fingerprint using DynamicsCompressor (hash of full buffer):
                fullPrint = hash.toString(CryptoJS.enc.Hex);

                for (let i = 4500; i < 5e3; i++) {
                    pxi_output += Math.abs(evnt.renderedBuffer.getChannelData(0)[i]);
                }
                // Fingerprint using DynamicsCompressor (sum of buffer values):
                shortPrint = pxi_output.toString();

                pxi_compressor.disconnect();

                resolve(`${useFullPrint ? fullPrint : shortPrint}`);
            };
        } catch (err) {
            console.error(err);
            resolve('');
        }
    });
};

补充:

实际开发中, 发现 fingerprintjs 库也用的相似的参数值, 项目的src/sources/audio.ts 中使用的数值与本方法中的数据基本相同,

之后如果要获取其他数据生成指纹, 可参考此项目的 src/source/index.ts , 其顶部引入了很多获取设备信息的方法, 想要啥信息直接去引入文件找

在可编辑 div (contentEditable)末尾插入换行符(\n 或
)无效的解决办法

给可编辑 div 末尾插入换行符, 发现仍然未换行;

解决方法: 提前给 div 末尾插入一个
就行了, 之后看自己情况要不要去掉

示例代码:

// 如果输入框末尾没有 BR 换行符, 则自动加一个, 避免 Ctrl + Enter 两次才显示
const currLastEl = dom_input.lastElementChild;
if (currLastEl) {
    if (currLastEl.tagName !== 'BR') {
        dom_input.appendChild(document.createElement('br'));
    }
} else {
    dom_input.appendChild(document.createElement('br'));
}

全局捕获异常

参考资料:

一篇文章教你如何捕获前端错误

备用: 一篇文章教你如何捕获前端错误

备用: 一篇文章教你如何捕获前端错误

自己实际使用场景:

全局监听 css js 等 chunk 文件是否加载失败, 失败则提示用户刷新页面, 以此解决发版后部分用户点击页面无反应问题(用户请求的还是旧版的 chunk 文件)

JS 实现逻辑同或异或

背景:

做一个根据第一个下拉框的值来决定第二个下拉框选项的功能

第二个下拉框有一个包含全部选项的数组, 要求当’第一个下拉框值是 A’ 时, ‘第二个下拉框的选项是 B’, 否则, ‘第二个下拉框的选项从全部选项中过滤掉 B’

实现:

于是, 逻辑简化为: A ? B : !B

A 是’条件 === xxx’, Boolean 型; B 是[options].includes(全选项数组当前遍历的选项值), 也是 Boolean 型;

因为不存在"根据 A 来决定是否在 B 前面加上 ! 符"这样的写法, 所以, 进一步转化条件为: (A && B) || (!A && !B), 即数学上的’同或’运算

查到 JS 没有同或运算, 但有按位异或操作符号 ^ , 因为 A 和 B 都是 Boolean 型, 直接使用 A ^ B 得到 0 或 1 , 能满足我的"遍历过滤选项"需求, 所以此处可以用 ^ 符

又因为异或是同或的取反, 于是使用 !(A ^ B) 来做为遍历过滤条件, 成功解决问题

CSS:左侧一个全选框, 右侧按钮数量不定, 要求按钮数量多时直接另起一行, 全选框单独一行

背景:

列表项可选中, 选中后顶部出现操作栏, 包括全选框 + 操作按钮

要求当按钮只有两个时, 他们显示在同一行, 全选框左对齐, 操作按钮右对齐

当按钮有三个时, 一行放不下, 要求全选框独占一行, 按钮另起一行左对齐显示

实现:

<div class="container">
    <div class="checkbox">div>
    
    <div class="button-group">div>
div>

<style>
    .container {
        display: flex;
        justify-content: space-between;
        flex-wrap: wrap;
    }
    
    .checkbox {
        flex: 1;
    }
    
    .button-group {
    }
style>

ios 上 chrome 浏览器第一次点击按钮/链接无反应

背景:

页面上有两个 元素, 一个 onclick="xxx()" , 一个 $('xxx').on('click', xxx)

在 ios 的 chrome 浏览器上, 页面刷新后第一次点击其中一个元素时, 事件不触发, 之后再点就正常, 且 safari 没有这个问题, 将 chrome 改为桌面版网站, 也没有这个问题

解决思路:

确定是浏览器而不是绑定方法的问题(我们提供的)后就没继续排查了, 未解决, 只是写下解决思路:

  1. 看能否电脑调试 ios

    电脑连手机调试 chrome 很方便, 于是想看下能否电脑调试 ios

    最后发现, 连 ios 设备只能调试 Safari , 无法调试 chrome

  2. 看是不是绑定方法的问题

    onclick 绑定的方法是我们写的, 埋点成功后提供给客户的页面使用

    看了一遍, 没发现啥问题, 而且要说不行, 为啥第二遍点就没问题呢, 于是改从客户页面上来看, 看其他元素有没有问题

  3. 对比问题元素和其他元素

    1. 发现了上述的第二个, 用 jQuery 绑定点击事件的也有这个问题. 这个的内容是客户自己写的, 跟我们没关系, 确定不是我们代码的问题了
    2. 想了下, 页面上绑定有点击相关动作的元素应该有不少, 其他的有没有这个问题? 继续排查
    3. 发现大部分按钮正常, 有个
  4. 如果实在需要我们来给客户解决, 准备怎么做

    之后想了下, 虽然不好调试 chrome on ios , 但是可以自己写一个简单的 html , 复刻相关代码结构, ios 上打开 html , 然后逐步排查

不使用 unload 和 beforeunload , 避免影响页面性能, 用 pagehide 代替

某客户检测网站性能, 发现公司的埋点 js 有 unload , 影响性能, 该检测网站给出了替换方案 pagehide , 并且之后搜了下张鑫旭大神的文章, 确定了修改方案

https://gtmetrix.com/avoid-unload-event-listeners.html

https://www.zhangxinxu.com/wordpress/2021/11/js-visibilitychange-pagehide-lifecycle/

HTML 页面的生命周期

https://www.zhangxinxu.com/wordpress/2021/11/js-visibilitychange-pagehide-lifecycle/

定义变量时使用[variable], 直接将变量作为对象 key

看同事代码发现的

for (const key in dataObj) {
    if (dataObj[key]) {
        ajaxData = {
            ...ajaxData,
            [key]: dataObj[key]
        };
    }
}

使用 importance 和 fetchpriority 提高/降低静态资源加载优先级

https://markdowner.net/skill/259698173348618240

https://juejin.cn/post/7134684645228347400

chrome 插件:content-script 部分逻辑在页面无法生效,可考虑插入 script 到页面上

背景:

某页面有个输入框, 用的应该是什么组件, 直接修改内容不生效/机制不明确, 于是使用 paste event 粘贴到输入框, 结果发现也不行

定位:

  1. 使用 mutationObserver , 发现事件确实触发了, 输入框内容变了, 但马上又变回来了, 于是怀疑是输入框组件有做 mutationOberser 监听, 发现不符合规范的变动马上变回来; 但整个页面另存为之后, 没找到对应逻辑
  2. 发现代码在网页控制台里运行, 可以实现功能
  3. 发现了另一个插件可实现粘贴功能, 参考对方实现

结论:

  1. 把逻辑从 content-script 抽取出来, 由 content-script 生成 script 标签, 插入到页面上去, 这样就实现了

    原理不清楚, 可能 content-script 运行的环境跟 script 运行的环境不同导致的吧, 从控制台里打印的语句也能看出来, 控制台也分了层级, 默认当前页面, 底下还有各种插件的控制台

  2. 插入页面具体方法, 参考插件, 单独弄一个可独立运行的 js 文件, 打包好, content-script 里使用 chrome.extension.getURL 获取此 js 的地址, 生成 script , 指定地址, 加载

百度地图埋点动态插入到页面不生效, document.write 缘故,不能异步引入该埋点 js

背景:

为提高页面加载速度, 根据地址的不同加载不同的 script (Vue 项目, 一份代码放到多个地址, 根据地址不同显示不同登录页), 结果引用的百度地图 api 失效

定位:

发现埋点 js 引入成功, 该 js 生成的内容是 document.write 再引入 js , css 文件, 这些文件没有加载出来

试了下放到各个位置, 都不行, 网上搜了下, 说是必须在 document.write 文档流还没关闭时引入, 所以不能异步引入 — 动态插入 script , 或者 async , defer 之类的

结论:

在 之前加上 script, 里面加上地址判断, 通过判断则用 document.write(‘’) 加载百度地图埋点代码

你可能感兴趣的:(前端备忘,前端,css,javascript)