我开发了个:所有数据只保存在 localStorage 的实用备忘录

5,833 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第28天,点击查看活动详情

背景

我时常把 Sublime Text 当做本地的备忘录,临时存一些文本、代码、思路等,非常方便。

特点在于:

  • 信息只保存本地,非常安全。
  • 响应速度非常快。
  • 编辑器好用,支持搜索、正则替换、多光标模式。
  • 自动保存,退出后,未主动保存的文件也会存下来。

但是偶尔它会弹窗提示付费,而且有一次我没保存的内容差点丢了(因为进程崩溃原因),结果是搜了教程,才花费九牛二虎之力在某个神秘文件夹里找到。

所以我就想,能否做个网页版的类似的工具呢?

我找到了 Ace,一个Web编辑器,我可以用它实现一个 网页版 Sublime Text。

image.png

image.png

理想效果

我期望这个「网页版 Sublime Text」有跟 Sublime Text 一样的特点:

  • 信息保存本地,安全。
  • 响应速度快。
  • 编辑器好用,支持搜索、正则替换、多光标模式。
  • 自动保存,退出后,未主动保存的文件也会存下来。

image.png

开发难点

  1. 备忘录肯定不能只有1个tab(文件),我需要多个文件。既然这样,还需要支持新建、修改、删除、重命名。
  2. 信息都存在本地,需要好好规划localStorage。
  3. 存储的信息不止是内容纯文本,还需要把光标位置存下来,更方便。

localStorage 规划

详见图:

image.png

所有的key都有个前缀memo-,这是因为我很多工具都部署在同一个域名 tool.hullqin.cn 下,所以需要前缀区分不同网页的存储。

有个memo-meta存储多个文件的信息,包括id、文件名、创建时间。

memo-{id}存储文件的文本。memo-{id}-c存储文件的光标位置。

如何保证 localStorage 有固定前缀

如果每次靠开发者自觉加前缀,是有风险的,指不定以后的哪天就忘了。所以需要一种方法,自动加固定前缀。

方法一:封装函数getItemsetItem,这两个函数自动调用localStorage.getItemlocalStorage.setItem,并在调用时加前缀。 方法二:全局修改localStorage.getItemlocalStorage.setItem函数,自动加前缀。

其中方法二更好。因为方法一还是给开发者提出了更高的要求:你必须调用getItemsetItem而不能调用localStorage.getItemlocalStorage.setItem。一旦开发者调用了后者,还是可能出错。

方法二的实现可以参考文章《火爆全网的 Evil.js 源码解读》,提到了修改方式:

const PREFIX = 'memo-';
const _getItem = window.localStorage.getItem;
const _setItem = window.localStorage.setItem;
const _removeItem = window.localStorage.removeItem;
window.localStorage.getItem = function (key) {
  return _getItem.call(window.localStorage, PREFIX + key);
}
window.localStorage.setItem = function (key, value) {
  return _setItem.call(window.localStorage, PREFIX + key, value);
}
window.localStorage.removeItem = function (key) {
  return _removeItem.call(window.localStorage, PREFIX + key);
}

如何实现左侧目录

我没有用 React 和 Vue,主要是这个函数:

const renderLists = (() => {
  const titlesElement = document.getElementById('titles');
  return () => {
    const meta = getMeta();
    titlesElement.innerHTML = meta.list.map(item => {
      return `<div class="title${current === item.id ? ' active' : ''}"><button class="delete" onclick="deleteMemo(${item.id})">×</button><div id="title-${item.id}" ${current === item.id ? 'contenteditable oninput="debouncedOnInput(' + item.id + ')"' : 'onclick="changeMemo(' + item.id + ')"'}>${item.title || 'untitled'}</div></div>`;
    }).join('');
  };
})();

我懒得每次设置innerHTML后再addEventListener,所以我直接用了内联的onclick

另外,由于需要修改标题,我用了contenteditable属性和oninput事件。

并且为了避免oninput频繁触发,我使用了debouncedOnInput做防抖。

这样,每次玩家修改标题,都会调用debouncedOnInput来修改 localStorage 中的 meta 信息。

关于contenteditable

引用下这个不错的回答:

image.png

意思是说,如果你想监听contenteditable元素的onchange事件,其实是无效的,你只能监听oninput事件。

我挺喜欢这个原生的编辑属性的。如果不用,在 React 或 Vue 中可以控制状态来渲染inputdiv。但是如果希望用原生JS写一点简单的东西,那么状态控制是需要避免的事情,会让代码变得更多更复杂,而 contenteditable 刚好就解决了这个复杂的问题。

目录分割线样式

参考文章《我又来帮掘金修专栏bug了,顺便教你个超牛逼的分割线CSS!》

监听内容改动

Ace 暴露了一些事件,我们可以监听。例如:change表示内容变化。所以我需要监听这个事件,内容变化时,把最新内容存入 localStorage。

但是光标改变时,其实也需要存入 localStorage。Ace 并没有暴露光标改变相关事件,只有mouseup事件可以参考。另外还需要监听方向键,所以我自己给dom元素添加了keyup事件。

由于事件监听了这么多,会频繁触发,所以需要防抖,频繁触发的,只触发一次就好。

editor.addEventListener('change', debouncedOnChange);
editor.addEventListener('mouseup', debouncedOnChange);
editor.container.addEventListener('keyup', debouncedOnChange);

使用地址 & 源码

使用地址:tool.hullqin.cn/memo.html

源码:github.com/HullQin

备注:目前源码主要是为了实现功能,晚上熬夜写出来的,没有经过设计,所以会偏过程化。以后需要加功能时,我再来优化。

写在最后

我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,联系我,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋象棋等游戏,不收费无广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这2个专栏里分享:《教你做小游戏》《极致用户体验》