非 Vue 相关技术总结

文章目录

优秀参考资料

  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. 场景描述:

    两个 <iframe> 里包着同一个 Vue 项目的两个页面,在一个页面点击了,传递参数过去到另一个页面

    跳到另一个页面是通过 DOM 取到左边目录所有目录项的路径属性,检测到第一个符合另一个页面的路径就手动 click 该目录项一下,就切换到了另一个 <iframe >

  3. 初步实现:

    通过 sessionStorage + 每次切换标签到这两个 Vue 页面就刷新该页面(启动生命周期钩子,检查 sessionStorage 是否有传值),初步实现了数据传递

  4. 后续思路:

    有天在群里有人提到了类似的问题,大佬们就提出了一些思路:

    1. hash + onhashChange
    2. contentDocument
    3. window.postMessage
    4. sessionStorage
  5. 最后实现:

    本来这个很令人捉急的项目结构只是项目重构完成前给用户凑合着用的,而且几乎没人用到这个模块,但是领导坚持要让他手下另一个公司当用户使用这部分功能,然后他们反映这样每次一切换这两个就刷新,体验很差,于是重改

    1. 使用 postMessage , 失败,不会玩

    2. 使用 onhashChange + sessionStorage

      // 具体的做法各有不同,但是思想基本上就是在该页面第一次加载的时候 mounted 钩子中加上 onhashchange 函数
      window.onhashchange = (val, oldVal) => {
          if (top.window.sessionStorage.getItem('另一个页面存入的数据属性名')) {
              if (this.view === '想要显示的页面组件') {
                  this.view = undefined;
                  this.$nextTick(() => {
                      this.view = '想要显示的页面组件'; // 刷新
                  });
              } else {
                  this.view = '想要显示的页面组件';
              }
          }
      }
      

使用 element-resize-detector 监控 div 的 resize

如题, 找"监控 div 的 resize 事件" 时, 发现 iview 框架中也用了这个

直接装 npm 就行

正则校验

  • webStorm 全局搜索显示全部匹配结果

    编辑器用的 webStorm , 正则搜索替换感觉不错, 但它的搜索界面一次只能显示出前100个匹配结果, 此时可以点击搜索界面右下角 OPEN IN FIND WINDOW 展示出全部匹配结果

  • 邮箱校验

    /^[a-z0-9]+([._-]*[a-z0-9])*@([a-z0-9]+[-.a-z0-9]*[a-z0-9]+\.){1,63}[a-z0-9]+$/i
    

    [效果查看](https://jex.im/regulex/#!flags=&re=%5E%5Ba-z0-9%5D%2B(%5B._-%5D*%5Ba-z0-9%5D)%40(%5Ba-z0-9%5D%2B%5B-.a-z0-9%5D%5Ba-z0-9%5D%2B%5C.)%7B1%2C63%7D%5Ba-z0-9%5D%2B%24

    注意: 邮箱的格式千奇百怪, 有的邮箱甚至还可以输入中文, 这个只能做参考, 还有一个简易版

    /^\w+?@\S+?\.[A-Za-z\d]{2,4}$/
    
  • 电话校验

    /^((\+\d{1,3}[- _]?\(?\d\)?[- _]?\d{1,5})|(\(?\d{2,6}\)?))[- _]?(\d{3,4})[- _]?(\d{4})(( x| ext)\d{1,5})?$/
    

    同事给的, 然后改 BUG 遇到一个有 _ 的奇葩电话号码, 就把 _ 也加进去了

  • 正则匹配非注释非console的中文

    /^(. )*?(?!.*(console\.\w+\(('|")|<!-- ?|/\* ?|// ?)).*[\u4e00-\u9fa5]/
    

    这个是全局搜索这类中文, 给网站做多语言用的

  • 转载: 正则表达式匹配"非",以及"非"字符串的匹配

    一般我就用的 (?!vue) 这种

  • 匹配符合要求的最短匹配字符 / 或者包括换行

    /(.*?)/
    
    // 再要加上换行和空格就是
    /([.\s\S]*?)/
    

    一般我用这个来替换, .* 任意长度的任意字符, ? 是不贪婪匹配, 只匹配自己要的, 打 () 是为了匹配, 在 webStorm 里用 $数字 替换, 蛮方便的

使用 flat 函数平铺复杂数组

flat函数

我的运用:

  1. 展开复杂数组(可指定展开到多少层数停止)
  2. 查看复杂数组中一共有多少项(会去掉空项)
  3. 获取指定层所有元素(如果有 undefined 注意去掉)
  4. 跟 slice(0) 一样 , flat(0) 复制数组(但是会去掉空项)

JS 异步

async await return new promise 笔记

使用 sortable.js 时固定列表顶部和底部(不能修改顺序)

原理是: 利用 sortable 配置的 onEnd 函数和 sortable 实例的 toArray 方法, 将不能移动的选项在移动完成后把它又放回到原来的位置;

代码:

endFunc(e) {
    // onEnd: endFunc
    if (e.newIndex !== e.oldIndex) {
        // 发生了移动
        let sortedArr = this.sortInstance.toArray(); // sortable 实例 toArray 方法, 导出顺序数组(移动后的顺序)
        sortedArr.splice(e.oldIndex, 0, ...sortedArr.splice(e.newIndex, 1)); // 按旧顺序重组数组
        if (e.newIndex >= this.boundEnd) { // 移动到底部 "不可移动选项" 之下           
            this.sortInstance.sort(sortedArr); // 按旧顺序重新排列
        } else {
            this.list_options.splice(e.newIndex, 0, ...this.list_options.splice(e.oldIndex, 1)); // 否则, 不管 sortable 实例, 将需要排序的数据(如果没有就不管)按新顺序排序
        }
    }
}

sortable.js 多层级排序互不影响

endFunc 中 e.newIndex 和 e.oldIndex 不是可排序的 dom 的顺序, 而是被移动的可排序 dom 在其父级 children 中的位置

echarts dataZoom 属性不生效解决办法

echarts 实例 options 对象的 dataZoom 属性修改之后, 先 echarts 实例 .clear() 清除, 再 .setOption 重新渲染

@功能的实现❌

背景:

客户

实现:

// @ 功能
at_user(name) {
    // 设置筛选用户 Select 搜索词为空
    console.log(name);
    console.log(this.$refs.filterUser);
    this.$refs.filterUser.setSelected(null);

    // 获取编辑框对象
    const dom_input = this.$refs.reply_textarea;
    // 获取输入框对象
    const sale = JSON.parse(name);
    const dom_at = document.createElement('span');
    dom_at.innerText = `@${sale.fullname}`;
    dom_at.contentEditable = false;
    dom_at.style['user-select'] = 'none';
    dom_at.setAttribute('userId', sale.id);
    // 编辑框设置焦点
    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('followupReply.browserError'));
        return false;
    }
    // 判断是否有最后光标对象存在
    if (this.temp_replyObj.lastEditRange) {
        // 存在最后光标对象,选定对象清除所有光标并添加最后光标还原之前的状态
        selection.removeAllRanges();
        selection.addRange(this.temp_replyObj.lastEditRange);
    }

    // 根据所在位置的不同以不同的方式插入结点
    if (selection.anchorNode == dom_input) {
        // 焦点就在文本框, 则直接 append node 到最后
        dom_input.appendChild(dom_at);
    } else if (selection.anchorNode.nodeName != '#text') {
        // 焦点在非文本结点, 则插入到焦点节点后面
        dom_input.insertBefore(dom_at, selection.anchorNode.nextSibling);
    } else {
        // 焦点在文本结点, 则在焦点位置文字后插入结点
        this.temp_replyObj.lastEditRange.insertNode(dom_at);
    }

    // 创建新的光标对象
    const range = document.createRange();
    // 光标对象的范围界定为新建的表情节点
    range.setStartAfter(dom_at);
    // 插入空格, 否则光标可能不显示
    dom_input.insertBefore(document.createTextNode(' '), dom_at.nextSibling);
    range.setStart(dom_at.nextSibling, 1);
    // 使光标开始和光标结束重叠
    range.collapse(true);
    // 清除选定对象的所有光标对象
    selection.removeAllRanges();
    // 插入新的光标对象
    selection.addRange(range);
    // 无论如何都要记录最后光标对象
    this.temp_replyObj.lastEditRange = selection.getRangeAt(0);
}

webpack 打包静态资源到 dll

我的用法是: 配合 webpack-bundle-analyzer 分析首页加载的 js , 把其中的部分静态资源分开到不同的文件, 减小单次请求体积(但相应地, 请求此数组增加了, 总大小是没变的);

打包产生的库文件可以放到 CDN , 加载更快;

之后其他页面引用被打包的静态资源时也是请求库文件, 而不是打包到自身的 js

具体转载自:

vue-cli3 DllPlugin 提取公用库

利用DllPlugin分割你的第三方库

scroll事件的capture和内存溢出, 节流和防抖❌

层叠上下文和z-index❌

Content-Disposition❌

七牛的 ?imageView2/0 可解

unbeforeunload的return null可执行之前的操作, 注意不要被覆盖了, 可以用addEventListener

踩坑:Notification 不显示

首先, 确定 windows 相关通知设置开了! windows 允许 chrome 浏览器通知, 浏览器允许该网站通知
之前发现 Chrome (版本: 81) 浏览器不显示 Notification , 且 Notification.requestPermisson() 也没弹出请求框, 最后定位, 发现原因如下:

  1. http 链接, 不允许通知
  2. 双显示屏 + 独立调试窗口 + 窗口最大化

第二点有点乱, 没总结规律

另外, 从张鑫旭大神的文章简单了解HTML5中的Web Notification桌面通知中, 找到了两个测试页面, 正常测试, https 测试, 如果客户始终不显示通知的话, 让客户点击这两个页面看看, 要还是不行的话那就没办法了

事件循环, 微任务, 宏任务

第 10 题:常见异步笔试题,请写出代码的运行结果

pointer-events: none 属性让鼠标无法选中元素

pointer-events

echarts 柱状图 label 自适应切换柱内柱外功能

背景: 柱状图数据中, label 默认显示在柱内, 当数据最大值和最小值相差过大时, 短柱放不下 label , 样式不美观

  1. 传入数据时, 传入 ${当前实际数据}.${数据最大值} (数据本身不能为小数)
  2. series.label.formatter 中, 拿到了之前出入的数据, 得到实际数据和数据最大值
  3. 设置 label.padding , top 为负数(自己试), 判断当前数据与数据最大值相差过大时(自己试), 把 formatter 返回的字符串加上一个换行 \n , 否则加上两个换行符, 这样一来, 正常柱显示在柱内, 短柱显示在柱外

隐藏的空格: unicode 8236 8237

excel隐藏的空间

UniCode 8236和8237 看着像空格(或者看不到), 但却不是空格, 可能导致错误

实际场景: 用户输入的数据未通过正则校验, 但是看起来完全符合要求, 最后复制该字符串逐个打印字符, 发现有一个"空格", 最终发现此问题

Object.freeze 让对象的属性无法变动

模拟下拉区域点击空白处消失解决方案

背景: 给客户网站上加入埋点表单, js 中请求后端接口生成留言表单给客户网站访客填写

问题: 表单中有些字段是多选字段, 之前代码使用输入框 + 浮层 div , 勾选浮层 div 中的选项后显示到输入框, 点击输入框 trigger() 浮层 div ; 现在产品要求改为点击空白处关闭浮层

方案:

  1. 给浮层 div 加上 tabIndex , 令其可 blur
  2. 给输入框, checkbox , 浮层都加上 onblur
  3. 在 onblur 中判断当前焦点是否还在这三者中, 可通过 document.activeElement , parentNode.contains 来判断
  4. 注意: 因为事件优先级问题, onblur 可能不奏效, 可以试试在 onblur() 最外层加入 setTimeout 0 来触发

jquery 同步请求: $.when 和 $.deferred

实例: jquery 通过 $.deferred 实现同步请求

场景: 保存云信聊天信息到自己的系统, 保存时后台会再确认一次接收消息客服是否还在线, 如果不在线则重新请求接口分配客服, 并转发该消息到新客服

// 定义变量
var serviceOffline = false;

// 如果发送云信消息成功
serviceOffline = $.deferred();

// 如果本地未取到云信存储的客服昵称等数据(用来保存到自己的系统), 则请求云信, 并在 getUser 的完成回调中调用保存方法
(function(dtd) {
	saveChatMsg(..., dtd);
})(serviceOffiline)

// 否则直接保存聊天信息, saveChatMsg 中 $.ajax 请求完成后, dtd.resolve(true/false)
// 调用 saveChatMsg 后, 直接使用 $.when 包裹后续代码, 在 then 中进行判断 resolve 的内容
$.when(serviceOffline).then(function(val) {
    if (val) {
        // 如果接收消息客服不在线, 重新请求客服并转发消息 --- resolve(true)
    } else {
        // 否则正常执行后续步骤 --- serviceOffline 不是 $.deferred() 或 resolve(false)
    }
});

手动让遮罩比最外层 z-index 更高

啊…忘了这个是啥了

推测: 这个是不是说, 遮罩显示时, 给最外层加一个 class , 调整它的 z-index , 使得遮罩能正常显示?

js 创建 style 标签并插入到页面

// 插入用户在表单设置里定义的样式 style
function insertXHLStyle(styleStr) {
    if (document.all) { // document.createStyleSheet(url)
        window.customStyle = styleStr;
        document.createStyleSheet("javascript:customStyle");
    } else { //document.createElement(style)
        var styleNode = document.createElement('style');
        styleNode.type = 'text/css';
        var styleTextNode = document.createTextNode(styleStr);
        styleNode.appendChild(styleTextNode);
        document.getElementsByTagName('HEAD').item(0).appendChild(styleNode);
    }
}

渲染进程❌

用"一个空格 + 一个 &nbsp;"正则替换两个空格, 避免全是 &nbsp; 无法显示换行效果

做翻译邮件内容功能时, 调用 google 翻译 api 翻译, 但 api 返回的结果会省略连续多个空格为 1 个, 于是想到, 把连续两个空格替换为 1 个空格和一个 &nbsp;

其他场景(比如渲染邮件内容)应该也能用到, 另外, 忘了为啥不把空格全部替换为 &nbsp; 了

调用 google 翻译 api 翻译邮件内容时, google 自动省略掉连续多个空格, 于是想到把内容正则处理一遍, 把两个空格替换为一个空格

onclick = null 和 on(‘click’) 和 addEventListener(‘click’) 的关系❌

阻止 chrome “保存并填写地址” 导致输入框聚焦时出现候选项

参考 禁止浏览器自动填充到表单 的这个回答

要求将不可见的input框放在页面的最前面,如body起始处, chrome之类的浏览器会填充最前面的输入框。
<!-- 阻止浏览器的自动填充 -->
<input type="text" name="_prevent_auto_complete_name" autocomplete="off" readonly="readonly" style="display: none !important;"/>
<input type="password" name="_prevent_auto_complete_pass" autocomplete="new-password" readonly="readonly" style="display: none !important;" />
chrome有两种填充, 一种是自动填充表单 autofill, 一种是自动完成密码 autocomplete; 请在需要的页面中进行设置。最新的问题,Chrome-72版本,将用户确定保存的用户名和密码强制填充到表单之中了,导致显示问题。以上代码实测生效。

另外, 放到 body 起始处无用的话, 可以放到表单起始处试试

ios new Date(str) 不支持 yyyy-mm-dd hh:mm:ss.ms,只支持 yyyy/mm/dd hh:mm:ss

echarts 注意清除画布

遇到一个 BUG , 水平条形图, 当前时间段筛选后得到三组数据, 换一个时间段筛选后只剩一组数据, 此时图标显示错误, 仍然显示三组数据, 其他两组全部是 NaN , undefined 之类的;

之后在 setOption 之前先 clear 一下就行了

微前端思想 - qiankun 框架

element.scrollIntoView(true) 滚动视图到指定位置, Drawer 内可用

git submodule 代码复用

scss: bem

https://segmentfault.com/a/1190000020655133

CSS 文字背景图片

https://www.zhangxinxu.com/wordpress/2020/10/text-as-css-background-image/

创建和触发自定义事件

https://developer.mozilla.org/zh-CN/docs/Web/Guide/Events/Creating_and_triggering_events

after 的 content 为中文冒号时显示成乱码 :

https://segmentfault.com/q/1010000005744881

https://www.html.cn/qa/css3/14944.html

Chrome 80 之后的版本, 跨域 set-cookie 失败, 发送请求时带不上 cookie

chrome浏览器中搜索下面地址:

chrome://flags/#same-site-by-default-cookies

chrome://flags/#cookies-without-same-site-must-be-secure

这两项设置为Disabled,并重启浏览器

补充:
现在不行了, 版本越往后限制越严, 还是下载免安装的老版本 chrome 浏览器(禁掉自动更新)调试吧

修改客户网站和聊天埋点冲突的方法

场景: 用户 paypal 埋点和聊天埋点冲突, 加上聊天埋点后就不能显示 paypal 按钮(不显示或显示后消失)

解决过程:

  1. 直接找到用户网站上报错的 js , 看了半天, 使用网站调试工具, 只确定该 js 在遇到报错时, 会接收错误消息, 报错并去掉按钮, 但不知道为啥聊天代码会导致报错, 剩下的也定位不下去了
  2. 想让用户把 paypal 埋点代码给我, 说给不了
  3. 想到变通方法 — 改 hosts 文件把聊天相关 js 的地址 lib.leadscloud.com 指向小机房; 逐块注释代码快速打包上传小机房, 再查看用户网站是否解决报错, 一句话"无法在本地自己的网站重现场景, 就改 hosts 直接在客户网站上测试"

错误原因: 最终发现是聊天代码中 util.js 的一句 Array.protoType.each , 注释后就没问题了

跨 Tab 页通信

Broadcast Channel API

​ https://developer.mozilla.org/zh-CN/docs/Web/API/Broadcast_Channel_API

window postMessage

websocket

storage event

​ window.addEventListener(‘storage’

SharedWorker

多个页面 websocket 接收消息重复提醒问题

背景: 网站登录成功后, 连接 websocket 接收消息, 网站可多标签页打开, 造成多个标签页都接受消息, 重复提醒

我接手时已经是方案一状态了, 随后改成二, 发现问题后改成三, 最终方案为三

方案一:

  1. 每个页面登录成功后, 存储时间戳到页面程序时, 同时也存储到 localStorage (数组, JSON 形式)中, 退出登录, 出错, 重连, 页面关闭/刷新(beforeUnload) 时, 从 localStorage 中去掉该时间戳
  2. 页面接受到消息时, 判断当前页面时间戳是不是 localStorage 时间戳中的最大值, 是的话才提醒
  3. 弊端: localStorage 不会主动消失, 某些情况下时间戳最大的页面意外关闭后, localStorage 中仍存在值(遇到过, 但是没找到原因), 导致无提醒

方案二:

  1. 使用 cookie 替代 localStorage , 设置 cookie 自动过期, 随后使用 setInterval 为 cookie 定时延长有效期, 一旦页面关闭, cookie 过期自动消失
  2. 弊端:
    1. setInterval 不一定准时执行, 可能导致某一时刻 cookie 中数据为空, 不提醒
    2. 页面在后台时, 一定时间后 chrome 会停止/延缓执行 setInterval , 导致数据为空, 不提醒
  3. 解决方案
    1. 网上搜索, 说可以用 web worker 让 setInterval 不休眠, 但是用了它我就不需要用 cookie 和 setInterval 了, 之前就是考虑到兼容性才不用它的
    2. 或者一些别的 hack (有人说播放一个空声音可以让 setInterval 不休眠)
    3. 最终决定用方案三

方案三:

  1. 参考 一种简单无副作用的同源跨页面数据同步方案 , 使用 sessionStorage 存储数据, 使用 localStorage 和 storage 监听来同步数据到 sessionStorage

后续:

方案三还是不行, sessionStorage 同步其他标签页的数据, 需要一定时间, 而 websocket 连接更快, 有个需求是 websocket 导致 websocket onopen 检测当前标签页数组时, 只能检查到当前标签页

于是改为延时 5s 再去检测标签页数组, 如果检测到数组当前标签页是标签页数组最小时间戳, 则发送 websocket 消息踢掉其他端; 但是, 如果用户点的块, 快速打开两个窗口, 这样会导致第二个窗口被踢掉, 等于还是有问题

iframe 渲染请求到的 html (邮件预览), 避免样式污染

背景: 公司之前弄了邮件系统, 但显示邮件内容时是直接 v-html , 导致邮件内容和项目样式互相污染; 之前同事改的是去掉邮件内容的样式文件, 结果导致部分内容显示错位, 现在想不改邮件内容, 用 iframe 包裹邮件内容显示

思路:

需要解决几个难点

  1. iframe 宽高如何随内容变化而变化
  2. iframe 如何与父级通讯
  3. 如何将不同类型的返回内容渲染成 html 放到 iframe 中

解决:

代码以 Vue 形式写的, 下面的代码是最终代码(iframe 渲染 + 自定义水平固定滚动条 + 打印 + 其他删除功能)拆分而来, 可能有些错漏/未删除变量

  1. 新建一个空白 HTML 页面, mailDetail.html , 只有最基本的 html 格式, 无任何内容

  2. 详情页新增 iframe 框, 引用此 html , 但先设置 height=“0” , 不显示内容

    <!-- publicPath: process.env.BASE_URL -->
    <!-- mailFrameName: 'mailContentFrame' -->
    <iframe :src="`${publicPath}mailDetail.html`"
            :name="mailFrameName"
            width="100%"
            height="0"
            style="border: none;"></iframe>
    
  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 = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
      
          // 返回最终的 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容器 -->
    <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(`<html><head><style media="print">* {word-wrap: break-word; word-break: break-word;}ul li {font-size: 12px;line-height: 18px;font-weight: 400; list-style-type:none;}</style></head><body οnlοad="window.print()">${containerNode.innerHTML}</body></html>`, '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

    <!-- 邮件详情 iframe -->
    
    <!-- 自定义滚动条 -->
    <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

    <!-- Start twemoji 库文件 -->
    <script src="https://twemoji.maxcdn.com/v/13.1.0/twemoji.min.js" integrity="sha384-gPMUf7aEYa6qc3MgqTrigJqf4gzeO6v11iPCKv+AP2S4iWRWCoWyiR+Z7rWHM/hU" crossorigin="anonymous"></script>
    <!-- End twemoji 库文件 -->
    
  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

    <!-- 因为正常 textarea 无法显示 emoji img , 现在将输入框改为 contentEditable 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 或 <br>)无效的解决办法

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

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

示例代码:

// 如果输入框末尾没有 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 浏览器第一次点击按钮/链接无反应

背景:

页面上有两个 <a href="javascript:;"> 元素, 一个 onclick="xxx()" , 一个 $('xxx').on('click', xxx)

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

解决思路:

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

  1. 看能否电脑调试 ios

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

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

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

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

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

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

    1. 发现了上述的第二个, 用 jQuery 绑定点击事件的也有这个问题. 这个的内容是客户自己写的, 跟我们没关系, 确定不是我们代码的问题了
    2. 想了下, 页面上绑定有点击相关动作的元素应该有不少, 其他的有没有这个问题? 继续排查
    3. 发现大部分按钮正常, 有个 <button onclick="xxx" 元素是正常触发的, 有两个 <p href="javascript:;" onclic="xxx"> 元素有问题, 于是怀疑是 href 的问题, 而且 p 上写这个也不合理, 转告给客户让他们先去掉这个试试, 如果还不行, 把 p 改成 button , 复刻没问题的 button 的代码结构, 看看是否还存在问题
  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(‘’) 加载百度地图埋点代码

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值