分类
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 手机上没有问题。

分类
js

在Chrome 扩展中使用 Handlebars

Chrome 扩展可以访问到各种用户敏感数据,比如 cookie 之类,所以 Chrome 团队对它的限制非常严格(见 Using eval in Chrome Extensions. Safely.),比如常规环境下完全不能使用 evalnew Function()。这给我们使用 Handlebars 之类的模板工具带来不小的麻烦。

一个解决方案就是使用上文中介绍的 sandbox。将可能使用到 eval 的代码放到一个独立沙盒中,不让它访问到那些敏感信息;然后通过 postMessage API 与之进行数据通讯,完成模板生成工作。

我觉得这个方案不太理想。首先我不喜欢 <iframe>;其次每次渲染模板还要 post 来 post 去,渲染结果也是异步传递(这点暂时存疑)。

另一套方案,则是利用 Handlebars 的预编译功能,将模板在开发环境中编译成函数,在扩展中直接使用。这样做的好处也很明显,因为发布插件时也会先处理代码,这个时候将模板处理完,工作效率更高。

等写完再补充具体代码吧。

分类
js

Chrome表单验证和keyup导致的灵异问题

先说下环境,Mac OS El Capitan + Chrome 46,框架是Backbone + Boostrap。

我做了一个自动搜索组件,独立测试时一切正常,放到产品中,别的都没问题,只有回车会出问题。代码在这里

不用说,这个问题很诡异,调试了半天没有头绪,只能通过观察现象去推测:

  1. 没有报错
  2. 除了回车,其它功能正常
  3. 除了回车,keyUpHandler都能被正确触发
  4. 回车后,光标跳到其它元素

看来看去,第4条最可疑——我按的是回车,它会什么会跳到别的单元格呢?

这次运气比较好,表单中的接下来的几个元素刚好是日期,用到Bootstrap Datetimepicker这个插件,focus之后会自动填充日期。于是我发现,每次跳到的“其它元素”,都是原先空白的,而且是required的;不会跳到固定的,或者紧挨着的那个文本框。

于是我便想,会不会是:

  1. 表单submitkeydown之后触发
  2. 触发之后进行表单验证,发现有未填的required元素,于是跳到该元素并提示
  3. Bootstrap Datetimepicker响应focus时间,填入日期,提示消失
  4. 我的Typeahead响应blur事件,隐藏列表
  5. keyup事件触发,但是输入框已没有焦点,就没有触发

这个推测看起来有点道理,于是我把侦听的事件改成keydown,问题果然解决。