分类
puppeteer

移除 Puppeteer 里的保存密码提示窗

大家知道,当我们使用 Chrome 完成登录的时候,Chrome 会询问我们是否要保存密码,如下图所示:

图一 询问是否保存密码的弹窗

但是在录制视频的时候,这个弹窗就不太必要了,甚至有些干扰,所以我们就想把它移除。如果搜索“puppteer prevent save password modal”,多半会被引导到这个页面,并得到结果:

--enable-automation 添加到启动标记里。

对应 Puppeteer 就是 puppeteer.launch(options) 里的 args,默认已经启用。但是使用这个选项会提示“Chrome is being controlled by automated test software.”,对录视频来说也不够理想。所以只能另辟蹊径,再找别的方法。

图二 标记浏览器被自动测试软件控制

后来我发现在 设置 > 密码(chrome://settings/passwords)里,可以关闭“提示保存密码”的选项,就不会再弹窗了。那么,我们只要修改浏览器的默认设置,即可。

我的第一反应是用 puppeteer 打开这个页面,然后修改配置。尝试之后发现不行,Puppeteer 不能打开非 http/https/file 协议的页面。接下来尝试修改 userDataDir 目录里的内容,我发现,如果使用 headless 即无窗口模式启动 puppeteer,生成的 user data 目录里的内容就很少,尤其没有 Default/Preferences 这个 json 文件,而后者才是保存浏览器设置的位置。

所以正确的解法就是:

  1. 先用 headless: false 模式启动一次 puppeteer,获得全是默认值的 userDataDir
  2. 修改 userDataDir/Default/Preferences 文件,在根对象上添加 "credentials_enable_service": false,不保存密码
  3. 启动新的 puppeteer 录制视频前,先复制 userDataDir 到目标位置
  4. puppeteer.launch({ignoreDefaultArgs: ['--enable-automation'], userDataDir: '/path/to/userDataDir'}) 启动窗口时禁用 --enable-automation 选项,避免出现图二的提示条,并且使用刚才复制的 userDataDir 保存缓存等信息

目前看来,这套解决方案最合适,希望对大家有帮助。

分类
扩展

Chrome 扩展大升级 Manifest V3:变化

上一篇博客 聊了聊 Manifest V3 的设计思路,接下来就该详细介绍下这个版本的变化。按照 官方介绍,V3 的变化主要有以下五点:

  1. 需使用 Service worker 替换 background scripts/pages。这个变化对我影响很大,未来开发新扩展的时候,需要使用全新的代码架构;老扩展要升级,多半也要彻底重构。
  2. 修改网络请求的 API 换作 chrome.declarativeNetRequest。这个变化我暂时没用到,将来有机会再说。
  3. 禁止运行远程代码,所有将会运行的代码都必须事先打包进扩展,接受商店的审查。这点对我厂的影响非常大,很可能我厂的 Showman 要彻底改变实现方式——最大的可能就是停留在 Manifest V2,如果 Manifest V2 被废弃,那就使用新技术栈开发下一代产品。
  4. API 全部提供 Promise。这是应该的,小改善。
  5. 其它一大批杂七杂八的功能变化。

其中 2 暂时不好评价,3 只能被动接受,4、5没啥好说的,所以接下来谈谈 1,background script => service worker。

Background Scripts/Pages

Background scripts/pages(后面简称 background script)的地位很重要,是浏览器扩展的重要组成部分,可以作为联系其它组件的中心、主控,在扩展功能复杂时,作用很大。比如我厂的 Showman,既需要用 content script 注入功能到目标网页,也需要单独打开页面让用户交互,这里每个 JS 都要跑在独立环境,彼此隔离,所以就需要有一个中控,一方面连接各个独立的组件,另一方面常驻内存存储一些全局数据。

相信大家不难想到,这种常驻内存的东西,虽然给开发带来了便利,但是也会给内存带来不小的压力。况且,在 Manifest V2 阶段,甚至还支持一个浏览器扩展注册多个 background scripts 和 background pages。每个页面和脚本都要独享一个环境,哪怕只有短短几行代码,于是就会吃掉大量的内存。

Chrome 对内存使用方面的不检点,被大家诟病已久。其开发团队当然也知道,所以最近几个版本 Chrome 都在试图改进这些问题。既然新版本要降低系统要求提高性能表现,尽量节省内存、同时减少运行时间就是必须考虑的事情。所以,新版本扩展规范规定,background script 有且只有一个,而且只能是 service worker。

Service worker 只能注册事件侦听器,不能持续运行。这样一来,就可以让 background script 的执行之间降到最低,并且随需而动,减少内存占用。

Service worker 的特殊用法

1. 事件侦听器都要放在顶层

事件侦听器都要放在顶层,非顶层的事件侦听器会被直接忽略。

2. 使用 storage API 存储持久化状态

不要用全局变量,把一些需要用到的数据都放到 storage 里,如下:

// 不要这样做
const foo = 'bar'
chrome.runtime.onMessage.addListener(({ type, name }) => {
  if (type === "set-name") {
    name = foo;
  }
});

// 这样做
chrome.runtime.onMessage.addListener(({ type, name }) => {
  const foo = await chrome.storage.local.get(['name']);
  if (type === "set-name") {
    name = foo.name || 'bar';
  }
});

3. 使用 chrome.alarms 取代定时器

我们以前一般习惯于用 setTimeoutsetInterval 定时执行,但它们在 service worker 里都会失效。此时,要用专门的 Alarms API 代替,使用方法倒也不难:

chrome.alarms.create({ delayInMinutes: 3 });
chrome.alarms.onAlarm.addListener(() => {
  // do something
});

需要注意的是,content script 里虽然仍支持 setTimeout,但是太长的定时器会被直接忽略掉,时间阈值是 13s,即短于这个时间的仍然会触发,但是超过的会被忽略。建议如果 content script 需要定时器,那么也交给 background script 来做。

4. 其它一些常见场景的处理

包括解析 HTML/XML、处理音视频播放、使用 <canvas> 绘图等,因为我暂时都没用到,所以就先不说了。

分类
js

使用 File System Access API 在浏览器里操作本地文件

如《Webpack 5 发布,Chrome 86 开始支持本地文件系统》一文所述,Chrome 86 开始,浏览器正式支持操作本地文件。接下来结合最近的使用,分享下用法。

0. 准备工作:理清概念

首先,我们要先搞清楚一些概念。实际上,让浏览器操作本地文件是开发者一直在努力并且不停在探索的方向,所以历史上有很多方案,存在很多类似但其实并不一样的 API,大家在学习的时候一定要搞清楚,不要弄混。

最早登场的是 File API,代表功能是 FileReader(参考:《使用 Promise 封装 FileReader》)。这个 API 最大的进步在于,我们可以在浏览器里读取和操作二进制文件,然后通过 <a download="file.ext"> 下载到本地。如此,浏览器作为工具平台的价值大大提高。

接下来,激进的 Google Chrome 提出并实现了 File System API。这个 API 试图在浏览器里创建一个独立的文件环境,让开发者可以在里面任意操作文件和目录,如果能做好,那么是一个非常好的抽象。可惜步子不仅大、而且偏,最终失败。我总结原因有二:

  • 一方面“独立的文件环境”,即无法操作系统本地文件,那么其实没什么价值……
  • 另外,当时浏览器的其它限制没有突破——没有包管理、没有 babel、甚至没有 Promise,IE 仍然大量存在,开发难度极大。

所以最终这套方案死得悄无声息。《HTML5的File API应用》,这篇博客可能是为数不多的中文分享。

接下来是 Chrome Extension、Chrome App、Chrome OS 里的 File (System) API。这几个产品都是 Google 私有,不用考虑其他浏览器厂商,所以可以放开手脚随便搞。这里大家需要注意的是,因为 Google 的产品策略一向是说关就关,所以大家要留心常看文档,别学了一半 API 没了,比如:Extension 的 chrome.fileSystem 就已经弃用了

最后,也就是今天的主角,File System Access API。这套方案应该是未来的主角。它提供了比较稳妥的本地文件交互模式,即保证了实用价值,又保障了用户的数据安全,明显是前辈 File System API 的继任者。

它的设计思路也不复杂:

  1. 要求用户手动选择文件或者目录,以获取文件或目录的控制权限
  2. 选择文件或目录后,获取到 FileHandle,后续的操作经由它来进行
  3. FileHandleserializable 对象,所以可以通过序列化和反序列化实现跨 session 的存储(即刷新后还能用)

好,下面看代码。

1. 读取本地文件

这段代码可以比较完整的演示 window.showOpenFilePicker API 的用法:

// 使用 `try...catch` 可以捕获用户取消选择时抛出的错误,如果你对错误不在意,不捕获也行
try {
  const [handle] = await showOpenFilePicker({
    multiple: false, // 只选择一个文件
    types: [
      {
        description: 'Navlang Files',
        accept: {
          'text/x-navlang': '.nav',
        },
      },
    ],
    excludeAcceptAllOption: true,
  });
} catch (e) {
  if (e.message.indexOf('The user aborted a request') === -1) {
    console.error(e);
    return;
  }
}

// 如果没有选择文件,就不需要继续执行了
if (!handle) {
  return;
}

// 这里的 options 用来声明对文件的权限,能否写入
const options = {
  writable: true,
  mode: 'readwrite',
};
// 然后向用户要求权限
if ((await handle.queryPermission(options)) !== 'granted'
  && (await handle.requestPermission(options)) !== 'granted') {
  alert('Please grant permissions to read & write this file.');
  return;
}

// 前面获取的是 FileHandle,需要转换 File 才能用
const file = await handle.getFile();
// 接下来,`file` 就是普通 File 实例,你想怎么处理都可以,比如,获取文本内容
const code = await file.text();

2. 保存本地文件

前面说过,FileHandle 可以序列化,也即可以进行持久化存储。所以我们只需要把对应的 FileHandle 存下来,然后保存即可。

if (data.file) {
  const writable = await data.file.createWritable();
  await writable.write(data.code);
  await writable.close();
}

如果之前没有获取过 FileHandle,则可以通过 window.showSaveFilePicker 来获取:

try {
  const file = await showSaveFilePicker(filePickerOptions);
} catch (e) {
  if (e.message.indexOf('The user aborted a request.') === -1) {
    console.error(e);
  }
  return;
}
// 然后接前面的代码
const writable = await file.createWritable();
await writable.write(data.code);
await writable.close();

这个功能现在有一点小问题,不知道是不是 Chrome 实现不太稳定,如果你打开开发者工具,然后钩上“Pause on caught exceptions”,那么保存时会暂停数次,并提示错误。不用理会,直接继续执行即可。我猜测这个过程本来应该由浏览器自动捕获并重试,直到超时保护或者写入成功,但是现在会错误地抛出来。

3. 总结

File System Access API 不仅可以操作文件,还可以操作目录,操作目录的方式和文件相仿,我就不详细举例了,大家可以看下后面的参考链接,或者等我用到目录、踩了坑再来分享。

这个 API 对前端来说意义不小。有了这个功能,Web 可以提供更完整的功能链路,从打开、到编辑、到保存,一套到底。虽然目前只有 Chrome 支持,但还是建议大家尽快把它用起来。


参考链接:

分类
chrome

Chrome 扩展里实现 SSO

周五打算给客户发版,结果在这里卡了大半天,写篇博客记录下。

0. SSO 的实现

SSO,Single sign-on,单点登录,即统一处理用户登录、提供用户身份凭据的功能。使用 SSO,可以只维护一套用户体系,容易开发维护;对用户来说,只需要登录一次就能使用该开发商的全部产品,也很轻松方便。

一般来说,SSO 的流程是:

  1. 用户使用 A产品,域名是 pa.mydomain.com,登录服务(S)位于 login.mydomain.com
  2. 用户使用提供服务的 A产品,A产品需要登录,用户选择登录
  3. 来到登录服务,完成登录
  4. S服务将用户指回 A产品,返回的 URL 里包含一个 token
  5. A产品拿到 token,请求 S服务,验证 token,获取部分用户信息(比如邮箱,一般只用来展示
  6. A产品生成自己所需的身份凭据,并以此验证用户身份

我厂的产品也是这么实现的。

1. Chrome 扩展遇到的问题

本地调试一切正常,但是加载成扩展之后,从登录服务跳回扩展会遇到 ERR_BLOCKED_BY_CLIENT 错误,URL 也被重定向到 chrome://invalid/。我在这里卡了很久,主要是不知道该怎么定位问题和搜索答案。

后来经过反复尝试,我终于发现,只有从登录页面跳转回去插件页面的时候,即 location.href='chrome-extension://{id}/ui/index.html 的时候,才会报错,所以立刻换用 chrome extension href ERR_BLOCKED_BY_CLIENT 作为关键词,立刻找到了答案:redirect to chrome-extension:// results in ERR_BLOCKED_BY_CLIENT

然后阅读文档:Manifest – Web Accessible Resources(可由 Web 访问的资源),得知需要在 manifest.json 里添加对应的配置:

{
  ...  "web_accessible_resources": [
    "ui/index.html"
  ],
  ...
}

添加后 SSO 就正常了。

2. 后记

不过我没想明白的是,这个配置意义何在?配置写在扩展里,防止 web 访问扩展里的文件,似乎并没有什么帮助,也没什么安全性的顾虑。也许是我还没遇到吧。

分类
chrome

让 Chrome API 支持 Promise

Chrome API 都是回调型,连续使用非常不方便,希望能改成 Promise 型。Chrome 本身不提供 promisify,不过可以自己写一个:

export default function promisify(original) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      original(...args, (...results) => {
        if (chrome.runtime.lastError) {
          reject(chrome.runtime.lastError.message);
        } else {
          resolve(...results);
        }
      });
    });
  }
}

这里有几个注意事项:

  1. 参数使用 ...args 进行拆解和合并,方便调用
  2. 需要检查 runtime.lastError,不然出错的时候浏览器会报错,影响体验

使用的时候,我比较喜欢只修改需要使用的函数,不打算 promisify 全部函数,大概是这样:

import promisify from './index';

/* global chrome */

export const update = promisify(chrome.tabs.update);
export const remove = promisify(chrome.tabs.remove);
export const get = promisify(chrome.tabs.get);
// 我觉得 `close tab` 看起来更合理
export const close = remove;
// 封装一个 `goto` 的快捷方式
export const goto = function (tabId, url) {
  return update(tabId, {url});
}
// 全部导入,好处是简单,坏处是不方便 tree-shaking
import * as ChromeTabs from '../chrome-promisify/chrome.tabs';

ChromeTabs.goto(tabId, 'https://blog.meathill.com');

// 需要哪个导入哪个
import {goto} from '../chrome-promisify/chrome.tabs';

goto(tabId, 'https://github.com/meathill');
分类
前端

Webpack 5 发布,Chrome 86 开始支持本地文件系统

早上起来日常刷技术新闻,看到两个对我和我厂影响比较大的消息,简单写几句。

Webpack 5 发布

Webpack 5 于今天(北京时间10月11日,美国时间10月10日)发布。这是个大版本更新,会有很多破坏性变更。所以不要轻易升级,否则可能遭遇各种问题。不过,对于像我这种三天两头主动升级依赖的人来说,是否可以平滑升级还未为可知。

这次的升级主要有以下几点变化:

  • 使用持久缓存提升性能
  • 通过替换更好的算法和缺省值,提升长期缓存的效果
  • 使用更好的 Tree Shaking 算法和代码生成方式,减少打包后的提及
  • 提升 web 平台的兼容性
  • 在不引入破坏性变更的前提下,清理掉为实现 v4 功能而遗留下的奇怪的内部架构
  • 现在的破坏性变更是为将来实现更多的功能打好基础,让我们可以在 v5 版停留尽可能长的时间
  • (我加的)多页面 css 合并的时候,不再需要 all.js

更详细的内容大家请移步官网了解:Webpack 5 release (2020-10-10)。英语苦手的同学稍微等两天,估计中译版和各种解读版也会很快问世。

我只有两个建议:

  1. 尽快升级你的项目到 webpack 5
  2. 不要再学/用以前的版本了

Chrome 86 开始支持本地文件系统

很久很久以前,我在上一家公司做创业项目肉大师(Web 创作工具),不小心踏入了 FileSystem API 这个大坑。还留下一篇长文《HTML5的File API应用》,可能是为数不多的中文资料。

时隔八年,如今文件系统 API 终于有了比较靠谱的实现,并且被 Chrome 正式支持。一般来说,Chrome 的统治地位会帮助这个 API 成为事实标准,所以如果你对操作本地文件有需求,那么就可以开始使用这个 API 了,将来它会慢慢普及到其它浏览器上。

简单来说,这个 API 允许用户选择若干文件或者目录,相当于用户主动授权某些文件或目录给当前网站,然后 JS 就可以从文件里读内容,或者把内容写入文件。当然,从安全角度出发,网页不能任意访问文件,一定要用户主动选择。

对于我厂来说,这意味着 QA 产品可以更容易的编辑、保存文件,可以大大提升用户体验。一些 Web 工具也可以直接保存内容到用户本地,感觉网页生态更强大了。

更详细的内容可以阅读这两篇文章:

总结

学无止境,勤为径苦作舟吧。

分类
html

解决诡异的 Chrome 自动完成问题

上周末客户突然报告了一个奇怪的 bug:删除用户时,会自动进入搜索结果页。我在我本地试了一下,100% 复现,相当诡异。经过仔细观察,我发现点击删除用户的按钮时,地址栏会发生变化,在新的路由下,会搜索当前用户:

删除用户时的截图

反复重试之后,我认为:是浏览器自动完成的锅。因为删除用户的操作非常敏感,所以我们要求管理员必须输入密码作为校验。而这个界面与常见的用户登录界面非常相像,所以浏览器自作聪明,找了一个 它认为是 用户名的地方填入了用户名,而这个文本框刚好是全局搜索的搜索框:

如图可见搜索框的位置

当其中的内容发生变化时(也即触发了 change 事件),就会跳转到 /search?{key}={query},然后开始搜索。

问题找到后,接下来进入修问题时间。由于是浏览器的锅,Web 前端难以直接触及,所以很难修。经过反复艰难的 Google,终于在这个页面找到了答案:https://support.google.com/chrome/thread/9498749?hl=en(3楼),原来 autocomplete 不止有 onoff 两个可选值,还可以是 usernameemail、或 password,等。(详见 MDN The HTML autocomplete attribute。)于是我尝试给真正的 username 输入框加上 autocomplete 属性,果然问题解决了。

看来以后有时间得再研究一下 autocomplete 属性了,说不定能解决之前为了防止自动补全而做的各种 hack。

分类
chrome

Chrome Devtool Protocol 开发笔记

周末动笔写教程,越写越长暂时收不了笔;周日发现上一台 iMac 回收被坑了,显示器从烧屏变闪屏了,心情大坏,简直写不动了,哎……

Chrome Devtool Protocol(下面简称 CDP)是一个非常强大工具,简单来说,它可以揭开束缚 Chrome 的各种封印,从浏览器角度深入页面(及其它领域,包括 worker),完成一些平日里难以完成的操作。

我目前研究它主要是想优化我厂官网的首屏 CSS,顺便为将来 QA 插件的深入开发做准备。

不过 CDP 的文档、资料各种不全,Google 也没什么结果,所以我会把日常踩到的坑记到这里,以备回顾。

分类
分享 技术

Chat idea:记一次 Firefox 下 Vue 带来的性能危机的解决

前两天遇到一个问题:我厂的一个产品在 Firefox 下,可能发生因为 CPU 占用过高而卡死的情况。这个问题在测试环境不复现,在 Chrome下基本上也不会复现。

因为我厂老板是这个产品的主要用户,这个 bug 让我倍感压力,但一直没有解决它的好办法。终于有一天,在某台生产机上调试另外一个 bug 的时候,我终于发现了稳定复现这个 bug 的方式。

接下来就是几个小时的 debug,然后发现问题所在,然后解决问题,然后发现解决方案不理想,于是寻求新的解决方案,然后找到新的 API,最终彻底的解决这个问题。

接下来我就分享这个过程,读完整篇文章当中你将学会:

  1. 使用开发者工具查找性能问题
  2. 不断切分,缩小问题范围
  3. 理解 Vue 响应式原理分析问题根源
  4. 修复问题并验证
  5. 新的解决方案 Intersection Observer
  6. 解决问题并上线

目标读者:

  1. 中级开发者
  2. 熟悉原生 JS

大家觉得这个 idea 如何?请留言告诉我。不出意外的话这将是我下个月的 gitchat 内容。


有同学在 Drift 里留言,这里更新回答一下:这篇文章最后没写,因为当时 GitChat 的编辑不感兴趣,所以就放弃了。将来有机会会写。

分类
js

Safari 下 Date 不支持”2018-01-01 00:00:00“

前两天发现一个小程序的问题,Android 正常,iPhone 出错。我们都知道,Debug 的关键在定位,如果是某些特殊环节,不常见的错误,就会浪费很多时间。

这个 Bug 也是如此,反复拉锯之后终于发现,问题出在下面这句:

let a = new Date(`${date} 00:00:00`);

date 是服务器端返回的值,我是把它和后面的 00:00:00 连起来,记作某天零点零刻,和今天的零点零刻做减法,计算日期差,并按照日期差来决定接下来的逻辑。这段代码在开发工具(包括 Mac)、Android 手机上运行都正常,只有在 iPhone 上不正常,于是我打开 Safari——苹果这点做得不错,桌面版 Safari 环境和 iOS 几乎没有差别,该出的问题一定会出——果然复现了这个问题。

按照规范,中国的日期格式是:“2018/01/01”,Safari 只支持这个格式。而 2018-01-012018-01-01T00:00:00 ISO 格式,Safari 也支持,但是会以格林威治时间为准,和我们有8小时的时差。Chrome 和 Android 内嵌的 WebView(基于 Blink 或者 Webkit)则都支持,所以在本地和 Android 手机上没有问题。