robin

robin 查看完整档案

杭州编辑兰州工业学院  |  计算机网络技术 编辑SegmentFault  |  前端 编辑 blog.rnode.me 编辑
编辑

前端开发一枚,做过RN、小程序。

个人动态

robin 关注了用户 · 3月4日

CrazyCodes @crazycodes

I am CrazyCodes,生命不息,编码不止。

GitHub : CrazyCodes

CSDN : CrazyCodes

掘金 : GraceDevelopment

不止于技术的微信公众号 : phpznb

与我一起传递技术正能量!

关注 4737

robin 收藏了文章 · 3月4日

有道写作浏览器扩展实践

有道写作浏览器扩展作为一款为网页增加英文语法批改的辅助工具,允许用户在任意网页上绝大部分的富文本编辑器、多行文本输入框中编辑英文文本,可实时得到批改结果反馈,并自行接受建议自动修改,实现完美写作
来源/ 有道技术团队公众号
作者/ 李靖雯
编辑/ 刘振宇

一、背景介绍

有道写作服务是有道出品的写作智能批改产品,为用户提供优质的作文拼写、语法、样式方面的批改服务。有道写作不仅仅支持浏览器扩展形式,还支持在其他平台使用:例如有道词典 APP-作文批改、Web 在线端、Word 插件、PC 词典内。欢迎各位体验。

http://write.youdao.com/

浏览器插件在浏览器里面的称呼是 Browser Extension,也就是浏览器扩展,是一个扩展网页浏览器功能的插件。它主要基于 HTML、JavaScript、CSS 开发,同时由于是扩展特性,可以利用浏览器本身提供的底层 API 接口进行开发,可以给所用页面或者特定页面添加一些特殊功能而不影响原本页面逻辑。

每个支持扩展的浏览器有自己下载扩展的应用商店,可以直接在应用商店下载。有些产品自己提供浏览器扩展的 .crx 文件让用户下载并安装。

二、适配浏览器

有道写作在 Windows/Mac 系统都可安装,适配 Chrome、360安全浏览器、360极速浏览器、Edge 新版浏览器等,在以上浏览器商店中搜索有道写作,点击安装按钮即可。

三、功能介绍&效果展示

在介绍开发思路与实践之前,我们先来直观地看一下有道写作浏览器扩展的实际效果,并对其功能进行简单的介绍。

3.1 表现方式

视觉效果就是,给错误的文本字符下面画一条横线,在 hover 的时候,可以给文本增加一个高亮的效果。在选接受建议的时候,可以替换成我们想要的文本数据。

image

3.2 适用场景

>>> 在线邮件编辑:

163邮箱

Outlook 邮箱

Gmail

>>> 社交动态、评论:

Facebook

微博动态

评论

>>> 工具、笔记类:

有道翻译

Google 翻译

石墨文档

3.3 功能介绍

>>> 实时批改:

支持一边修改一边实时提供批改反馈,展示批改错误数量。

>>> 语法检测:

![上传中...]()

>>> 增强编辑框:

可以查看每一个错误反馈详细内容,并可分错误类型过滤查看结果。

>>> 接受建议:

点击接受建议时候替换正确文本。

![上传中...]()

四、开发思路

需求:扩展需要针对页面上的可输入文本的编辑框赋予批改的功能

4.1 适配编辑器

那么,网页中可输入文本的编辑框都有哪些呢?

通常我们常见可输入编辑框有:

  • 基于 Web 的表单可以输入文本控件:input、textarea
<input value="123"/>
<textarea>123456</textarea>
  • 可编辑属性的元素:contenteditable
<blockquote contenteditable="true">
    <p>Edit this content to add your own quote</p>
</blockquote> 

Input 元素通常是一行且输入范围较短的内容,考虑到批改交互的功能,我们的扩展针对以下可输入较多文本的编辑器进行兼容:

  • contenteditable 富文本编辑器
  • textarea
  • 其他文档编辑器

4.2 富文本编辑器

我们常见基于 contenteditable 实现的富文本编辑器有百度编辑器、draft.js、 有道云笔记(旧版)等等。

相比 textarea,富文本编辑器可以包含很多不同标签,可以以用来渲染成不同字体颜色的文本、图片、附件、视频、音频等等元素。

实现基于浏览器的富文本编辑器的四要素

四代编辑器的技术选型

  • 第一代编辑器主要是通过有限的 execCommand 指令对 html 文档进行操作。
  • 第二代则是在 execCommand 基础上,添加更多自定义指令甚至自己实现指令方式修改 html 文档。
  • 第三代是引入数据模型(json/xml),绑定自定义实现指令从而渲染html文档。
  • 第四代主要是直接抛弃整个 contenteditbale,单独制定选区和监听输入事件。

更多关于编辑器的介绍,可参考有道技术团队之前发布的文章:

为什么要介绍富文本编辑器内容呢,因为了解多这些编辑器实现方式和保存机制可以帮助后面实现并优化扩展的功能。

4.3 初想

一开始的想法是,将原始编辑器的纯文本内容提取并发送到服务端,然后根据服务端返回的数据进行重新的拼接,在错误节点位置使用特殊标记标签进行标注。

以有道写作 Web 端为例:

使用这种方法实现批改效果的还有 163 邮箱英文智能检查、Gmail 自带写信语法检测功能等。这种方法适合我们自定义的编辑器,可以自己控制文本的渲染和指令。

但由于浏览器扩展是基于别人写的编辑器上进行的辅助工具,不能随意修改其文本格式和样式。比如复制带有划线的文本进行粘贴,会出现冗余的划线(除非原本的编辑器有做粘贴文本的标签过滤),但是不能寄希望于别人写的编辑器都有这个功能。

4.4 实现

需要分别从两个部分进行考虑:

  1. 如何定位画线
  2. 如何接受建议替换正确文本

如何定位画线,并且可以给予其高亮的效果呢?

需要解决的问题是:需要在不影响原编辑器的格式以及功能前提下,将结果划线部分加入到界面上。

>>>contenteditabe:

  • 第一步:虚拟辅助器边框

虚拟一个元素(大小相同,位置相对)在原始编辑器之上,将结果划线标注在这个元素之上,我称之为辅助器。

因为是覆盖在原始编辑器上,需要禁止辅助器的鼠标响应行为,在 hover 的时候需要将鼠标位置同步到辅助器之上。

辅助器需要和原编辑器相同,才能定位准确,需要获得原编辑器以下属性。

  • 第二步:找准定位

问题:如果单纯提取元素的 innerHTML/InnerText/textContent 作为批改请求的参数,无法实现准确定位。

原因:服务端返回结果是根据纯文本定位,网页上的编辑器格式是HTML文档格式,包含不同字体不同格式的标签。

举个例子:html 中有两个块级元素,分别展示两句话,差异只在于两句话 font-size 不一样。

通过 element.textContent 提取出来的内容都是相同的,校验返回的错误标志结果也相同,如下:

因为无法从纯文本的角度得到两种情况差异,难点就在于:需要解析 html 格式,将服务端数据转换到实际格式位置中。

要知道,这相当于要在一个空白的白板元素里添加一个多个绝对定位的高亮元素。需要知道每个错误相对原编辑框的相对位移,和自身宽高。

下面是一个反向推敲的过程:

  1. 我需要得到的是 hightlightElement : { top, letf, width, height };
  2. 通过 range.getClientRects() 可以获得我们想要的数据。
  3. 于是需要知道如何获取一个错误节点对应的 range。

  1. 需要找到对应的开头节点和它的相对位移、以及结束节点和它的相对位移。range: { startNode, stratOffest, endNode, endOffest}。方法就是通过错误节点在纯文本的开始(fixedposStart)跟结束位置(fixedposEnd)通过遍历全文每一个文本节点(textNodes)的数据长度(textNodes.nodeValue)进行计算得出。
  2. fixedposStart、fixedposEnd根据服务端返回数据经过稍微计算可得出。全文每一个文本节点(textNodes)需要通过过滤某些标签得到。
  3. 所以需要先思考如何处理 html 中各种标签问题。

所以划线的原理是:提取其纯文本的 textnode 节点,根据结果 position 匹配开始的节点和位移、结束节点和位移,获取其文本片段 range 对应编辑器的 x,y,height,width,画出高亮区域。

具体步骤如下:

a. 根据原文所有 html 标签加工过滤,提取纯文本和加工后的文本节点集合:

html 内有各种标签节点,需要根据这些标签不同意义,对标签内的文本进行加工。比如针对 p 标签,通常是表示段落,需要将其包裹的内容后面添加一个换行符。

p 标签处理例子:
问题: 这个换行符是一起发给服务端的,服务端返回来的数据定位也算上了这个换行符。
解决方案: 过滤标签的同时记录文本处理过的位置,在后面的计算反向处理。同时还需要注意字符的转义问题,尤其注意零宽字符的处理。

b. 提取纯文本节点:

(上图文本内容根据标签内容分成5个纯文本节点)

c. 结合服务端数据计算每个错误全文定位:

比如 has 错误对应的错误节点信息。

d. 根据定位获取每个错误节点文本片段:

e. 通过文本片段获取相对视口的位置:

划线步骤图

  • 第三步:在assist范围内画出线和高亮

contenteditable 集合辅助器工作的流程图

>>> textarea:

textarea 本身是无法获取其 textnode 的,它相当于只有一个节点。考虑将其转换成文本节点:

  • 创建一个隐形 mirror,这个 mirror 具备与原始编辑器相同边框大小、可编辑区域。

  • textarea 任何文本变动同步到 mirror
this.textarea.addEventListener('input',this.mirror.update);
  • 再为这个 mirror 创建一个 assist,同理上面处理 contenteditbale 的流程相一致。

>>>关于突变:

编辑器其实就是一个普通的元素,以下编辑器的交互会引起我们页面内文本节点的变化:

  • 文本内容变化
  • 尺寸变化(窗口变大变小)
  • 位置变化
  • 字体大小变化(加粗,居中)
  • 滚动

这些变化也就影响我们定位的变化,称之为突变。需要处理每一个突变引起的重新定位问题(重点难点)。

同时,需要监听原始编辑器的输入、字体变化、编辑器尺寸变化等等触发 assist 的重新定位方法。

// 通过ResizeObserver监听编辑器尺寸变化 objResizeObserver = new ResizeObserver((entries) => {
    var entry = entries[0];
    this.elementResizeHandler(entry.target)
}); 

ResizeObserver 兼容性问题需要通过 polyfill 库文件解决。

重新定位方法(mutation):

  • 通过新旧 textnode array 比对,正向遍历节点集合和反向遍历节点集合,得到被修改的 textnode 是哪一个段文本节点 textnode 集合。
  • 只需要处理被影响的 textnode 所对应的错误节点集合根据移动的 offest 计算后面影响的节点位移。

  • 其他错误相对自己 textnode 的位移是不会改变的。

如何接受一个建议,替换文本:

替换文本意味要修改原编辑器的数据甚至格式,就会造成刚才说的对部分编辑器会引起格式错乱和保存失败的情况。

难点:不影响原始数据存储格式,不影响原始编辑器撤回操作,同时还能触发原编辑器保存机制。

解决方法:不直接用脚本修改 dom 节点,模拟用户修改数据的方式:选中文字,替换内容。

以新版有道云笔记为例子:

  1. 通过之前复杂计算得到结果片段,根据结果片段计算出对于可视窗口的位置,得到 {top, left, height, width}。
  2. 模拟鼠标从左向右滑动的操作事件加在内容区域。

  1. 找到自定义的自绘区域。
  2. 一个错误结果中可能涉及不同的样式,我们仅获取当前节点第一个片段的字体样式,模拟一个粘贴事件。

  1. 在自绘区域触发自定义粘贴事件。

4.5 增强编辑框

入口在点击右下角按钮或者 hover 出现结果卡片时候,点击详细建议进入

>>>增强编辑框的作用:

  • 提供更大的编辑空间
  • 查看详细的批改结果

增强编辑框是一个特殊的 contenteditable 编辑器。

>>> 初始化、关闭赋值:

在初始化增强编辑器的时候,直接获取原编辑器数据,这里忽略了原编辑器的一些样式、图片,只使用 html 数据部分。

在增强编辑器中编辑后返回原编辑器时候,需要将新数据返回赋值。

>>> 通信:

增强编辑框是嵌入页面的 iframe,只在顶层页面出现。与原来页面的通信是通过postMessage 方式。

(注意:postMessage 不能传递 html 元素和过于复杂的 json object)

如果是原本编辑器是 iframe,需要找到最上层 window.top,利用 window.top 和增强编辑框进行通信。

五、整体流程

上图为有道写作浏览器扩展从注入到浏览器页面,以及运行的大致流程。

为了在不影响用户操作前提下,扩展脚本只会在当前页面空闲时候加载,并且批改功能只在部分被用户点击 focus 的编辑器中激活。

以上是开发有道写作浏览器扩展过程中的开发思路和部分技术实现细节,借此机会分享给大家,欢迎与有道技术团队一起探讨更多关于前端、浏览器扩展的知识问题。

查看原文

robin 赞了文章 · 3月4日

有道写作浏览器扩展实践

有道写作浏览器扩展作为一款为网页增加英文语法批改的辅助工具,允许用户在任意网页上绝大部分的富文本编辑器、多行文本输入框中编辑英文文本,可实时得到批改结果反馈,并自行接受建议自动修改,实现完美写作
来源/ 有道技术团队公众号
作者/ 李靖雯
编辑/ 刘振宇

一、背景介绍

有道写作服务是有道出品的写作智能批改产品,为用户提供优质的作文拼写、语法、样式方面的批改服务。有道写作不仅仅支持浏览器扩展形式,还支持在其他平台使用:例如有道词典 APP-作文批改、Web 在线端、Word 插件、PC 词典内。欢迎各位体验。

http://write.youdao.com/

浏览器插件在浏览器里面的称呼是 Browser Extension,也就是浏览器扩展,是一个扩展网页浏览器功能的插件。它主要基于 HTML、JavaScript、CSS 开发,同时由于是扩展特性,可以利用浏览器本身提供的底层 API 接口进行开发,可以给所用页面或者特定页面添加一些特殊功能而不影响原本页面逻辑。

每个支持扩展的浏览器有自己下载扩展的应用商店,可以直接在应用商店下载。有些产品自己提供浏览器扩展的 .crx 文件让用户下载并安装。

二、适配浏览器

有道写作在 Windows/Mac 系统都可安装,适配 Chrome、360安全浏览器、360极速浏览器、Edge 新版浏览器等,在以上浏览器商店中搜索有道写作,点击安装按钮即可。

三、功能介绍&效果展示

在介绍开发思路与实践之前,我们先来直观地看一下有道写作浏览器扩展的实际效果,并对其功能进行简单的介绍。

3.1 表现方式

视觉效果就是,给错误的文本字符下面画一条横线,在 hover 的时候,可以给文本增加一个高亮的效果。在选接受建议的时候,可以替换成我们想要的文本数据。

image

3.2 适用场景

>>> 在线邮件编辑:

163邮箱

Outlook 邮箱

Gmail

>>> 社交动态、评论:

Facebook

微博动态

评论

>>> 工具、笔记类:

有道翻译

Google 翻译

石墨文档

3.3 功能介绍

>>> 实时批改:

支持一边修改一边实时提供批改反馈,展示批改错误数量。

>>> 语法检测:

![上传中...]()

>>> 增强编辑框:

可以查看每一个错误反馈详细内容,并可分错误类型过滤查看结果。

>>> 接受建议:

点击接受建议时候替换正确文本。

![上传中...]()

四、开发思路

需求:扩展需要针对页面上的可输入文本的编辑框赋予批改的功能

4.1 适配编辑器

那么,网页中可输入文本的编辑框都有哪些呢?

通常我们常见可输入编辑框有:

  • 基于 Web 的表单可以输入文本控件:input、textarea
<input value="123"/>
<textarea>123456</textarea>
  • 可编辑属性的元素:contenteditable
<blockquote contenteditable="true">
    <p>Edit this content to add your own quote</p>
</blockquote> 

Input 元素通常是一行且输入范围较短的内容,考虑到批改交互的功能,我们的扩展针对以下可输入较多文本的编辑器进行兼容:

  • contenteditable 富文本编辑器
  • textarea
  • 其他文档编辑器

4.2 富文本编辑器

我们常见基于 contenteditable 实现的富文本编辑器有百度编辑器、draft.js、 有道云笔记(旧版)等等。

相比 textarea,富文本编辑器可以包含很多不同标签,可以以用来渲染成不同字体颜色的文本、图片、附件、视频、音频等等元素。

实现基于浏览器的富文本编辑器的四要素

四代编辑器的技术选型

  • 第一代编辑器主要是通过有限的 execCommand 指令对 html 文档进行操作。
  • 第二代则是在 execCommand 基础上,添加更多自定义指令甚至自己实现指令方式修改 html 文档。
  • 第三代是引入数据模型(json/xml),绑定自定义实现指令从而渲染html文档。
  • 第四代主要是直接抛弃整个 contenteditbale,单独制定选区和监听输入事件。

更多关于编辑器的介绍,可参考有道技术团队之前发布的文章:

为什么要介绍富文本编辑器内容呢,因为了解多这些编辑器实现方式和保存机制可以帮助后面实现并优化扩展的功能。

4.3 初想

一开始的想法是,将原始编辑器的纯文本内容提取并发送到服务端,然后根据服务端返回的数据进行重新的拼接,在错误节点位置使用特殊标记标签进行标注。

以有道写作 Web 端为例:

使用这种方法实现批改效果的还有 163 邮箱英文智能检查、Gmail 自带写信语法检测功能等。这种方法适合我们自定义的编辑器,可以自己控制文本的渲染和指令。

但由于浏览器扩展是基于别人写的编辑器上进行的辅助工具,不能随意修改其文本格式和样式。比如复制带有划线的文本进行粘贴,会出现冗余的划线(除非原本的编辑器有做粘贴文本的标签过滤),但是不能寄希望于别人写的编辑器都有这个功能。

4.4 实现

需要分别从两个部分进行考虑:

  1. 如何定位画线
  2. 如何接受建议替换正确文本

如何定位画线,并且可以给予其高亮的效果呢?

需要解决的问题是:需要在不影响原编辑器的格式以及功能前提下,将结果划线部分加入到界面上。

>>>contenteditabe:

  • 第一步:虚拟辅助器边框

虚拟一个元素(大小相同,位置相对)在原始编辑器之上,将结果划线标注在这个元素之上,我称之为辅助器。

因为是覆盖在原始编辑器上,需要禁止辅助器的鼠标响应行为,在 hover 的时候需要将鼠标位置同步到辅助器之上。

辅助器需要和原编辑器相同,才能定位准确,需要获得原编辑器以下属性。

  • 第二步:找准定位

问题:如果单纯提取元素的 innerHTML/InnerText/textContent 作为批改请求的参数,无法实现准确定位。

原因:服务端返回结果是根据纯文本定位,网页上的编辑器格式是HTML文档格式,包含不同字体不同格式的标签。

举个例子:html 中有两个块级元素,分别展示两句话,差异只在于两句话 font-size 不一样。

通过 element.textContent 提取出来的内容都是相同的,校验返回的错误标志结果也相同,如下:

因为无法从纯文本的角度得到两种情况差异,难点就在于:需要解析 html 格式,将服务端数据转换到实际格式位置中。

要知道,这相当于要在一个空白的白板元素里添加一个多个绝对定位的高亮元素。需要知道每个错误相对原编辑框的相对位移,和自身宽高。

下面是一个反向推敲的过程:

  1. 我需要得到的是 hightlightElement : { top, letf, width, height };
  2. 通过 range.getClientRects() 可以获得我们想要的数据。
  3. 于是需要知道如何获取一个错误节点对应的 range。

  1. 需要找到对应的开头节点和它的相对位移、以及结束节点和它的相对位移。range: { startNode, stratOffest, endNode, endOffest}。方法就是通过错误节点在纯文本的开始(fixedposStart)跟结束位置(fixedposEnd)通过遍历全文每一个文本节点(textNodes)的数据长度(textNodes.nodeValue)进行计算得出。
  2. fixedposStart、fixedposEnd根据服务端返回数据经过稍微计算可得出。全文每一个文本节点(textNodes)需要通过过滤某些标签得到。
  3. 所以需要先思考如何处理 html 中各种标签问题。

所以划线的原理是:提取其纯文本的 textnode 节点,根据结果 position 匹配开始的节点和位移、结束节点和位移,获取其文本片段 range 对应编辑器的 x,y,height,width,画出高亮区域。

具体步骤如下:

a. 根据原文所有 html 标签加工过滤,提取纯文本和加工后的文本节点集合:

html 内有各种标签节点,需要根据这些标签不同意义,对标签内的文本进行加工。比如针对 p 标签,通常是表示段落,需要将其包裹的内容后面添加一个换行符。

p 标签处理例子:
问题: 这个换行符是一起发给服务端的,服务端返回来的数据定位也算上了这个换行符。
解决方案: 过滤标签的同时记录文本处理过的位置,在后面的计算反向处理。同时还需要注意字符的转义问题,尤其注意零宽字符的处理。

b. 提取纯文本节点:

(上图文本内容根据标签内容分成5个纯文本节点)

c. 结合服务端数据计算每个错误全文定位:

比如 has 错误对应的错误节点信息。

d. 根据定位获取每个错误节点文本片段:

e. 通过文本片段获取相对视口的位置:

划线步骤图

  • 第三步:在assist范围内画出线和高亮

contenteditable 集合辅助器工作的流程图

>>> textarea:

textarea 本身是无法获取其 textnode 的,它相当于只有一个节点。考虑将其转换成文本节点:

  • 创建一个隐形 mirror,这个 mirror 具备与原始编辑器相同边框大小、可编辑区域。

  • textarea 任何文本变动同步到 mirror
this.textarea.addEventListener('input',this.mirror.update);
  • 再为这个 mirror 创建一个 assist,同理上面处理 contenteditbale 的流程相一致。

>>>关于突变:

编辑器其实就是一个普通的元素,以下编辑器的交互会引起我们页面内文本节点的变化:

  • 文本内容变化
  • 尺寸变化(窗口变大变小)
  • 位置变化
  • 字体大小变化(加粗,居中)
  • 滚动

这些变化也就影响我们定位的变化,称之为突变。需要处理每一个突变引起的重新定位问题(重点难点)。

同时,需要监听原始编辑器的输入、字体变化、编辑器尺寸变化等等触发 assist 的重新定位方法。

// 通过ResizeObserver监听编辑器尺寸变化 objResizeObserver = new ResizeObserver((entries) => {
    var entry = entries[0];
    this.elementResizeHandler(entry.target)
}); 

ResizeObserver 兼容性问题需要通过 polyfill 库文件解决。

重新定位方法(mutation):

  • 通过新旧 textnode array 比对,正向遍历节点集合和反向遍历节点集合,得到被修改的 textnode 是哪一个段文本节点 textnode 集合。
  • 只需要处理被影响的 textnode 所对应的错误节点集合根据移动的 offest 计算后面影响的节点位移。

  • 其他错误相对自己 textnode 的位移是不会改变的。

如何接受一个建议,替换文本:

替换文本意味要修改原编辑器的数据甚至格式,就会造成刚才说的对部分编辑器会引起格式错乱和保存失败的情况。

难点:不影响原始数据存储格式,不影响原始编辑器撤回操作,同时还能触发原编辑器保存机制。

解决方法:不直接用脚本修改 dom 节点,模拟用户修改数据的方式:选中文字,替换内容。

以新版有道云笔记为例子:

  1. 通过之前复杂计算得到结果片段,根据结果片段计算出对于可视窗口的位置,得到 {top, left, height, width}。
  2. 模拟鼠标从左向右滑动的操作事件加在内容区域。

  1. 找到自定义的自绘区域。
  2. 一个错误结果中可能涉及不同的样式,我们仅获取当前节点第一个片段的字体样式,模拟一个粘贴事件。

  1. 在自绘区域触发自定义粘贴事件。

4.5 增强编辑框

入口在点击右下角按钮或者 hover 出现结果卡片时候,点击详细建议进入

>>>增强编辑框的作用:

  • 提供更大的编辑空间
  • 查看详细的批改结果

增强编辑框是一个特殊的 contenteditable 编辑器。

>>> 初始化、关闭赋值:

在初始化增强编辑器的时候,直接获取原编辑器数据,这里忽略了原编辑器的一些样式、图片,只使用 html 数据部分。

在增强编辑器中编辑后返回原编辑器时候,需要将新数据返回赋值。

>>> 通信:

增强编辑框是嵌入页面的 iframe,只在顶层页面出现。与原来页面的通信是通过postMessage 方式。

(注意:postMessage 不能传递 html 元素和过于复杂的 json object)

如果是原本编辑器是 iframe,需要找到最上层 window.top,利用 window.top 和增强编辑框进行通信。

五、整体流程

上图为有道写作浏览器扩展从注入到浏览器页面,以及运行的大致流程。

为了在不影响用户操作前提下,扩展脚本只会在当前页面空闲时候加载,并且批改功能只在部分被用户点击 focus 的编辑器中激活。

以上是开发有道写作浏览器扩展过程中的开发思路和部分技术实现细节,借此机会分享给大家,欢迎与有道技术团队一起探讨更多关于前端、浏览器扩展的知识问题。

查看原文

赞 10 收藏 3 评论 0

robin 关注了用户 · 3月3日

xialeistudio @xialeistudio

《ThinkPHP实战》《ThinkPHP5实战》《Node.js 与 Webpack 开发实战》作者,慕课网签约讲师,知名互联网公司资深研发,第四本书正在写作中。
微信xialeistudio

关注 4918

robin 赞了问题 · 2月26日

segmentfalut的富文本编辑器什么时候能支持『图表』呢?

如图,CSDN撰写页面的富文本编辑器已经可以支持:甘特图、序列图、流程图了
image.png
image.png
image.png
image.png
想问问segmentfault有计划安排这些功能吗?

关注 1 回答 0

robin 赞了文章 · 2月25日

万物互联的背后,有一座“数据围城”

物联网之父 Kevin Ashton 在近期的一次访谈中表示:“物联网的真正意义,不在于收集存储数据。更多场景下,在于正确高效地利用数据。”

之所以提出这个观点,是因为现阶段的物联网也被称作数据“泛在聚合”意义上的物联网。万物互联的时代造就了庞大的数据海洋,Kevin 认为应该通过对其中每个数据进行属性的精确标识,全面实现数据的资源化。

如果不能合理、合规、合情的利用这些数据,我们将会进入一座物联网时代的“数据围城”。

一、物联网时代的“数据围城”

未来学家托夫勒认为,改变世界的有四种力量:暴力、知识、金钱,以及大数据。

战争改变人类社会的走向,知识影响社会的发展轨迹,金钱操纵着世界发展的命脉。而大数据之所以能位列其中,是因为“大数据”就代表着社会的形态,如何定义和理解大数据,就是如何定义和理解这个社会。

物联网作为一种建立在互联网上的泛在网络,让互联网时代的大数据从量变发展到了质变 —— 数据既包含数据本身,也包含了物联网中的万物以及物的状态,物与物、物与人之间的交互。

“像是一座被围困的城堡,城外的人想冲进去,城里的人想逃出来。”这是钱钟书先生书中所描述的围城,而物联网时代的“数据围城”,是指数据虽然可以为我们认知社会、推进社会发展提供源源不断的动力,但却因为我们的不“善假于物”,被不合理的分析和解读。

二、为何要打破这座“数据围城”?

打破这座“数据围城”,既是互联网深入发展的必然要求,也是物联网的使命所在。

而想要打破这座“数据围城”,就需要在物联网所造就的数据海洋中,构建一种“泛在的聚合”关系,使人们不再受系统环境、外部设备和数据终端的限制,便可对各类数据执行方便的检索、计算和分析,并结合已有的数学分析模型,挖掘这些数据所代表的事务之间普遍存在的复杂联系,从而实现人类对周边世界认知能力的革命性飞跃。

打破“数据围城”的前提,是要洞悉物联网时代的数据特点,这其中包括了数据采集、数据处理、数据共享和数据的有效性甄别四个方面,只有在特定场景中进行特定的处理,数据才能转化成我们所需要的信息。

以数据采集为例,作为物联网的第一道关隘,若想打破“数据围城”,切入点必须从传感器入手。传感器是物联网感知层的数据接口,主要负责感知外界信息、响应上层指令,并配合各类终端设备完成数据的统一标准化接入。然而,不同类别的传感器所捕获的信息内容和信息格式均不相同,需要按一定的频率,周期性地采集环境信息并实时更新。随着现代物联网的发展,传感器本身也需要具备智能化处理的能力,即能够对物体实施智能控制。

因此,从传感器入手,需要思考的是如何重新定义“传感器”和“数据”间的关系。如果能将传感器和智能处理相结合,从海量信息中提取、分析、加工和处理数据,辅助业务决策,适应用户的多种需求,便能拓展出全新的应用领域和应用模式。

牵一发而动全身,仅仅从数据采集这一层切入,便要考虑如此多的因素。因此,若想真正打破物联网时代的“数据围城”,将会涉及物联网产业中的多个角色,但只要围绕着前文提到的数据特点进行突破,一定可以达到事半功倍的效果。

三、如何利用物联网时代的数据特点打破“数据围城”

物联网的本质是由众多生态、系统通过特定的规律整合而成,无论生态多么庞大、系统多么复杂,其都可以细分为一个个组件和功能模块,洞悉了这些组件和模块的数据特点,便可以推导出与之关联的物联网的“破城口”。

以现代企业智慧办公为例,来看一下在该场景如何利用各个组件和功能模块的数据特点打破这座“数据围城”。通过前文的分析,已知该场景的数据特点包含三个层面:数据共享、场景化(数据处理)和效率(数据的有效性甄别)。(详情请阅览:《纵观 Excel 演化史,开发者如何通过“表格技术”提升企业生产力》

其中的数据共享,既是该场景的特点,也是物联网时代数据的重要特征。进入物联网时代后,办公软件的使用场景从 PC 和桌面端,扩展到了移动设备、智能手机、PAD 等更多的移动端,企业所面临的智慧办公最大的难点也已经从单纯的一台操作设备,升级成跨设备以及多人之间的协作协同,越来越多的数据需要被采集、分享和运用。

在操作系统层面, HarmonyOS 借助了自身分布式软总线、分布式数据管理和分布式安全三大核心能力有效解决了跨设备的协同问题。但对于具体数据信息的采集、处理、共享和多人协作编辑,仍需要各类在线文档软件的支持。

在线文档类软件的出现,为企业办公提供了全新的工作模式,通过将办公数据从本地迁移到云端,打破了时间和空间的限制,用“更高的效率和更低的成本”实现了在线实时存储和多人协作,这一点也与物联网未来的发展不谋而合。

可见,对于在线文档类软件来说,只要能贴合物联网时代的数据特点,便可以协助打破这座“数据围城”。而无论是数据的采集、计算分析和多人协同交互等都离不开表格控件所提供的底层支持。葡萄城,作为物联网数据类应用落地“协作者”的代表之一,提供的正是这样的能力。(详情请阅览:打破技术壁垒, 用SpreadJS 抢占“表格文档协同编辑系统”的入市先机

作为全球领先的软件开发技术提供商,葡萄城以“ 赋能开发者”为使命,致力于通过各类软件开发工具和服务,创新开发模式,提升开发效率,服务企业数智化转型。

葡萄城研发的纯前端表格控件 SpreadJS ,提供了表格文档协同编辑、 数据填报和类Excel报表设计的功能支持,可帮助软件厂商和系统集成商有效应对数据处理、数据共享和数据有效性甄别等业务需要。

纯前端表格控件 SpreadJS

借助 SpreadJS“高性能、跨平台、与 Excel 高度兼容”的产品特性,可以让数据处理不再受硬件、操作系统与使用环境的限制,从而实现高效的在线填报、模板设计以及多人协同,构建出更为便捷、易用、安全的分布式数据管理架构。(了解详情:SpreadJS 纯前端表格控件

结语:物联网时代,重新审视人与世界间的关系

互联网时代让我们重塑了人与人之间的关系。而物联网时代则将这层关系网放大,需要让我们重新审视物与物、人与物之间的关系,这也是之所以需要打破这一座“数据围城”的意义所在。

了解物联网行业的朋友都知道,物联网产业链中包含八大环节:芯片提供商、传感器供应商、无线模组(含天线)厂商、网络运营商(含 SIM 卡商) 、平台服务商、系统及软件开发商、智能硬件厂商和技术服务提供商。

其中网络运营商负责的是物联网的底层通道,也是目前物联网产业链中最成熟的环节;芯片、传感器等硬件厂商则是物联网的大脑和四肢,低功耗、高可靠性的半导体芯片是物联网几乎所有环节都必不可少的关键部件之一;专供物联网的操作系统虽然仍处于起步阶段,但目前入局的都是 IT 行业的巨头,如谷歌、微软、苹果、华为、阿里等。

纵观整个环节中,目前最容易被忽视、最需要与物联网相结合的恰恰是物联网应用落地真正的“协作者” —— 技术服务提供商。他们才是万物互联时代链接人与物、人与物联网之间最直接的一根纽带。

打破万物互联时代的数据围城,既需要迎合时代大的技术背景,也需要聚焦到每个人的需求当中。我们需要华为、阿里、谷歌这类技术先锋为人类扩展技术的无限可能,也需要像 SpreadJS 这样的垂直细分产品,以人为中心,在技术大潮中服务用户的本质需求。

segmnetfault 思否

查看原文

赞 26 收藏 2 评论 1

robin 赞了文章 · 2月24日

手把手教你写一个脚手架

最近在学习 vue-cli 的源码,获益良多。为了让自己理解得更加深刻,我决定模仿它造一个轮子,争取尽可能多的实现原有的功能。

我将这个轮子分成三个版本:

  1. 尽可能用最少的代码实现一个最简版本的脚手架。
  2. 在 1 的基础上添加一些辅助功能,例如选择包管理器、npm 源等等。
  3. 实现插件化,可以自由的进行扩展。在不影响内部源码的情况下,添加功能。

有人可能不懂脚手架是什么。按我的理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽可能快的进行业务开发。

建议在阅读本文时,能够结合项目源码一起配合使用,效果更好。这是项目地址 mini-cli。项目中的每一个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。所以在阅读源码时,记得要切换到对应的分支。

第一个版本 v1

第一个版本的功能比较简单,大致为:

  1. 用户输入命令,准备创建项目。
  2. 脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能。
  3. 用户选择自己需要的功能。
  4. 脚手架根据用户的选择创建 package.json 文件,并添加对应的依赖项。
  5. 脚手架根据用户的选择渲染项目模板,生成文件(例如 index.htmlmain.jsApp.vue 等文件)。
  6. 执行 npm install 命令安装依赖。

项目目录树:

├─.vscode
├─bin 
│  ├─mvc.js # mvc 全局命令
├─lib
│  ├─generator # 各个功能的模板
│  │  ├─babel # babel 模板
│  │  ├─linter # eslint 模板
│  │  ├─router # vue-router 模板
│  │  ├─vue # vue 模板
│  │  ├─vuex # vuex 模板
│  │  └─webpack # webpack 模板
│  ├─promptModules # 各个模块的交互提示语
│  └─utils # 一系列工具函数
│  ├─create.js # create 命令处理函数
│  ├─Creator.js # 处理交互提示
│  ├─Generator.js # 渲染模板
│  ├─PromptModuleAPI.js # 将各个功能的提示语注入 Creator
└─scripts # commit message 验证脚本 和项目无关 不需关注

处理用户命令

脚手架第一个功能就是处理用户的命令,这需要使用 commander.js。这个库的功能就是解析用户的命令,提取出用户的输入交给脚手架。例如这段代码:

#!/usr/bin/env node
const program = require('commander')
const create = require('../lib/create')

program
.version('0.1.0')
.command('create <name>')
.description('create a new project')
.action(name => { 
    create(name)
})

program.parse()

它使用 commander 注册了一个 create 命令,并设置了脚手架的版本和描述。我将这段代码保存在项目下的 bin 目录,并命名为 mvc.js。然后在 package.json 文件添加这段代码:

"bin": {
  "mvc": "./bin/mvc.js"
},

再执行 npm link,就可以将 mvc 注册成全局命令。这样在电脑上的任何地方都能使用 mvc 命令了。实际上,就是用 mvc 命令来代替执行 node ./bin/mvc.js

假设用户在命令行上输入 mvc create demo(实际上执行的是 node ./bin/mvc.js create demo),commander 解析到命令 create 和参数 demo。然后脚手架可以在 action 回调里取到参数 name(值为 demo)。

和用户交互

取到用户要创建的项目名称 demo 之后,就可以弹出交互选项,询问用户要创建的项目需要哪些功能。这需要用到 [
Inquirer.js](https://github.com/SBoudrias/...Inquirer.js 的功能就是弹出一个问题和一些选项,让用户选择。并且选项可以指定是多选、单选等等。

例如下面的代码:

const prompts = [
    {
        "name": "features", // 选项名称
        "message": "Check the features needed for your project:", // 选项提示语
        "pageSize": 10,
        "type": "checkbox", // 选项类型 另外还有 confirm list 等
        "choices": [ // 具体的选项
            {
                "name": "Babel",
                "value": "babel",
                "short": "Babel",
                "description": "Transpile modern JavaScript to older versions (for compatibility)",
                "link": "https://babeljs.io/",
                "checked": true
            },
            {
                "name": "Router",
                "value": "router",
                "description": "Structure the app with dynamic pages",
                "link": "https://router.vuejs.org/"
            },
        ]
    }
]

inquirer.prompt(prompts)

弹出的问题和选项如下:

问题的类型 "type": "checkbox"checkbox 说明是多选。如果两个选项都进行选中的话,返回来的值为:

{ features: ['babel', 'router'] }

其中 features 是上面问题中的 name 属性。features 数组中的值则是每个选项中的 value

Inquirer.js 还可以提供具有相关性的问题,也就是上一个问题选择了指定的选项,下一个问题才会显示出来。例如下面的代码:

{
    name: 'Router',
    value: 'router',
    description: 'Structure the app with dynamic pages',
    link: 'https://router.vuejs.org/',
},
{
    name: 'historyMode',
    when: answers => answers.features.includes('router'),
    type: 'confirm',
    message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
    description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
    link: 'https://router.vuejs.org/guide/essentials/history-mode.html',
},

第二个问题中有一个属性 when,它的值是一个函数 answers => answers.features.includes('router')。当函数的执行结果为 true,第二个问题才会显示出来。如果你在上一个问题中选择了 router,它的结果就会变为 true。弹出第二个问题:问你路由模式是否选择 history 模式。

大致了解 Inquirer.js 后,就可以明白这一步我们要做什么了。主要就是将脚手架支持的功能配合对应的问题、可选值在控制台上展示出来,供用户选择。获取到用户具体的选项值后,再渲染模板和依赖。

有哪些功能

先来看一下第一个版本支持哪些功能:

  • vue
  • vue-router
  • vuex
  • babel
  • webpack
  • linter(eslint)

由于这是一个 vue 相关的脚手架,所以 vue 是默认提供的,不需要用户选择。另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不用用户进行选择。所以可供用户选择的功能只有 4 个:

  • vue-router
  • vuex
  • babel
  • linter

现在我们先来看一下这 4 个功能对应的交互提示语相关的文件。它们全部放在 lib/promptModules 目录下:

-babel.js
-linter.js
-router.js
-vuex.js

每个文件包含了和它相关的所有交互式问题。例如刚才的示例,说明 router 相关的问题有两个。下面再看一下 babel.js 的代码:

module.exports = (api) => {
    api.injectFeature({
        name: 'Babel',
        value: 'babel',
        short: 'Babel',
        description: 'Transpile modern JavaScript to older versions (for compatibility)',
        link: 'https://babeljs.io/',
        checked: true,
    })
}

只有一个问题,就是问下用户需不需要 babel 功能,默认为 checked: true,也就是需要。

注入问题

用户使用 create 命令后,脚手架需要将所有功能的交互提示语句聚合在一起:

// craete.js
const creator = new Creator()
// 获取各个模块的交互提示语
const promptModules = getPromptModules()
const promptAPI = new PromptModuleAPI(creator)
promptModules.forEach(m => m(promptAPI))
// 清空控制台
clearConsole()

// 弹出交互提示语并获取用户的选择
const answers = await inquirer.prompt(creator.getFinalPrompts())
    
function getPromptModules() {
    return [
        'babel',
        'router',
        'vuex',
        'linter',
    ].map(file => require(`./promptModules/${file}`))
}

// Creator.js
class Creator {
    constructor() {
        this.featurePrompt = {
            name: 'features',
            message: 'Check the features needed for your project:',
            pageSize: 10,
            type: 'checkbox',
            choices: [],
        }

        this.injectedPrompts = []
    }

    getFinalPrompts() {
        this.injectedPrompts.forEach(prompt => {
            const originalWhen = prompt.when || (() => true)
            prompt.when = answers => originalWhen(answers)
        })
    
        const prompts = [
            this.featurePrompt,
            ...this.injectedPrompts,
        ]
    
        return prompts
    }
}

module.exports = Creator


// PromptModuleAPI.js
module.exports = class PromptModuleAPI {
    constructor(creator) {
        this.creator = creator
    }

    injectFeature(feature) {
        this.creator.featurePrompt.choices.push(feature)
    }

    injectPrompt(prompt) {
        this.creator.injectedPrompts.push(prompt)
    }
}

以上代码的逻辑如下:

  1. 创建 creator 对象
  2. 调用 getPromptModules() 获取所有功能的交互提示语
  3. 再调用 PromptModuleAPI 将所有交互提示语注入到 creator 对象
  4. 通过 const answers = await inquirer.prompt(creator.getFinalPrompts()) 在控制台弹出交互语句,并将用户选择结果赋值给 answers 变量。

如果所有功能都选上,answers 的值为:

{
  features: [ 'vue', 'webpack', 'babel', 'router', 'vuex', 'linter' ], // 项目具有的功能
  historyMode: true, // 路由是否使用 history 模式
  eslintConfig: 'airbnb', // esilnt 校验代码的默认规则,可被覆盖
  lintOn: [ 'save' ] // 保存代码时进行校验
}

项目模板

获取用户的选项后就该开始渲染模板和生成 package.json 文件了。先来看一下如何生成 package.json 文件:

// package.json 文件内容
const pkg = {
    name,
    version: '0.1.0',
    dependencies: {},
    devDependencies: {},
}

先定义一个 pkg 变量来表示 package.json 文件,并设定一些默认值。

所有的项目模板都放在 lib/generator 目录下:

├─lib
│  ├─generator # 各个功能的模板
│  │  ├─babel # babel 模板
│  │  ├─linter # eslint 模板
│  │  ├─router # vue-router 模板
│  │  ├─vue # vue 模板
│  │  ├─vuex # vuex 模板
│  │  └─webpack # webpack 模板

每个模板的功能都差不多:

  1. pkg 变量注入依赖项
  2. 提供模板文件

注入依赖

下面是 babel 相关的代码:

module.exports = (generator) => {
    generator.extendPackage({
        babel: {
            presets: ['@babel/preset-env'],
        },
        dependencies: {
            'core-js': '^3.8.3',
        },
        devDependencies: {
            '@babel/core': '^7.12.13',
            '@babel/preset-env': '^7.12.13',
            'babel-loader': '^8.2.2',
        },
    })
}

可以看到,模板调用 generator 对象的 extendPackage() 方法向 pkg 变量注入了 babel 相关的所有依赖。

extendPackage(fields) {
    const pkg = this.pkg
    for (const key in fields) {
        const value = fields[key]
        const existing = pkg[key]
        if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
            pkg[key] = Object.assign(existing || {}, value)
        } else {
            pkg[key] = value
        }
    }
}

注入依赖的过程就是遍历所有用户已选择的模板,并调用 extendPackage() 注入依赖。

渲染模板

脚手架是怎么渲染模板的呢?用 vuex 举例,先看一下它的代码:

module.exports = (generator) => {
    // 向入口文件 `src/main.js` 注入代码 import store from './store'
    generator.injectImports(generator.entryFile, `import store from './store'`)
    
    // 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
    generator.injectRootOptions(generator.entryFile, `store`)
    
    // 注入依赖
    generator.extendPackage({
        dependencies: {
            vuex: '^3.6.2',
        },
    })
    
    // 渲染模板
    generator.render('./template', {})
}

可以看到渲染的代码为 generator.render('./template', {})./template 是模板目录的路径:

所有的模板代码都放在 template 目录下,vuex 将会在用户创建的目录下的 src 目录生成 store 文件夹,里面有一个 index.js 文件。它的内容为:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
    },
    mutations: {
    },
    actions: {
    },
    modules: {
    },
})

这里简单描述一下 generator.render() 的渲染过程。

第一步, 使用 globby 读取模板目录下的所有文件:

const _files = await globby(['**/*'], { cwd: source, dot: true })

第二步,遍历所有读取的文件。如果文件是二进制文件,则不作处理,渲染时直接生成文件。否则读取文件内容,再调用 ejs 进行渲染:

// 返回文件内容
const template = fs.readFileSync(name, 'utf-8')
return ejs.render(template, data, ejsOptions)

使用 ejs 的好处,就是可以结合变量来决定是否渲染某些代码。例如 webpack 的模板中有这样一段代码:

module: {
      rules: [
          <%_ if (hasBabel) { _%>
          {
              test: /\.js$/,
              loader: 'babel-loader',
              exclude: /node_modules/,
          },
          <%_ } _%>
      ],
  },

ejs 可以根据用户是否选择了 babel 来决定是否渲染这段代码。如果 hasBabelfalse,则这段代码:

{
    test: /\.js$/,
    loader: 'babel-loader',
    exclude: /node_modules/,
},

将不会被渲染出来。hasBabel 的值是调用 render() 时用参数传过去的:

generator.render('./template', {
    hasBabel: options.features.includes('babel'),
    lintOnSave: options.lintOn.includes('save'),
})

第三步,注入特定代码。回想一下刚才 vuex 中的:

// 向入口文件 `src/main.js` 注入代码 import store from './store'
generator.injectImports(generator.entryFile, `import store from './store'`)

// 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
generator.injectRootOptions(generator.entryFile, `store`)

这两行代码的作用是:在项目入口文件 src/main.js 中注入特定的代码。

vuexvue 的一个状态管理库,属于 vue 全家桶中的一员。如果创建的项目没有选择 vuexvue-router。则 src/main.js 的代码为:

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
    render: (h) => h(App),
}).$mount('#app')

如果选择了 vuex,它会注入上面所说的两行代码,现在 src/main.js 代码变为:

import Vue from 'vue'
import store from './store' // 注入的代码
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  store, // 注入的代码
  render: (h) => h(App),
}).$mount('#app')

这里简单描述一下代码的注入过程:

  1. 使用 vue-codemod 将代码解析成语法抽象树 AST。
  2. 然后将要插入的代码变成 AST 节点插入到上面所说的 AST 中。
  3. 最后将新的 AST 重新渲染成代码。

提取 package.json 的部分选项

一些第三方库的配置项可以放在 package.json 文件,也可以自己独立生成一份文件。例如 babelpackage.json 中注入的配置为:

babel: {
    presets: ['@babel/preset-env'],
}

我们可以调用 generator.extractConfigFiles() 将内容提取出来并生成 babel.config.js 文件:

module.exports = {
    presets: ['@babel/preset-env'],
}

生成文件

渲染好的模板文件和 package.json 文件目前还是在内存中,并没有真正的在硬盘上创建。这时可以调用 writeFileTree() 将文件生成:

const fs = require('fs-extra')
const path = require('path')

module.exports = async function writeFileTree(dir, files) {
    Object.keys(files).forEach((name) => {
        const filePath = path.join(dir, name)
        fs.ensureDirSync(path.dirname(filePath))
        fs.writeFileSync(filePath, files[name])
    })
}

这段代码的逻辑如下:

  1. 遍历所有渲染好的文件,逐一生成。
  2. 在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。
  3. 写入文件。

例如现在一个文件路径为 src/test.js,第一次写入时,由于还没有 src 目录。所以会先生成 src 目录,再生成 test.js 文件。

webpack

webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。

添加新功能

添加一个新功能,需要在两个地方添加代码:分别是 lib/promptModuleslib/generator。在 lib/promptModules 中添加的是这个功能相关的交互提示语。在 lib/generator 中添加的是这个功能相关的依赖和模板代码。

不过不是所有的功能都需要添加模板代码的,例如 babel 就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持 ts。除了添加 ts 相关的依赖,还得在 webpackvuevue-routervuexlinter 等功能中修改原有的模板代码。

举个例子,在 vue-router 中,如果支持 ts,则这段代码:

const routes = [ // ... ]

需要修改为:

<%_ if (hasTypeScript) { _%>
const routes: Array<RouteConfig> = [ // ... ]
<%_ } else { _%>
const routes = [ // ... ]
<%_ } _%>

因为 ts 的值有类型。

总之,添加的新功能越多,各个功能的模板代码也会越来越多。并且还需要考虑到各个功能之间的影响。

下载依赖

下载依赖需要使用 execa,它可以调用子进程执行命令。

const execa = require('execa')

module.exports = function executeCommand(command, cwd) {
    return new Promise((resolve, reject) => {
        const child = execa(command, [], {
            cwd,
            stdio: ['inherit', 'pipe', 'inherit'],
        })

        child.stdout.on('data', buffer => {
            process.stdout.write(buffer)
        })

        child.on('close', code => {
            if (code !== 0) {
                reject(new Error(`command failed: ${command}`))
                return
            }

            resolve()
        })
    })
}

// create.js 文件
console.log('\n正在下载依赖...\n')
// 下载依赖
await executeCommand('npm install', path.join(process.cwd(), name))
console.log('\n依赖下载完成! 执行下列命令开始开发:\n')
console.log(`cd ${name}`)
console.log(`npm run dev`)

调用 executeCommand() 开始下载依赖,参数为 npm install 和用户创建的项目路径。为了能让用户看到下载依赖的过程,我们需要使用下面的代码将子进程的输出传给主进程,也就是输出到控制台:

child.stdout.on('data', buffer => {
    process.stdout.write(buffer)
})

下面我用动图演示一下 v1 版本的创建过程:

创建成功的项目截图:

第二个版本 v2

第二个版本在 v1 的基础上添加了一些辅助功能:

  1. 创建项目时判断该项目是否已存在,支持覆盖和合并创建。
  2. 选择功能时提供默认配置和手动选择两种模式。
  3. 如果用户的环境同时存在 yarn 和 npm,则会提示用户要使用哪个包管理器。
  4. 如果 npm 的默认源速度比较慢,则提示用户是否要切换到淘宝源。
  5. 如果用户是手动选择功能,在结束后会询问用户是否要将这次的选择保存为默认配置。

覆盖和合并

创建项目时,先提前判断一下该项目是否存在:

const targetDir = path.join(process.cwd(), name)
// 如果目标目录已存在,询问是覆盖还是合并
if (fs.existsSync(targetDir)) {
    // 清空控制台
    clearConsole()

    const { action } = await inquirer.prompt([
        {
            name: 'action',
            type: 'list',
            message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
            choices: [
                { name: 'Overwrite', value: 'overwrite' },
                { name: 'Merge', value: 'merge' },
            ],
        },
    ])

    if (action === 'overwrite') {
        console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
        await fs.remove(targetDir)
    }
}

如果选择 overwrite,则进行移除 fs.remove(targetDir)

默认配置和手动模式

先在代码中提前把默认配置的代码写好:

exports.defaultPreset = {
    features: ['babel', 'linter'],
    historyMode: false,
    eslintConfig: 'airbnb',
    lintOn: ['save'],
}

这个配置默认使用 babeleslint

然后生成交互提示语时,先调用 getDefaultPrompts() 方法获取默认配置。

getDefaultPrompts() {
    const presets = this.getPresets()
    const presetChoices = Object.entries(presets).map(([name, preset]) => {
        let displayName = name

        return {
            name: `${displayName} (${preset.features})`,
            value: name,
        }
    })

    const presetPrompt = {
        name: 'preset',
        type: 'list',
        message: `Please pick a preset:`,
        choices: [
            // 默认配置
            ...presetChoices,
            // 这是手动模式提示语
            {
                name: 'Manually select features',
                value: '__manual__',
            },
        ],
    }

    const featurePrompt = {
        name: 'features',
        when: isManualMode,
        type: 'checkbox',
        message: 'Check the features needed for your project:',
        choices: [],
        pageSize: 10,
    }

    return {
        presetPrompt,
        featurePrompt,
    }
}

这样配置后,在用户选择功能前会先弹出这样的提示语:

包管理器

vue-cli 创建项目时,会生成一个 .vuerc 文件,里面会记录一些关于项目的配置信息。例如使用哪个包管理器、npm 源是否使用淘宝源等等。为了避免和 vue-cli 冲突,本脚手架生成的配置文件为 .mvcrc

这个 .mvcrc 文件保存在用户的 home 目录下(不同操作系统目录不同)。我的是 win10 操作系统,保存目录为 C:\Users\bin。获取用户的 home 目录可以通过以下代码获取:

const os = require('os')
os.homedir()

.mvcrc 文件还会保存用户创建项目的配置,这样当用户重新创建项目时,就可以直接选择以前创建过的配置,不用再一步步的选择项目功能。

在第一次创建项目时,.mvcrc 文件是不存在的。如果这时用户还安装了 yarn,脚手架就会提示用户要使用哪个包管理器:

// 读取 `.mvcrc` 文件
const savedOptions = loadOptions()
// 如果没有指定包管理器并且存在 yarn
if (!savedOptions.packageManager && hasYarn) {
    const packageManagerChoices = []

    if (hasYarn()) {
        packageManagerChoices.push({
            name: 'Use Yarn',
            value: 'yarn',
            short: 'Yarn',
        })
    }

    packageManagerChoices.push({
        name: 'Use NPM',
        value: 'npm',
        short: 'NPM',
    })

    otherPrompts.push({
        name: 'packageManager',
        type: 'list',
        message: 'Pick the package manager to use when installing dependencies:',
        choices: packageManagerChoices,
    })
}

当用户选择 yarn 后,下载依赖的命令就会变为 yarn;如果选择了 npm,下载命令则为 npm install

const PACKAGE_MANAGER_CONFIG = {
    npm: {
        install: ['install'],
    },
    yarn: {
        install: [],
    },
}

await executeCommand(
    this.bin, // 'yarn' or 'npm'
    [
        ...PACKAGE_MANAGER_CONFIG[this.bin][command],
        ...(args || []),
    ],
    this.context,
)

切换 npm 源

当用户选择了项目功能后,会先调用 shouldUseTaobao() 方法判断是否需要切换淘宝源:

const execa = require('execa')
const chalk = require('chalk')
const request = require('./request')
const { hasYarn } = require('./env')
const inquirer = require('inquirer')
const registries = require('./registries')
const { loadOptions, saveOptions } = require('./options')
  
async function ping(registry) {
    await request.get(`${registry}/vue-cli-version-marker/latest`)
    return registry
}
  
function removeSlash(url) {
    return url.replace(/\/$/, '')
}
  
let checked
let result
  
module.exports = async function shouldUseTaobao(command) {
    if (!command) {
        command = hasYarn() ? 'yarn' : 'npm'
    }
  
    // ensure this only gets called once.
    if (checked) return result
    checked = true
  
    // previously saved preference
    const saved = loadOptions().useTaobaoRegistry
    if (typeof saved === 'boolean') {
        return (result = saved)
    }
  
    const save = val => {
        result = val
        saveOptions({ useTaobaoRegistry: val })
        return val
    }
  
    let userCurrent
    try {
        userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout
    } catch (registryError) {
        try {
        // Yarn 2 uses `npmRegistryServer` instead of `registry`
            userCurrent = (await execa(command, ['config', 'get', 'npmRegistryServer'])).stdout
        } catch (npmRegistryServerError) {
            return save(false)
        }
    }
  
    const defaultRegistry = registries[command]
    if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
        // user has configured custom registry, respect that
        return save(false)
    }
  
    let faster
    try {
        faster = await Promise.race([
            ping(defaultRegistry),
            ping(registries.taobao),
        ])
    } catch (e) {
        return save(false)
    }
  
    if (faster !== registries.taobao) {
        // default is already faster
        return save(false)
    }
  
    if (process.env.VUE_CLI_API_MODE) {
        return save(true)
    }
  
    // ask and save preference
    const { useTaobaoRegistry } = await inquirer.prompt([
        {
            name: 'useTaobaoRegistry',
            type: 'confirm',
            message: chalk.yellow(
                ` Your connection to the default ${command} registry seems to be slow.\n`
            + `   Use ${chalk.cyan(registries.taobao)} for faster installation?`,
            ),
        },
    ])
    
    // 注册淘宝源
    if (useTaobaoRegistry) {
        await execa(command, ['config', 'set', 'registry', registries.taobao])
    }

    return save(useTaobaoRegistry)
}

上面代码的逻辑为:

  1. 先判断默认配置文件 .mvcrc 是否有 useTaobaoRegistry 选项。如果有,直接将结果返回,无需判断。
  2. 向 npm 默认源和淘宝源各发一个 get 请求,通过 Promise.race() 来调用。这样更快的那个请求会先返回,从而知道是默认源还是淘宝源速度更快。
  3. 如果淘宝源速度更快,向用户提示是否切换到淘宝源。
  4. 如果用户选择淘宝源,则调用 await execa(command, ['config', 'set', 'registry', registries.taobao]) 将当前 npm 的源改为淘宝源,即 npm config set registry https://registry.npm.taobao.org。如果是 yarn,则命令为 yarn config set registry https://registry.npm.taobao.org

一点疑问

其实 vue-cli 是没有这段代码的:

// 注册淘宝源
if (useTaobaoRegistry) {
    await execa(command, ['config', 'set', 'registry', registries.taobao])
}

这是我自己加的。主要是我没有在 vue-cli 中找到显式注册淘宝源的代码,它只是从配置文件读取出是否使用淘宝源,或者将是否使用淘宝源这个选项写入配置文件。另外 npm 的配置文件 .npmrc 是可以更改默认源的,如果在 .npmrc 文件直接写入淘宝的镜像地址,那 npm 就会使用淘宝源下载依赖。但 npm 肯定不会去读取 .vuerc 的配置来决定是否使用淘宝源。

对于这一点我没搞明白,所以在用户选择了淘宝源之后,手动调用命令注册一遍。

将项目功能保存为默认配置

如果用户创建项目时选择手动模式,在选择完一系列功能后,会弹出下面的提示语:

询问用户是否将这次的项目选择保存为默认配置,如果用户选择是,则弹出下一个提示语:

让用户输入保存配置的名称。

这两句提示语相关的代码为:

const otherPrompts = [
    {
        name: 'save',
        when: isManualMode,
        type: 'confirm',
        message: 'Save this as a preset for future projects?',
        default: false,
    },
    {
        name: 'saveName',
        when: answers => answers.save,
        type: 'input',
        message: 'Save preset as:',
    },
]

保存配置的代码为:

exports.saveOptions = (toSave) => {
    const options = Object.assign(cloneDeep(exports.loadOptions()), toSave)
    for (const key in options) {
        if (!(key in exports.defaults)) {
            delete options[key]
        }
    }
    cachedOptions = options
    try {
        fs.writeFileSync(rcPath, JSON.stringify(options, null, 2))
        return true
    } catch (e) {
        error(
            `Error saving preferences: `
      + `make sure you have write access to ${rcPath}.\n`
      + `(${e.message})`,
        )
    }
}

exports.savePreset = (name, preset) => {
    const presets = cloneDeep(exports.loadOptions().presets || {})
    presets[name] = preset

    return exports.saveOptions({ presets })
}

以上代码直接将用户的配置保存到 .mvcrc 文件中。下面是我电脑上的 .mvcrc 的内容:

{
  "packageManager": "npm",
  "presets": {
    "test": {
      "features": [
        "babel",
        "linter"
      ],
      "eslintConfig": "airbnb",
      "lintOn": [
        "save"
      ]
    },
    "demo": {
      "features": [
        "babel",
        "linter"
      ],
      "eslintConfig": "airbnb",
      "lintOn": [
        "save"
      ]
    }
  },
  "useTaobaoRegistry": true
}

下次再创建项目时,脚手架就会先读取这个配置文件的内容,让用户决定是否使用已有的配置来创建项目。

至此,v2 版本的内容就介绍完了。

小结

由于 vue-cli 关于插件的源码我还没有看完,所以这篇文章只讲解前两个版本的源码。v3 版本等我看完 vue-cli 的源码再回来填坑,预计在 3 月初就可以完成。

如果你想了解更多关于前端工程化的文章,可以看一下我写的《带你入门前端工程》。 这里是全文目录:

  1. 技术选型:如何进行技术选型?
  2. 统一规范:如何制订规范并利用工具保证规范被严格执行?
  3. 前端组件化:什么是模块化、组件化?
  4. 测试:如何写单元测试和 E2E(端到端) 测试?
  5. 构建工具:构建工具有哪些?都有哪些功能和优势?
  6. 自动化部署:如何利用 Jenkins、Github Actions 自动化部署项目?
  7. 前端监控:讲解前端监控原理及如何利用 sentry 对项目实行监控。
  8. 性能优化(一):如何检测网站性能?有哪些实用的性能优化规则?
  9. 性能优化(二):如何检测网站性能?有哪些实用的性能优化规则?
  10. 重构:为什么做重构?重构有哪些手法?
  11. 微服务:微服务是什么?如何搭建微服务项目?
  12. Severless:Severless 是什么?如何使用 Severless?

参考资料

查看原文

赞 38 收藏 25 评论 0

robin 赞了文章 · 2月23日

协同表格+低代码,这个免费工具可作为团队的数据管理和自动化中心

企业中往往有很多零散的数据需要管理,这些数据往往分散在 Excel 表格、数据库、OA 等多个系统之中。我们能不能对这些数据进行集中的收集、展示和共享协作呢?更进一步的,在这些数据之上,我们能不能快速的开发一些小应用进行数据的处理、反馈、提醒呢?

今天我们就来分享 SeaTable 这款免费的工具,看看它如何帮我们实现零散数据的集中管理和自动化。

先简单介绍下 SeaTable ,它是一款新型的协同表格和低代码平台。它支持“文件”、“图片”、“单选项”、“协作人”、“计算公式”等丰富的数据类型。 它帮助你用表格的形式来方便的组织和管理各类信息。它同时包含完善的 API、自动化规则、脚本运行能力,可以按照你的需要进行扩展,实现数据处理的自动化和业务流程的自动化。

SeaTable 包含以下的版本:

  • 开发者版: 面向把 SeaTable 当做轻型数据库使用的用户。可以免费下载,私有部署!!没有行数、存储量和 API 调用的限制。
  • 云服务版: 面向有协同需求的普通用户,有行数、存储量和 API 调用的限制。
  • 企业版: 在以上版本的基础上,同时有完善的权限管理和用户管控功能,可以云端使用也可以本地部署。

下面我们以一个多网站运维管理为例来说明 SeaTable 中数据的记录和管理、数据可视化和自动化。

多网站运维管理的例子

作为开发团队,我们往往要运维多个网站,有些给内部用,有些给外部用。我们不仅要把各种零散信息集中记录,以方便查看和协作,还要对站点证书过期时间等,进行监控和维护。要解决这些问题,如果自己写一个自动化工具需要花费不少时间,还不好维护。而如果用 SeaTable ,就能在很短的时间内完成,维护起来还方便。

比如我们团队平时管理的站点就有二十多个,全部使用的是免费的 Lets' encrypt 证书,并通过脚本在证书过期前自动更新证书。偶尔会出现脚本没有配置对,或其他的原因导致证书没有正常更新(尤其是对新部署的站点)。这就需要制作一个功能来解决这类问题。

下面我们来逐一介绍怎么用 SeaTable 来实现:

  • 站点数据的协同记录
  • 用脚本实现自动化更新网站证书过期时间
  • 自动化监控和提醒网站证书过期时间
  • 让表格数据信息可视化

数据的协同记录

关于数据的协同记录,主要分享以下几点:

  1. SeaTable 支持日期、图片、文件、单选、URL、长文本、协作人、创建者、创建时间等丰富的数据类型,用它的数据类型,就可以把运维相关的各种数据类型的信息都集中记录到表格里。
  2. 当我们把表格的只读或可读写权限,共享给同事后,他们就可以进行只读或协作编辑了。而且表格管理员还可以根据需要,锁定表头、锁定行、设置列的编辑权限等。比如可以对某列设置任何人都不能编辑、或只有管理员可以编辑、或哪些共享用户能编辑。
  3. 我们还可以用 API 或者 Python 脚本来同步数据库中的记录,或者从第三方抓取数据。

用脚本来自动化更新网站证书的过期时间

SeaTable 提供了 Python 脚本的运行环境,我们可以把脚本和数据放在一个地方进行管理,不需要再单独找一个服务器。同时,可以在表格中根据不同的需要存储多个 Python 脚本文件, 一键点击运行就可以达到我们想要的效果。如下图:

image

具体的脚本内容这里就不介绍,有兴趣了解更多的同学可以访问 https://seatable.github.io/se...

脚本除了点击运行外,还可以安排每日自动运行。

image

用提醒规则来自动化提醒

下面用 SeaTable 的“提醒规则”功能,来实现自动化提醒。

点击表格右上角的“提醒规则”按钮,添加一个提醒规则。比如对“证书过期时间”列的时间,可以设置在还有多少天就要过期时,自动发出提醒通知。另外,在个人微信号绑定了表格账号的前提下,当这个运维管理表有未阅读的提醒通知时,如果两分钟内你没有点开网页并阅读,那么提醒就会发送到个人微信上。

提醒规则设置,如下图:

image

灵活查看和可视化

在灵活查看数据、可视化和统计分析等方面,SeaTable 有表格视图功能,有日历、时间线、图库、地图等实用的插件,也有便捷的“统计”功能等。我们可以根据数据特征去选择使用。比如可以在多视图间快速切换查看不同角度的数据;利用统计图表,来对这个记录了零散数据的网站运维表进行更直观的动态可视化。

多视图:

image

统计图表:

image

总结

我们用 SeaTable 就可以非常方便地在表格里记录和管理各种类型的数据信息。更重要的是,我们无需再开发工具,用它的“脚本”和“提醒规则”等功能,就快速完成了自动化的数据处理和流程管理。

作为一款新型的协同表格和低代码平台,从使用上来看,它不仅使用门槛低,而且具备灵活性和通用性,即便是非专业技术人员,也能构建自己的业务应用程序,从而不再严重依赖技术研发,大幅降低沟通、人力和开发成本。平时我们可以利用它完善的 API、提醒规则和脚本功能等,帮我们快速实现数据处理自动化和业务流程自动化的灵活需求。

查看原文

赞 17 收藏 1 评论 3

robin 赞了文章 · 2月20日

使用 mask 实现视频弹幕人物遮罩过滤

经常看一些 LOL 比赛直播的小伙伴,肯定都知道,在一些弹幕网站(Bilibili、虎牙)中,当人物与弹幕出现在一起的时候,弹幕会“巧妙”的躲到人物的下面,看着非常的智能。

简单的一个截图例子:

image

其实,这里是运用了 CSS 中的 MASK 属性实现的。

mask 简单用法介绍

之前在多篇文章都提到了 mask,比较详细的一篇是 -- 奇妙的 CSS MASK,本文不对 mask 的基本概念做过多讲解,向下阅读时,如果对一些 mask 的用法感到疑惑,可以再去看看。

这里只简单介绍下 mask 的基本用法:

最基本,使用 mask 的方式是借助图片,类似这样:

{
    /* Image values */
    mask: url(mask.png);                       /* 使用位图来做遮罩 */
    mask: url(masks.svg#star);                 /* 使用 SVG 图形中的形状来做遮罩 */
}

当然,使用图片的方式后文会再讲。借助图片的方式其实比较繁琐,因为我们首先还得准备相应的图片素材,除了图片,mask 还可以接受一个类似 background 的参数,也就是渐变。

类似如下使用方法:

{
    mask: linear-gradient(#000, transparent)                      /* 使用渐变来做遮罩 */
}

那该具体怎么使用呢?一个非常简单的例子,上述我们创造了一个从黑色到透明渐变色,我们将它运用到实际中,代码类似这样:

下面这样一张图片,叠加上一个从透明到黑色的渐变,

{
    background: url(image.png) ;
    mask: linear-gradient(90deg, transparent, #fff);
}

image

应用了 mask 之后,就会变成这样:

image

这个 DEMO,可以先简单了解到 mask 的基本用法。

这里得到了使用 mask 最重要结论:添加了 mask 属性的元素,其内容会与 mask 表示的渐变的 transparent 的重叠部分,并且重叠部分将会变得透明。

值得注意的是,上面的渐变使用的是 linear-gradient(90deg, transparent, #fff),这里的 #fff 纯色部分其实换成任意颜色都可以,不影响效果。

CodePen Demo -- 使用 MASK 的基本使用

使用 mask 实现人物遮罩过滤

了解了 mask 的用法后,接下来,我们运用 mask,简单实现视频弹幕中,弹幕碰到人物,自动被隐藏过滤的例子。

首先,我简单的模拟了一个召唤师峡谷,以及一些基本的弹幕:

mask1

方便示意,这里使用了一张静态图,表示了召唤师峡谷的地图,并非真的视频,而弹幕则是一条一条的 <p> 元素,和实际情况一致。伪代码大概是这样:

<!-- 地图 -->
<div class="g-map"></div>
<!-- 包裹所有弹幕的容器 -->
<div class="g-barrage-container">
    <!-- 所有弹幕 -->
    <div class="g-barrage">6666</div>
    ...
    <div class="g-barrage">6666</div>
</div>

为了模拟实际情况,我们再用一个 div 添加一个实际的人物,如果不做任何处理,其实就是我们看视频打开弹幕的感受,人物被视频所遮挡:

mask2

注意,这里我添加了一个人物亚索,并且用 animation 模拟了简单的运动,在运动的过程中,人物是被弹幕给遮挡住的。

接下来,就可以请出 mask 了。

我们利用 mask 制作一个 radial-gradient ,使得人物附近为 transparent,并且根据人物运动的 animation,给 mask 的 mask-position 也添加上相同的 animation 即可。最终可以得到这样的效果:

.g-barrage-container {
    position: absolute;
    mask: radial-gradient(circle at 100px 100px, transparent 60px, #fff 80px, #fff 100%);
    animation: mask 10s infinite alternate;
}

@keyframes mask {
    100% {
        mask-position: 85vw 0;
    }
}

mask3

实际上就是给放置弹幕的容器,添加一个 mask 属性,把人物所在的位置标识出来,并且根据人物的运动不断的去变换这个 mask 即可。我们把 mask 换成 background,原理一看就懂。

  • 把 mask 替换成 background 示意图:

mask4

background 透明的地方,即 mask 中为 transparent 的部分,实际就是弹幕会被隐藏遮罩的部分,而其他白色部分,弹幕不会被隐藏,正是完美的利用了 mask 的特性。

其实这项技术和视频本身是无关的,我们只需要根据视频计算需要屏蔽掉弹幕的位置,得到相应的 mask 参数即可。如果去掉背景和运动的人物,只保留弹幕和 mask,是这样的:

mask6

需要明确的是,使用 mask,不是将弹幕部分给遮挡住,而是利用 mask,指定弹幕容器之下,哪些部分正常展示,哪些部分透明隐藏

最后,完整的 Demo 你可以戳这里:

CodePen Demo -- mask 实现弹幕人物遮罩过滤

实际生产环境中的运用

当然,上面我们简单的还原了利用 mask 实现弹幕遮罩过滤的效果。但是实际情况比上述的场景复杂的多,因为人物英雄的位置是不确定的,每一刻都在变化。所以在实际生产环境中,mask 图片的参数,其实是由后端实时对视频进行处理计算出来的,然后传给前端,前端再进行渲染。

对于运用了这项技术的直播网站,我们可以审查元素,看到包裹弹幕的容器的 mask 属性,每时每刻都在发生变化:

mask5

返回回来的其实是一个 SVG 图片,大概长这个样子:

image

这样,根据视频人物的实时位置变化,不断计算新的 mask,再实时作用于弹幕容器之上,实现遮罩过滤。

最后

本文到此结束,希望对你有帮助 :),本文介绍了 CSS mask 的一个实际生产环境中,非常有意义的一次实践,也表明很多新的 CSS 技术,运用得当,还是能给业务带来非常有益的帮助的。

想 Get 到最有意思的 CSS 资讯,千万不要错过我的公众号 -- iCSS前端趣闻 😄

gzh_small.png

更多精彩 CSS 技术文章汇总在我的 Github -- iCSS ,持续更新,欢迎点个 star 订阅收藏。

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

查看原文

赞 47 收藏 24 评论 7

robin 关注了用户 · 2月20日

chokcoco @chokcoco

坎坷切图仔

关注 205

认证与成就

  • 获得 96 次点赞
  • 获得 15 枚徽章 获得 0 枚金徽章, 获得 3 枚银徽章, 获得 12 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-07-09
个人主页被 4.6k 人浏览