lennonover


  • 首页

  • 归档

  • 标签

前端质量平台如何利用 AST 实现代码扫码

发表于 2022-03-19

本篇文章主要通过一个简单物料检测例子来介绍一下我们团队前端代码质量平台,如何做代码扫码和分析,希望对大家有一定的参考价值

一、AST(抽象语法树)

In computer science, an abstract syntax tree (AST), or just syntax tree, is a tree representation of the abstract syntactic structure of source code written in a programming language. - 维基百科
Javascript 的语法是为了给开发者更好的编程而设计的,但是不适合程序的理解。浏览器编译器一般会把源码转化为 AST 来进行进一步的分析等其他操作,一个程序在执行之前会经历三个步骤统称为编译:

  • 分词/词法分析: 将由字符组成的字符串分解成有意义的代码块
  • 解析/语法分析: 词法单元流转换成一个由元素嵌套所组成的代表了程序语法结构的抽象语法树(abstract syntax code,AST)
  • 代码生成: 将 AST 转换成可执行代码的过程被称为代码生成
    抽象语法树(abstract syntax code,AST)是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构.

1.1 AST 用途

babel、eslint、prettier 等工具无一例外的应用了 AST 树,而树的遍历就深度优先和广度优先两种,在这只能是深度优先。

二、JavaScript 解析器

js 解析器是代码分析扫描工具的基础,没有它就很难工作,开篇我们先来介绍下目前有哪些常用的解析器。

2.1 ESTree

ESTree 的初衷通过社区的力量,保证和 es 规范的一致性,通过自定义的语法结构来表述 JavaScript 的 AST,后来随着知名度越来越高,多位知名工程师的参与,使得变成了事实意义上的规范,目前这个库是 Mozilla 和社区一起维护的。因为 EsTree 定义的规范,所以现在所有的 js 解析器或者编译器,基本上都绕不开它。

2.2 Esprima

这是第一个用 JavaScript 编写的符合 EsTree 规范的 JavaScript 的解析器,后续多个编译器都是受它的影响。

2.3 acorn

acorn 和 Esprima 很类似,输出的 ast 都是符合 EsTree 规范的,目前 webpack 的 AST 解析器用的就是 acorn,和 Esprima 一样,也是也不直接支持 JSX

2.4 @babel/parser

babel 官方的解析器,最初 fork 于 acorn,后来完全走向了自己的道路,其构建的插件体系非常强大,提供了一套完善的 visitor 插件机制用于扩展,通过编写 babel 插件来操作 ast 非常的方便。

2.5 espree

eslint、prettier 的默认解析器,最初 fork 于 Esprima 的一个分支,后来因为 ES6 的快速发展,但 Esprima 短时间内又不支持,后面就基于 acorn 开发。

2.6 swc

用的 rust 编写的 js 编译器,单核比 babel 快 4 倍,4 核比 babel 快 70 倍,也可以用来打包 js、ts 代码,并且也拥有 tree shaking 功能,目标就是替换 babel,比如 Next.js 11.1 就用 SWC 替代 Babel 和 Terser。

2.7 esbuild

esbuild 是用 go 编写的下一代 web 打包工具,它拥有目前最快的打包记录和压缩记录,snowpack 和 vite 的也是使用它来做打包工具,为了追求卓越的性能,目前没有将 AST 进行暴露,也无法修改 AST,无法用作解析对应的 JavaScript。

三、代码扫码平台的技术实现

3.1 @babel/parser

前面提到 @babel/parser 提供了一套完善的插件机制用于扩展,通过编写 babel 插件来操作 ast 非常的方便。

1
2
3
4
5
6
7
8
9
10
constparser = require("@babel/parser");
consttraverse = require("@babel/traverse").default;
// 编写自定义规则插件
constvisitor = {};
// 源代码
constcode = `const str = "hello world";`;
// code -> ast
constast = parser.parse(code);
// 分析代码
traverse(ast, visitor);

我们可以通过编写 visitor ,当然直接使用这些 API 的场景倒不多,项目中经常用到的,是各种 Babel 插件,接下俩我展示是如何快速开发一个插件

3.1.1 编写一个简单的插件

我们把 foo === bar; >> sebmck === bar; 分析 AST 结构:

1
2
3
4
5
6
7
8
9
10
11
12
{
"type": "BinaryExpression",
"operator": "===",
"left": {
"type": "Identifier",
"name": "foo"
},
"right": {
"type": "Identifier",
"name": "bar"
}
}

我们从添加 BinaryExpression 访问者方法开始,只关注哪些使用了 === 的 BinaryExpression 用新的标识符来替换 left/right 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
exportdefaultfunction({ types: t }) {
return {
visitor: {
// Visitor 中的每个函数接收2个参数:path 和 state,
BinaryExpression(path) {
if (path.node.operator !== "===") {
return;
}
path.node.left = t.identifier("sebmck");
path.node.right = t.identifier("dork");
}
}
};
}

这样一个简单转换插件就完成了,具体详见 Babel 插件手册, babel 插件的编写主要涉及 vistors、paths、scope 和 binding 几个能力。

3.2 eslint rule

前面我介绍了基于 babel 操作 ast 的示例,Babel 不但完成了 AST 的解析工作,它还提供了一套完善的 visitor 插件机制用于我们操作 ast,业内评价 Babel is the new jQuery。但是我们平台希望能够扫描脚本更加方便使用也就是希望能在开发结算就能发现问题,我们开发阶段和 eslint 形影不离,完全可以采用开发自定义 eslint rules 的形式对代码分析,我们通过自定义的开发 eslint rules 来实现代码分析需求,同时也满足可以在项目里配置当前检测规则。

3.2.1 如何编写 rule

一条 rule 就是一个 node 模块,其主要由 meta 和 create 两部分组成,其中:

  • meta 代表了这条规则的元数据,如其类别,文档,可接收的参数的 schema 等等
  • create:如果说 meta 表达了我们想做什么,那么 create 则用表达了这条 rule 具体会怎么分析代码

3.2.2 eslint rule 规则模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
name: "规则名称",
meta: {
type: "规则类型,如suggestion",
docs: {
description: "规则描述",
category: "规则分类:如Possible Errors",
recommended: true,
url: "说明规则的文档地址,如https://eslint.org/docs/rules/no-extra-semi",
},
fixable: "是否可以修复,如code",
schema: [],
},
create: function (context) {
// 事件回调
return {};
},
};

使用方式就是在事件回调函数中使用 context 中获取的 AST 等信息进行分析。

  • 事件回调函数
  • 选择器 selector 通过 AST selectors 我们可以方便的找到静态代码中的内容
  • 访问器 visitor
  • context 它是一个树对象
  • context.report 这个方法用来向用户报告错误信息
    更多详见 Working with Rules 官方文档写的非常详细。
    我们拿个实际生产中的例子,统计我们物料市场的物料 @finance/searchTable 在项目中引用的次数,

3.2.3 编写检测规则

规则实现 use-material-num.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
importdocsUrlfrom'../docsUrl';
// 定义规则名称
varRULE_NAME = 'use-material-num';
module.exports = {
name:RULE_NAME,
meta: {
// 规则类型
type:'suggestion',
docs: {
// 说明规则的文档地址
url:docsUrl(RULE_NAME),
},
// 是否可以修复,如code
fixable:null,
messages: {
useMaterialNum:"{{ value }} is used in the project",
},
},
create(context) {
constmaterialArr = ['@finance/searchTable'];
// 事件回调
return {
Literal:functionhandleRequires(node) {
if (node.parent && node.parent.type === 'ImportDeclaration' && materialArr.indexOf(node.value) !== -1) {
context.report({
node:node,
messageId:'useMaterialNum',
data: {
value:node.value,
},
});
},
},
};
},
};

规则测试 test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
varrule = require("./use-material-num");
varRuleTester = require("eslint").RuleTester;
varruleTester = newRuleTester();
ruleTester.run("use-material-num", rule, {
valid: [
{
code: "import SearchTable from '@finance/searchTable'",
},
],
invalid: [
{
code: "import SearchTable from '@finance/searchTable'",
output: "import SearchTable from '@finance/searchTable'",
errors: [
{
message: "'@finance/searchTable' is used in the project",
},
],
},
],
});

我们直接调用 ruleTester 的 run 函数就可以完成扫码了,看起来是不是很简单。

3.2.4 实现过程分析

目标是在代码中找到 import SearchTable from '@finance/searchTable'

1 确定代码 AST 树形结构

我们可以利用在线 astexplorer

2 分析树编写规则

ast
根据上面的 code vs AST 关系图可以发现 type 是 'ImportDeclaration' 的文本就代表这引入,同时 node.value 代表当前节点的值,通过值的对比我们就可以知道当前的是不是物料市场里的组件,当然具体生产环境还会有 import A,{ SearchTable }from '@finance/searchTable' 等场景这时候就需要分析 specifiers 里的类型是 ImportDefaultSpecifier 或者 ImportSpecifier。根据上面分析就很容易理解下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
constmaterialArr = ['@finance/searchTable'];
return {
Literal:functionhandleRequires(node) {
if (node.parent && node.parent.type === 'ImportDeclaration' && materialArr.indexOf(node.value) !== -1) {
context.report({
node:node,
messageId:'useMaterialNum',
data: {
value:node.value,
},
});
},
},
};
3 Eslint Api 检测

通过 Eslint Api 执行我们的规则够就很容易检测出物料的使用,如下示例:
物料

3.3 更换 ESLint 的 AST 解析器

ESLint 也支持使用第三方 AST 解析器,比如我们可以用 @babel/eslint-parser 来替换 espree

1
2
3
module.exports = {
parser: "@babel/eslint-parser",
};

四、总结

本文先简单介绍 AST 和用途,并介绍了几款业内常见的解析器,然后围绕如何代码扫码分析介绍两种方式 babel 和 eslint 的实现,通过前端质量平台的应用情况来看针对一些通用型的问题,利用 AST 代码扫描能够很容易检测并发现问题,AST 能力十分强大,学习 AST 相关可以帮助我们在实际的开发过程中能够更容易的解决一些问题。

参考

  • https://juejin.cn/post/7054008042764894222/
  • https://astexplorer.net/

Icon 图标交付-我们有了最友好的方案

发表于 2022-01-29

背景

为了解决在维护 Web 图标库时所面临的痛苦,期望打造最友好的从设计到研发图标交付方案,我希望它们能让更多的开发者和设计师受益,让网站建设更简单、更快速,更高效。

之前的方案

目前团队没有统一的图标库,对于图标的使用根据业务有两种,针对移动端(H5)设计交付设计稿后研发图片形式集成到项目中使用;对于 web 端设计同学有在 Iconfont 维护字体图标也就是 IconFont 但是在维护过程比较繁琐。

图片资源

在移动端场景,开发同学目前都是根据设计交付的设计稿在 Figma 导出图片资源使用,为了解决图片上传问题也开发相应 chrome 插件自动上传。再此场景使用图片也是可以的也是最基础的处理方式,问题就是相同图标无法复用。

IconFont

Web Font 的发展得益于 CSS3 的 @font-face 属性。我们通常看到的图标都是以图片形式集成到项目中使用,Iconfont 是一套字体图标由阿里妈妈团队出品,和我们使用自定义字体的方式是一样的,并且它是一种矢量图标,在 web 端用到 iconfont 相对较多,但是很少移动端很少采用 iconfont;
IconFont 的使用支持 unicode 引用、 font-class 引用和 symbol 引用三种,简单总结一下 iconfont 的优缺点:

  • 优点
  • 缩放不会模糊
  • 跨平台一套资源可在 web、iOS、Android 等多个平台使用
  • 体积小包含几十个图标的字体包比一个 svg 图标资源的体积还要小
  • 缺点
  • 更新成本高,目前没有特别友好的解决方案这样是本文最重要的原因
  • 毕竟是字体图标在没有目前浏览器还没实现渐进式字体前还是有体验问题
  • 虽然支持了彩色图标,但是无法和单色共存,也是依赖浏览器的彩色字体(COLR)规范的实现
    随着我们前端一体化平台建设,我们在开发平台 icon 库遇到最大的问题组件库的更新,也就是如何和设计协作,针对现有的 Iconfont 的协作方式目前无法满足,尤其针对在后续的更新维护。我们期望有个高效的协作方式,参考了业界 Juuun开源 和 github icon 的技术方案实现了以下方案。

现在的方案

现在我们采用 Figma + Figma 插件 + gitlab CI 生成 SVG React/Vue 组件库。 1.对于设计只需要在 Figma Icon 页面运行插件
由于我们公司当前设计工作都迁移在 Figma ,所以对于设计只需要在 Figma 维护一个 Icon 资源页面,当 Icon 需要更新时运行插件:

2.开发在飞书群收到 MR 通知

对于研发可以从机器人了解到操作人、修改的内容,我们会根据仓库 ID 配置 MR 同步到多仓库,同时对比当前版本和私服最新版本的差异避免误操作。 3.审核修改内容合并 MR 执行 gitlab CI

开发根据修改内容决定是否合并,如果合并 CI 会执行三个 Stages 分别是 install 获取依赖;build 通过 Figma API 获取 svg 图标并构建成 React 和 Vue 版本组件库;publish 推送到 npm 同时将图标站点推送到静态服务方便查找图标。

4.CI 发布到 npm 仓库、静态站点成功后飞书再次通知完成
这时候开发界面可以根据版本预览到图标使用
5.开发使用时只需要更新对应的 npm 包(React、Vue)

SVG

为什么选择 svg 方案,回看图标演进从雪碧图到 iconfont,再到 SVG 图片,以及内联 SVG 代码的 React/Vue 组件,组件无疑是目前最方便的模式。参考 Vite 的核心理念就是一切都是按需的,模块只有在被请求时才会被转换。通过将所有图标编译成多个文件并将它们作为 npm 包分发来解决这个问题,因此我们开发 react 和 vue 两个版本的 npm 包。同时 svg 也有着以下特点:

  • svg 可缩放矢量图形可以无损放大,也不会出现字体渲染锯齿问题表现最好
  • 可着色能力我们可以使用 fill=”currentColor” 来为 SVG 着色( CSS filters 和 CSS Mask 也可以)
  • 灵活性 Inline svg 直接写进 html 特别适合组件化
    Inline SVG vs Icon Fonts 也给出了详细的 Inline Svg 与 Icon Fonts 之间的区别。当然 svg 还有很多可玩比如:张老师的《学习了,CSS 中内联 SVG 图片有比 Base64 更好的形式》等等,这里不过多介绍。

Figma

Figma 是一个 基于浏览器 的协作式 UI 设计工具,从推出至今越来越受到 UI 设计师的青睐很多的设计团队投入了 Figma 的怀抱。同时它已经推出的 Web API,通过 API 可以快速轻松地对内部公司工作流程进行脚本改进,或将 Figma 与其他工具集成。也就为我们从 Figma 图标文件生成 React/Vue 组件代码提供了能力。

Figma 插件

Figma 插件类似于浏览器插件,使用 Web 技术(HTML/CSS/JS)进行开发。Figma 为插件提供的沙箱环境,可以访问到 Figma 全局变量用于获取文档内容,但是屏蔽了其它的各种全局变量,如 window、document 以及 fetch 等,可以避免恶意插件越权访问未授权的内容或破坏程序运行环境,提升安全性,你可以理解成 main 和 ui,其中 main 代码运行在沙箱之中,ui 部分代码运行在 iframe 之中。编程过程涉及到两个窗口之间的通信用 postMessage,其他详见官方文档。
在设计执行插件通知时,为了避免删除或者重命名 ICON 而造成更新后老版本不兼容,新增了版本对比及时提醒:

Figma Web Api

通过 web api 就可以拿到原始的 svg 图标资源,通过脚本对 svg 图标处理,因此我们和设计定了一些规则:

  1. 确保图标基础尺寸是 24*24
  2. 每个图标必须编组 create component(Option+Command+K)
  3. 如果是彩色图标可以在命名中包含 colr 例如 social-wechatcolr-origin
  4. 图标命名请使用英文(不可以含 /)和 -,例如 dewu-logo-original
  5. 图标一般有描边(stroke)和填充(fill)两种样式,一个图标只能采用一种形式,不可以混合,默认 fill。想要转为 fill 样式,你可以使用 Outline Stroke(Shift+Command+O) 将图标转换为填充形状。
    通过图标命名我们对 svg 处理成单色(去掉原始颜色设置 fill=”currentColor”)、彩色(保留原始颜色)。

Gitlab Api

通过 GitLab API 获取、操作 GitLab 项目,详见官方文档。我们主要用到了 repository 、merge_requests 两个模块:

1
2
3
4
getPackageContent();
updatePackage();
createBranch();
createMRequest();

Component

在通过 Figma Web Api 获取 svg 文件并根据规则处理 svg 文件后,我们需要编码把 svg icon 转换成 React 和 Vue 的组件库。对于 svg 文件处理使用了第三方 svgo,svgo 是 SVG Optimizer 的简写,是一个基于 Nodejs 的 SVG 文件优化工具,简单使用:

1
2
3
4
5
6
7
8
9
10
constSvgo = require("svgo");
constsvgo = newSvgo({
plugins: [
{ convertShapeToPath: false },
{ mergePaths: false },
{ removeAttrs: { attrs: "(fill|stroke.*)" } },
{ removeTitle: true },
],
});
svgo.optimize(svg);

这里面的 { removeAttrs: { attrs: '(fill|stroke.*)' } } 是为了都给图标去色,当然对于彩色图标是不要去色。后面就是真的单个 svg 文件生成组件,过程不再赘述。

Gitlab CI

GitLab CI 是 GitLab 内置的进行持续集成的工具,只需要在仓库根目录下创建 .gitlab-ci.yml 文件,并配置 GitLab Runner;每次提交的时候,gitlab 将自动识别到.gitlab-ci.yml 文件,并且使用 Gitlab Runner 执行该脚本。公司部署的 Gitlab 已有公共 Runner 所以我们只需编写 .gitlab-ci.yml 文件即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
image: node:14.13.1
# 定义 stages
stages:
- install
- build
- publish
# 定义 job
install-staging:dep:
stage: install
tags:
- ep-share
only:
- master
script:
- echo"=====start install======"
- yarn
- echo"=====end install======"
artifacts:
expire_in: 60 mins
paths:
- node_modules/
build-staging:dep:
# 省略 build 代码
publish-staging:dep:
# 省略 publish 代码

我们设置 master 变化是执行:

图标文档站点

我们通过把构建后的静态站点和组件包同步到 npm 私服上,由于我们内网部署了 unpkg 的 CDN 服务,所以我们只需要根据版本就能访问到相应的文档,例如:https://unpkg.shizhuang-inc.com/poizon-react-icon@1.0.6/docs/index.html
poizon-react-icon 的 1.0.6 版本文档,这样好处是不用再关心文档部署,同时也提供了文档版本管理的能力。

总结

我们再来回顾完整交付更新流程,设计和研发整个过程只需要在自己工作流中简单操作即可完成图标交付,非常高效!当然任何技术方案都有它的优劣,最重要的还是根据需求取舍,未来渐进式字体和彩色字体的推进我们也可能会拥抱 font 同时也会有新的交付方案,但是对于我们目前阶段最合适的还是这套。

参考链接

  • https://github.com/leadream/Figma-icon-automation
  • https://www.Figma.com/plugin-docs/intro/
  • https://www.iconfont.cn/help/detail?helptype=code
  • https://gist.github.com/banyudu/f7472f935897adfbb54cc8eb38dc5373
  • https://github.blog/2018-04-12-driving-changes-from-designs/

你不知道的 for in 中 key 的顺序机制

发表于 2021-11-15

背景

最近开发业务遇到一个需求:
图片
产品希望把公司按照他希望的顺序排列,我们当前的实现逻辑是:

1
2
3
4
5
6
7
8
9
10
11
12
13
const sectionMapToArray = (Object) => {
const _arr = [];
for (const key in Object) {
if (Object.hasOwnProperty(key)) {
const element = Object[key];
_arr.push({
label: element,
value: key,
});
}
}
return _arr;
};

很简单的一个循环操作,但是它引发了什么问题呢?

1
2
3
4
5
6
7
8
9
10
const object = {
"010101": "识装信息",
102: "识装集团",
101: "得物集团",
A01: "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、‘A01’

我们看到输出的顺序变了,理想状态下是输出 ‘010101’、’102’、’101’、‘A01’

  • 猜想一 按照 key 的 ASC 码
1
2
3
4
5
6
7
8
9
10
const object = {
A: "JiaWu Pro",
"010101": "识装信息",
识装集团: "识装集团",
101: "得物集团",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'A'、'010101'、‘识装集团’

对象的遍历输出并不是按照属性的 ASC 码升序排序。

  • 猜想二 按照 key 转换成整数的大小
1
2
3
4
5
6
7
8
9
10
const object = {
"010101": "识装信息",
102: "识装集团",
101: "得物集团",
A01: "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、‘A01’

我们在再看开头的例子好像是按照 key 的整数大小,测试一下:

1
2
3
4
5
6
7
8
9
10
const object = {
"010101": "识装信息",
102: "识装集团",
101: "得物集团",
"-100": "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、‘-100’

看结果 -100 应该是第一个但是并没有,在猜测一下是非负数整数:

1
2
3
4
5
6
7
8
9
10
11
const object = {
"010101": "识装信息",
102: "识装集团",
101: "得物集团",
测试: "JiaWu Pro",
"-100": "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、'测试'、‘-100’

看结果似乎似乎接近真相了,我们在试试:

1
2
3
4
5
6
7
8
9
10
11
12
const object = {
"010101": "识装信息",
"0101": "识装信息",
102: "识装集团",
101: "得物集团",
测试: "JiaWu Pro",
"-100": "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、'0101'、'测试'、‘-100’

我们发现 ‘010101’、’0101’ 这两个位置并不对,但是到这里已经没有什么头绪只能去查资料。

排序机制

integer properties are sorted, others appear in creation order.
通过查阅资料发现这么定义:当 key 整数类型会做一层排序,其他类型则按创建顺序来排。
但是我们通过最后例子可以发现 ‘010101’、’0101’ 转换成 number 后并不符合,所以如何定义 integer 整数?
通过上 integer 关键词在 查找:
An integer index is a String-valued property key that is a canonical numeric String (see 7.1.16) and whose numeric value is either +0 or a positive integer ≤ 2^53−1. An array index is an integer index whose numeric value i is in the range +0 ≤ i < 2^32−1.
我们发现 canonical numeric String (see 7.1.16) 有定义 integer :
The abstract operation CanonicalNumericIndexString returns argument converted to a numeric value if it is a String representation of a Number that would be produced by ToString, or the string “-0”. Otherwise, it returns undefined. This abstract operation functions as follows:
1.Assert: Type(argument) is String.
2.If argument is “-0”, return −0.
3.Let n be ToNumber(argument).
4.If SameValue(ToString(n), argument) is false, return undefined.
5.Return n.
A canonical numeric string is any String value for which the CanonicalNumericIndexString abstract operation does not return undefined.>
通过 7.1.16 我们知道会把 key 转换成数字,由于转换数字过程,第四条规则:
SameValue(ToString(n), argument)
我们知道 SameValue 是 Object.is() 内部采用的比较算法类似于 ===,区别保证 -0 和 +0 不再相同,但 Object.is(NaN, NaN) 会返回 true。
所以也就出现了 SameValue(TOString(10101),’010101’) 为 false 的情况就解释了

1
2
3
4
5
6
7
8
9
10
11
12
const object = {
"010101": "识装信息",
"0101": "识装信息",
102: "识装集团",
101: "得物集团",
测试: "JiaWu Pro",
"-100": "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、'0101'、'测试'、‘-100’

‘010101’、’0101’ 的顺序问题

2^32−1

我们回过来在看文档中 2^32−1 代表什么意思,在看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
const object = {
"010101": "识装信息",
10188988888: "识装信息",
102: "识装集团",
101: "得物集团",
测试: "JiaWu Pro",
"-100": "JiaWu Pro",
};
for (const key in object) {
console.log(key);
}
// 输出 '101'、'102'、'010101'、'10188988888'、'测试'、‘-100’

我们发现 10188988888 在 010101 的前面,按照之前的逻辑应该是反着来的,所以就要介绍下 2^32−1:
32 位无符号整数的最大值。
为什么会有 2^32−1 边界限制,这里又涉及到 V8 的排序实现,大概意思是因为性能原因 详见:从 Chrome 源码看 JS Object 的实现

总结

当 key 整数类型会做一层排序,其他类型则按创建顺序来排,关于什么是整数类型要同时满足 canonical numeric String 和 +0 ≤ i < 2^32−1

参考资料

  • https://262.ecma-international.org/6.0/#sec-canonicalnumericindexstring
  • https://zhuanlan.zhihu.com/p/26169639

物料市场-组件 monorepo 架构

发表于 2021-11-07

背景

最近平台启动了物料市场技术项目,目的是通过提供丰富可复用物料、一体化物料解决方案、全链路研发来实现对研发体验和研发效率的提升。在设计公共组件架构时平台采用了多包架构(Monorepo),一套灵活的组件研发体系,并且天然支持按需使用。

什么是 Monorepo

monorepo 是一种将多个项目代码存储在一个仓库里的软件开发策略,这种方式在一个项目仓库(repo)中管理多个模块/包(package)。很多出名开源的项目都是采纳的 monorepo 的组织形式,比如 Babel,React ,Jest, create-react-app, react-router 、 npm@7 也带来一流的 monorepo 支持等等。

monorepo 的优劣

monorepo 的优势

  • 代码重用将变得非常容易
  • 依赖管理将变得非常简单
  • 代码重构将变得非常便捷

monorepo 的劣势

  • 项目粒度的权限管理变得非常复杂(既是优点也是缺点)
  • 学习成本变高
  • 库体积超大,目录结构复杂度上升
    基于两者的优缺点,结合我们当前组件库的特点:
    ● 每个包之间是有相关依赖的。
    ● 统一的构建工具,统一发版。
    ● 对版本的说明要求较高
    所以我们推荐采用 Monorepo 对组件库进行管理,目前最常见的 Monorepo 解决方案是 Lerna 和 Yarn 的 workspaces 特性。我们采用 Yarn 官方推荐的做法,用 Yarn 来处理依赖问题,用 Lerna 来处理发布问题。

lerna

A tool for managing JavaScript projects with multiple packages. Lerna is a tool that optimizes the workflow around managing multi-package repositories with git and npm.
基于上文我们可以知道 lerna 是最出名的 monorepo 的管理工具,也是当前项目采用的方案
通过 lerna 创建的项目结构

1
2
3
- packages(目录)
- lerna.json(配置文件)
- package.json(工程描述文件)

lerna.json 文件,默认内容为:

1
2
3
4
{
"packages": ["packages/*"],
"version": "0.0.0"
}

我们根据需求修改之后

1
2
3
4
5
6
{
"packages": ["packages/*"],
"npmClient": "yarn",
"version": "independent",
"useWorkspaces": true,
}
  • npmClient
    我们显示声明了我们的包客户端为 yarn。
  • useWorkspaces
    让 Lerna 追踪我们 workspaces 设置的目录。
  • version
    independent 将每个子项目的版本号看作是相互独立的。

Verdaccio

一个 npm 包本地发布工具,使用 Verdaccio 可以在本地创建一个 npm 仓库作为代理用来测试 lerna。

1
npm install --global verdaccio

在您的项目根目录创建 .npmrc 文件

1
registry="http://localhost:4873/"

每当您执行 lerna publish 时,子项目所构建成的 package 将会发布在本地 npm 仓库中,而当您执行 lerna bootstrap 时,Verdaccio 将会放行,让您成功从远程 npm 仓库中拉取相应的代码。

lerna 常用命令

  • lerna init
    初始化 lerna 项目
  • lerna create
    创建一个新的由 lerna 管理的包。
  • lerna add axios
    增加模块包到最外层的公共 node_modules 中
  • lerna add A –scope=B
    增加模块包到 packages 中指定项目,例如将 A 模块增加到 B 项目中
  • lerna list
    显示所有的安装的包
  • lerna clean
    从所有包中删除 node_modules 目录
  • lerna publish
    在当前项目中发布包 publish 不会发布 package.json 中 private 为 true 的包
  • lerna bootstrap
    lerna 提供了可以将子项目的依赖包提升到最顶层的方式 ,我们可以执行 lerna clean 先删除每个子项目的 node_modules , 然后执行命令 lerna bootstrop –hoist。
    基于上面的命令我们的脚手架 duwork 也同步实现

物料按照 Monorepo 的方式组织代码结构

包的结构

1
2
3
4
5
6
7
8
9
10
- packages
- buttonA 组件 A
- __test__ //存放测试相关代码
- dist // 打包的目录
- doc // 组件的文档
- src // 存放源码
- index.js // 打包的入口文件
- LICENSE // 版权信息(MIT)
- package.json // 组件的描述信息
- buttonB 组件 B

开发流程

1
2
3
4
5
6
创建
duwork create -c
打包
duwork build
发布
duwork publish

正常的开发流程是每个人新建一个 git branch,通过代码审核之后进行合并。从上面可以看到整套流程在 monorepo 架构下变得非常清晰。

总结

通过本文我们介绍了 monorepo,以及最佳实践,monorepo 给我们带来的收益是非常可观的,可能您的场景并不试用 monorepo,所以说脱离实际情况谈最优解都是不切实际的想法,一个模式的提出必定面对解决一个问题,但是即使您的场景并不试用 monorepo,还是希望工具和思想也可以运用到工作之中。

参考文章

  • https://github.com/lerna/lerna/tree/main/utils/create-symlink
  • https://jishuin.proginn.com/p/763bfbd5505d

我们是如何保证前端项目质量

发表于 2021-10-04

本篇文章主要介绍一下我们团队开发过程中,如何做代码质量的把控和提升。

背景

代码质量是一个项目最重要部分,更好的质量的代码,能够产生更少的 bug,从而让项目的质量得到提升。业务快速增长,随之而来的前端需求激增,同时经过我们的分析,发现当前我们开发流程存在的一些问题:

  • 如何高效团队 CR?
  • 开发人员怎么衡量项目的质量?
  • 每个公司都有一个安全团队,他们负责公司一些代码安全,但是如果他万一发生一些问题的时候,通知到我们团队的时候,我们如何响应他们?
  • 团队沉淀、基建落地如何量化提供一个参考依据
  • 重构后的代码,除了一些我们通过一些生产或者性能对比,如何从代码层面来展示我们的量化结果。
    面对上面的问题,就需要我们通过一些技术手段来实现,通过代码的扫描来找到潜在的问题。我们构建出衡量项目质量的模型,通过模型对项目分析输出评估结果反馈对进行质量趋势分析来推动优化。

如何定义质量模型?

衡量质量基础是要可量化,结合我们目前项目常用的标准主要从、技术选型、开发规范、可维护性和安全四个方面:

  • 技术选型
    主要关注框架的技术选型是否先进、主流、收敛,例如:框架、请求库、UI 库。
  • 编码规范
    主要包含是否遵守了最佳实践和团队编码规范(readme
    、npm script、git 工作流、Mock、Eslint、团队最佳实践
    )。
  • 可维护性
    代码分层、模块化、圈复杂度、重复率、大文件、文档等得出可读性及复杂度评分。
  • 安全
    基于团队开发规范,检测可能存在的安全风险(外链、敏感成词、token、npm 包等)。

如何检测?

通过代码静态分析的方式检测代码质量,平台整体架构分成四部分:

  • 代码分析计算服务:用于仓库代码元数据采集和规则检测,质量计算
  • 数据平台后端服务:用于存储采集的数据,为前端展示提供数据
  • 数据平台前端服务:用于对采集的数据可视化分析展示
  • 数据平台 web 可视化界面
    系统工作流程主要分为两种:
  • 自动脚本服务定时拉取分析仓库代码,通过代码静态分析匹配规则生成检测结果计算出质量分,通过邮件、飞书等形式发送本次扫描报告,开发人员可以通过报告查看负责的项目详情做进一步处理。
  • 开发人员主动触发,通过指定项目分支主动触发分析脚本,根据检测结果分析。
    同时为了能够实现编码事前修改,我们将部分检测规则的 lint 继承的本地开发环境来保证做到事前检测提升效率。

质量分计算

根据质量模型把各项指标加权求和,可以得到一个工程的度总分,代表它的综合评估结果,同时对质量分的历史趋势做出预判。

实践效果

后续计划

未来我们会进一步的在检测维度上继续增加,同时持续优化可视化界面的用户体验,最终目标是打造一个完善的项目质量保障平台,能够为团队项目质量带来更大的提升。

cookies

发表于 2021-09-22

前置解释

  • 同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互,要求协议,端口和主机都相同。
  • HTTP 是一个无状态的协议,所谓无状态协议,简单的理解就是即使同一个客户端连续两次发送请求给服务器,服务器也识别不出这是同一个客户端发送的请求
  • Cookie 是客户端保存用户信息的一种机制,保存在客户机硬盘上。可以由服务器响应报文 Set-Cookie 的首部字段信息或者客户端 document.cookie 来设置,并随着每次请求发送到服务器。子域名可以获取父级域名 Cookie。

SameSite 来源

熟悉 web 安全攻击 CSRF 的都知道 CSRF 的本质实际上是利用了 Cookie 会自动在请求中携带的特性,诱使用户在第三方站点发起请求的行为。针对这个问题浏览器厂商对此给 cookie 加了 SameSite 属性。 Chrome 于 2015 年 6 月支持了该属性,Firefox 和 Safari 紧随其后也增加了支持。

同域和同站

  • 同域是指两个 URL 的协议/主机名/端口一致,判断比较严格。
  • 同站判断就比较宽松,根据 Mozilla 维护的公共后缀表(Pulic Suffix List[2])使用有效顶级域名(eTLD)+1 的规则查找得到的一级域名是否相同来判断是否是同站请求。
    例如:
    .org 是在 PSL 中记录的有效顶级域名,imnerd.org 则是一级域名。所以 https://blog.imnerd.org 和 https://www.imnerd.org 是同站域名。
    .github.io 也是在 PSL 中记录的有效顶级域名,所以 https://lizheming.github.io 和 https://blog.github.io 得到的一级域名是不一样的,他们两个是跨域请求。

SameSite 值

SameSite 属性有以下几个值:

  • SameSite=None:无论是否跨站都会发送 Cookie
  • SameSite=Lax:允许部分第三方请求携带 Cookie
  • SameSite=Strict:只发送相同站点请求的 Cookie,即当前网页 URL 与请求目标 URL 完全一致。

Strict

Strict 最为严格只发送相同站点请求的 Cookie,即当前网页 URL 与请求目标 URL 完全一致。浏览器做了仅针对 HTTPS 域名才支持 SameSite=None 配置。所以如果你要设置 SameSite=None 的话,则必须还要携带 Secure 属性才行。
例如对于一个普通的站点,如果一个已经登录的用户跟踪一个在电子邮件上的网站链接,这个站点将不会收到 Cookie ,用户访问该站点还需要重新登陆。

Lax

对于允许用户从外部链接到达本站并使用已有会话的网站站,默认的 Lax 值在安全性和可用性之间提供了合理的平衡。 Lax 属性只会在使用危险 HTTP 方法发送跨域 Cookie 的时候进行阻止,例如 POST 方式。同时,使用 JavaScript 脚本发起的请求也无法携带 Cookie。
lax
从上图可以看出,对大部分 web 应用而言,Post 表单,iframe,AJAX,Image 这四种情况从以前的跨站会发送三方 Cookie,变成了不发送。

none

浏览器会在同站请求、跨站请求下继续发送 Cookies,不区分大小写。

策略改变带来的影响

在 Chrome 80+ 版本中,SameSite 的默认属性是 SameSite=Lax。如果想要指定 Cookies 在同站、跨站请求都被发送,那么需要明确指定 SameSite 为 None。Chrome 也宣布,将在下个版本也就是 Chrome 83 版本,在访客模式下禁用三方 Cookie,在 2023 年全面禁用三方 Cookie。

  • 使用的三方埋点数据异常
  • 依赖 cookie 智能广告推荐失败
  • 跨站请求 cookie 丢失、统一登录、支付等失败

FLoC

FLoC 通过获取浏览器的浏览记录将用户加入 “相似” 用户的分组内,每个分组拥有对应的 FLoC ID。有别于之前使用 Cookie ID 标记直接将用户行为数据传递到广告商网站处理的方式。它提出了 document.interestCohort() 这个新的 API,将用户的行为在本地转换成了不带个人隐私的关键词,既规避了用户隐私问题,同时又解决了广告的精准投放问题。
谷歌在 Chrome 浏览器的 89 版本上小规模测试了代替 cookie 的 FLoC(Federated Learning of Cohorts, 联邦学习群组)技术。
微软、Opera、Firefox、GitHub,EFF 等都对此表达了反对意见。
如果不想启用你可以:

  • 给站点添加相关的拒绝标头:Permissions-Policy: interest-cohort=() 可以屏蔽
  • 使用表示拒绝的浏览器:Brave、Vivaldi
  • 使用表示暂时不会跟进的浏览器:Mozilla Firefox、Microsoft Edge

Cookie 作用域处理

  • 同域名
    当访问同域名下的页面时,Cookie 会正常携带,后台服务即可直接获取到对应的 SessionID 值,后台为单服务还是多服务无差别。
  • 不同子域名
    子域名间 Cookie 是不共享的,但各子域名均可获取到父级域名的 Cookie,即 m.dewu.com 与 jiawu.dewu.com 均可以获取 dewu.com 域名下的 Cookie。所以可以通过将 Cookie 设置在父级域名上,可以达到子域名共享的效果。
  • 完全不同域名
    不同域名是无法直接共享 Cookie ,可以选择将 Session ID (或 Token )保存到浏览器的 LocalStorage 中,也可以通过特殊手段将它写入多个其他域下的 LocalStorage 中,让前端在每次向后端发送请求时,主动将 LocalStorage 的数据传递给服务端。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 获取 token
var token = result.data.token;
// 动态创建一个不可见的iframe,在iframe中加载一个跨域HTML
var iframe = document.createElement("iframe");
iframe.src = "http://app1.com/localstorage.html";
document.body.append(iframe);
// 使用postMessage()方法将token传递给iframe
setTimeout(function () {
iframe.contentWindow.postMessage(token, "http://app1.com");
}, 4000);
setTimeout(function () {
iframe.remove();
}, 6000);
// 在这个iframe所加载的HTML中绑定一个事件监听器,当事件被触发时,把接收到的token数据写入localStorage
window.addEventListener('message', function (event) {
localStorage.setItem('token', event.data)
}, false);

前端通过 iframe+postMessage() 方式,将同一份 Token 写入到了多个域下的 LocalStorage 中,前端每次在向后端发送请求之前,都会主动从 LocalStorage 中读取 Token 并在请求中携带,这样就实现了同一份 Token 被多个域所共享。
也可以 CAS(Central Authentication Service)中央认证服务,是 Yale 大学发起的一个开源项目,旨在为 Web 应用系统提供一种可靠的单点登录方法。

参考链接

  • https://www.chromestatus.com/feature/5088147346030592
  • https://www.chromium.org/updates/same-site
  • https://hacks.mozilla.org/2020/08/changes-to-samesite-cookie-behavior/
  • https://github.com/mqyqingfeng/Blog/issues/157#
  • https://mp.weixin.qq.com/s/QZkOXhQIg2LqDWpi7mzCdQ

Top-level await

发表于 2021-08-09

背景: chrome 于 5 月 4 号发布 v9.1 版本 ,带来了 Top-level await 新特新,我们可以在模块顶级中使用 await,不需要在额外的加入 async。

什么是 Top-level await

如果我们试图在一个 async 函数外面使用 await 关键字,将会引起语法错误,例如:

1
2
3
<script type="text/javascript">
await Promise.resolve(console.log('执行'))
</script>

会产生如下报错:

await is only valid in async functions and the top level bodies of modules
但是在 v9.1 我们可以在模块顶级中使用 await,不需要额外的加入 async。

1
<script type="module">await Promise.resolve(console.log('执行'))</script>

并且它有着如下特点:

  • 顶层 await 在模块图的执行阶段发挥作用,此时所有的资源都已经获取并链接了,不存在资源被阻塞的风险;
  • 顶层 await 只限于在 ES6 模块中使用,不支持普通脚本或者 CommonJS 模块

先看一个例子

在使用 ES6 模块化的时候,经常会遇到需要导入导出的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// a.js
export function getSum(x) {
return x * x;
}
// b.js
import { getSum } from "./a.js";
let sum;
(async () => {
await sleep(1000);
sum = getSum(2);
})();
function sleep(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
export { sum };
// c.js
import { sum } from "./b.js";
console.log(sum); // undefined
setTimeout(() => console.log(sum), 2000); // 4

在这个例子中,我们在文件之间进行变量的导入导出。
通过分析代码我们可以发现第一次打印的都是 undefined 第二次打印得到的是 4。因为在 async 函数执行完毕之前,c.js 就已经访问了 b.js 导出的变量。name 怎么解决呢?

导出 IIFE promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// a.js
export function getSum(x) {
return x * x;
}
// b.js
import { getSum } from "./a.js";
let sum;
export default (async () => {
await sleep(1000);
sum = getSum(2);
return { sum };
})();
function sleep(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
// c.js
import promise from "./b.js";
promise.then(({ sum }) => {
console.log(sum); // 4
setTimeout(() => console.log(sum), 2000); // 4
});

我们将变量作为 async IIFE 的返回值返回。这样的话,c.js 只需简单地等待 promise 被 resolve,之后获取变量。
但是从静态分析、可测试性、工程学以及其它角度来讲,这种做法相比 ES2015 的模块化来说是一种显而易见的倒退。

Top-level await 怎么解决

我们仍然异步地初始化我们的导出,但是我们可以通过 Top-level await 来正常地使用 sum。
我们可以导入 b.js,而不需要知道它会异步初始化的导出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// a.js
export function getSum(x) {
return x * x;
}
// b.js
import { getSum } from "./a.js";
let sum;
await sleep(1000);
sum = getSum(2);
function sleep(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
export { sum };
// c.js
import { sum } from "./b.js";
console.log(sum); // 4
setTimeout(() => console.log(sum), 2000); // 4

await promise 被 resolve 之前, c.js 中任意一条语句都不会执行。

Top-level await 是怎么工作的

JavaScript 会静态地确认哪些模块是异步的,这些模块导出的 Promise 都会放到 Promise.all() 中。其余的导入仍然照常处理,并且拒绝(reject)和同步的异常都会被转为异步函数。

Top-level await 还可以做些什么

资源初始化

1
2
//connect() return a promise.
const connection = await dbConnector.connect();

动态加载模块

1
const data = await import(`./file${v}.js`);

资源加载备选方案

如果 CDN A 无法导入 Vue,那么会尝试从 CDN B 中导入。

1
2
3
4
5
6
let Vue;
try {
Vue = await import("https://cdn-a.dewu.com/Vue");
} catch {
Vue = await import("https://cdn-b.dewu.com/Vue");
}

参考

  • https://blog.bitsrc.io/why-should-you-use-top-level-await-in-javascript-a3ba8139ef23
  • https://javascript.plainenglish.io/javascript-top-level-await-in-a-nutshell-4e352b3fc8c8
  • https://2ality.com/2020/09/ecmascript-2021.html

如何实现个在线的按需的 ployfill

发表于 2021-06-14

polyfill 在英文中有垫片的意思,意为兜底的东西。在计算机科学中,指的是对未能实现进行的”兜底”操作。简单的说就是它可以让你可以毫无顾虑地使用最新的 JavaScript 特性,而不需要关注浏览器兼容性。
我们用最多的 polyfill 方式都是基于 core-js 正常使用方式一般是这两种: 1.通过 cdn 不考虑按需引入整个 core-js 文件非常大,最新的 3.15.2 版本大小 742 kB。 2.用 babel 处理,通过设置 @babel/preset-env 加上 useBuiltins 配置来按需裁剪 core-js。
目前主流做法都是选择方案 2 通过 @babel/preset-env 裁剪,事实上,在 CDN 的缓存的作用下收益会比方案 2 剪裁有更好的性能。

目标

根据 babel 的做原理,如果能可以根据浏览器的兼容性配置动态的生成裁剪后的 core-js,然后只引入 CDN 上剪裁后的 core-js 文件。

如何根据浏览器的兼容性动态剪裁

很简单,只需要按照 babel 的处理方式

1
2
import "core-js";
import "regenerator-runtime/runtime";

配一个 .browserlisrc 浏览器信息

1
chrome 86

利用 rollup 打包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import babel from "@rollup/plugin-babel";
import nodeResolve from "@rollup/plugin-node-resolve";
// CommonJS格式
import commonJS from "@rollup/plugin-commonjs";
import { terser } from "rollup-plugin-terser";
export default {
input: "index.js",
output: {
file: "dist.js",
format: "iife",
},
plugins: [
babel({ babelHelpers: "bundled" }),
nodeResolve(),
commonJS(),
terser(),
],
};

经过 rollup 打包后就会生成一个根据浏览器裁剪后的 ployfill 文件了。

如何云化

上面我们通过 babel 工具去生成 polyfill,然后上传到 CDN 并修改引用地址,当需要不同浏览器兼容性的时又要重复上面的操作,成本很高,所以我们希望运用云函数的能力来实现在线生成,生成的逻辑在线化,目前云服务上都提供 Serverless 计算服务像阿里云 函数计算 FC 。

改造

只需要将 rollup 调用方式改成 api,通过 url 传参方式传递浏览器信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const path = require("path");
const { encode } = require("js-base64");
const { rollup } = require("rollup");
const rollupConfig = require("./factory/rollupConfig");
const terser = require("terser");
const respond = (body, statusCode = 200) => {
return {
statusCode,
body,
isBase64Encoded: false,
headers: {
"Content-Type":
statusCode === 200
? "application/javascript; charset=utf-8"
: "text/plain; charset=utf-8",
},
};
};
exports.handler = async (event) => {
const { targets } = event.queryStringParameters;
if (!targets) {
return respond("No targets specified", 400);
return;
}
const key = encode(targets, true);
try {
const bundle = await rollup(rollupConfig(targets));
const {
output: [asset],
} = await bundle.generate({ file: "dist.js", format: "iife" });
const minified = await terser.minify(asset.code);
return respond(minified.code);
} catch (ex) {
console.error(ex);
return respond(ex.message, 500);
}
};

这样在线生成搞定了,但是每次生成时间巨长,真实启动的服务并不可用,所以我们需要在生成的前面加一个 CDN 配置,需要在上线前预热 CDN 就能达到秒开。当然业界也有成熟的在线服务例如 https://polyfill.io/v3/

现实

这样就完成了加载按需剪裁 CDN 资源的 ployfill,减少了资源大小,同时增加了缓存命中。
但是 chrome 85 版本之后更新了缓存策略,新的策略生效后会大大降低缓存命中,新的策略:

  • 之前,单资源的缓存是以 URL 来作为键,并不关心请求 URL 的来源;
  • 目前,缓存的键由 URL、顶部 window 域名、当前 window 域名三元组构成;
    其他浏览器支持情况:
  • Safari 实现了顶部 window 域名 + URL 的键控制机制;
  • Firefox 即将实现同粒度的缓存键;
    更新缓存机制后,缓存未命中的情形增加了 3.6%,整体网络加载字节数增加了 4%。

后续

虽然由于浏览器的缓存策略更新从而导致跨站缓存无法共享,但是本站的收益还是很大的。另外也可以采用更加细致的方案,把每一个 core-js 的所有 api 都在线化编译成独立的 ES 模块,然后项目采用 ES 模块的方式打包,让浏览器去加载最小粒度的 polyfill,精细度更大。

IntersectionObserver实现高性能的交互动画

发表于 2021-03-26

背景:以前我们在写滚动动画的时候通常判断元素是否显示通常会通过 getBoundingClientRect 获取位置,但是 getBoundingClientRect 将触发重排,利用此技术可能会很快造成性能瓶颈。

关于 IntersectionObserver

IntersectionObserver 是 web 领域众多观察器中的一个,是用来是观察元素和窗体相交的状态,很适合用在滚动交互事件,像是懒加载、埋点等场景。

除了 IntersectionObserver ,我们常用的还有用来观察 DOM 变化的 MutationObserver;用来观察元素的尺寸变化 ResizeObserver。

使用

  • 创建一个 intersection observer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const callback = (entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
console.log("元素曝光了");
}
};

const options = {
root: document.querySelector("#scrollArea"), // 指定根(root)元素,必须是目标元素的父级元素,如果未指定或者为null,则默认为浏览器视窗。
rootMargin: "0px", // 根(root)元素的外边距
threshold: 1.0, // 阈值为1.0意味着目标元素完全出现在root选项指定的元素中可见时,回调函数将会被执行。
};
const observer = new IntersectionObserver(callback, options);

const ele = document.querySelector(".animatedElement");

observer.observe(ele);

callback 回调函数将会在主线程中被执行, 其中 entry 属性:

1
2
3
4
5
6
7
entry.boundingClientRect 目标元素的区域信息
entry.intersectionRatio 目标元素的可见比率
entry.intersectionRect 目标元素与根元素交叉的区域信息
entry.isIntersecting 是否进入可视区域
entry.rootBounds 根元素的矩形区域信息
entry.target 被观察的目标,是一个DOM节点
entry.time 可见性发生变化的时间,相交发生时距离页面打开时的毫秒数.精度为微秒。

Intersection Observer 可用的方法有 observe(),unobserve() 和 disconnect()。

  • observe():用来添加观察者要监视的目标元素,观察者可以具有多个目标元素,但是此方法一次只能接受一个目标。
1
2
const element = document.querySelector('.animatedElement');
observer.observe(element);
  • unobserve():用来从观察的元素列表中移除元素。
1
observer.unobserve(element);
  • disconnect():用来停止观察其所有目标元素。观察者本身仍处于活动状态,但没有目标。在 disconnect() 之后,目标元素仍然可以通过 observe() 传递给观察者。
1
observer.disconnect();

利用 getBoundingClientRect 实现动画

通常是监听滚动,通过获取元素位置

1
2
window.addEventListener("scroll", () => checkForVisibility);
window.addEventListener("resize", () => checkForVisibility);

根据元素的位置触发动画

1
2
3
4
5
6
7
8
function checkForVisibility() {
const element = document.querySelector(".animatedElement");
const distTop = element.getBoundingClientRect().top;
const distBottom = element.getBoundingClientRect().bottom;
const distPercentTop = Math.round((distTop / window.innerHeight) * 100);
const distPercentBottom = Math.round((distBottom / window.innerHeight) * 100);
// do something
}

但是 getBoundingClientRect 将触发重排也就会造成性能问题,那 getBoundingClientRect() 为什么会触发 Reflow 呢?
在 chromium 的源码中搜索 getBoundingClientRect 可以看到代码:

1
2
3
DOMRect* Range::getBoundingClientRect() const {
return DOMRect::FromFloatRect(BoundingRect());
}

BoundingRect() 中调用了 UpdateStyleAndLayout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FloatRect Range::BoundingRect() const {
owner_document_->UpdateStyleAndLayout(DocumentUpdateReason::kJavaScript);

Vector<FloatQuad> quads;
GetBorderAndTextQuads(quads);

FloatRect result;
for (const FloatQuad& quad : quads)
result.Unite(quad.BoundingBox()); // Skips empty rects.

// If all rects are empty, return the first rect.
if (result.IsEmpty() && !quads.IsEmpty())
return quads.front().BoundingBox();

return result;
}

其中 UpdateStyleAndLayout 方法调用之后将会触发 LayoutTree 的重新渲染,也就是 Reflow。

利用 IntersectionObserver 实现动画

通过 Intersection Observer API 获取到两个元素重叠部分的准确值,只需几行代码即可设置根据元素可见性触发动画:

1
2
3
4
5
6
7
8
9
10
const animationObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
// do something
entry.target.classList.toggle("animating", entry.isIntersecting);
}
});

for (const element of querySelectorAll(".animatedElement")) {
animationObserver.observe(element);
}

IntersectionObserver-polyfill

对于不支持的浏览器可以引入 w3c 官方创建的 polyfill https://github.com/w3c/IntersectionObserver。

参考资料

• https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
• https://www.zhangxinxu.com/wordpress/2020/12/js-intersectionobserver-nav/

代码质量-代码重复

发表于 2021-02-07

背景:重复率是 oasis(衡量前端工程质量平台)的检测指标之一,对项目定期进行代码重复率检测是一个很有意义的事,可以帮助开发人员发现冗余代码,进行代码抽象和重构。

概念

代码重复(英文:duplicate code,也叫代码克隆)在程序设计中表示一段源代码在一个程序,或者一个团体所维护的不同程序中重复出现,是不希望出现的现象。为避免巧合,只有一定数量的代码完全相同才能判定为代码重复。 – 维基百科

代码重复一般分为两大类:

  • 句法重复
    两个函数的代码片段基本一致,只是参数名、函数名、字符等做了修改,或是多/少了某一行多代码,这种改动只是文本层面的改动,也被称为。
  • 语义重复
    两个函数的代码实现不同,从文本层面看区别较大,但实现的都是同一个功能。

基于句法重复、语义重复两大类,然后被划分为四小类:

  • 完全一致的代码或者只修改了空格和评论(句法克隆)。
  • 结构上和句法上一致的代码,例如只是修改了变量名 (句法克隆)。
  • 插入和删除了部分代码(句法克隆)。
  • 功能和逻辑上一致的代码,语义上的拷贝(语义克隆)。
    其中前三种为句法克隆,第四种为语义克隆。检测难度也是一次递增,目前对前三种代码重复的检测已颇为成熟; 而对第四种的检测准确率仍不高,无法达到应用标准。
    重复代码未必就是 copy-paste 产生的,可能就是不同人重复写的,就算是 copy-paste 产生的,在 paste 之后可能代码也会发生变化。

前端重复检测

检测代码重复的手段

  • Textual:代码片段以文本/字符串/词法的形式相互比较,并且只有在两个代码片段在文本内容方面确实相同时才被发现被克隆。
  • Token:在编译器的词法分析阶段,所有源代码行都被划分为一系列 Token。 然后将所有 Token 转换回 Token 序列。
  • Syntactic(句法分析):
    • 基于树: 提取的 AST 用于子树比较以识别相似区域。
    • 基于度量:通过源代码收集度量,然后使用这些度量为每个代码片段生成向量。然后使用向量对代码的相似度进行对比。
  • Semantic(语义克隆):主要检测代码片段不同,但功能相同的函数。
  • Learning : 通过机器学习和统计分析的方式来进行克隆检测。

检测代码重复的流程

  1. 将源码拆分为对比单元(comparison units, 如 class,function,block,statement)。
  2. 将对比单元转化为中间表达(IR, Intermediate Representation,如 token,AST, PDG)。
  3. 再对这些对比单元的 IR 进行 match detection(对比),通常是将对比单元组成 clone pair 的形式:一次对比两个(c1, c2)或是多个(c1, c2, c3)。

检测工具

  • jsinspect
    利用 babylon 对于 JavaScript 或者 JSX 代码构建 AST 语法树,根据不同的 AST 节点类型,标记相似结构的代码块,检测效果比较好。
  • jscpd:其重复率判定依据为一定长度标识符的 MD5 值是否相同,虽然结果没有 jsinspect 好,但是支持文件格式广泛,如 java、oc、js、jsx、vue、ts、less 等。
  • PMD:支持 js 文件检测,也可以自己开发扩展包来解析指定的语言(需要安装 java 环境,npm 不支持,无法匹配前端脚手架)。

由于前端文件类型众多所以 oasis 最终选择了 jscpd 作为代码重复率检测工具。

重复率标准

重复率标准的制定需要参考的因素有很多,例如 tokens、项目、架构、时间等等,目前 oasis 平台是 10%,具体数值还会在运行一段时间后观察调整。
代码重复率 = 重复的行数 / 扫描的文件总行数。

降低重复率

应用中,可以参考《重构》(Refactoring)中说的三次原则(Rule of three),即同样的代码将要出现第三次前,考虑抽象它,复用它。具备小而美的工程思想,随着前端生态的完善,前端的组件化开发效率已经有了很大的提升。

总结

代码重复会让项目失控,重复代码不仅让代码量大增,造成编译速度慢,占用大量存储空间,造成了代码可维护性差,代码质量下降。通过重构从而降低代码的耦合性,这样不仅提高代码的灵活性、健壮性以及可读性,也方便后期的维护。我们也不是单纯地追求公共代码地完全剥离化,过度的抽象反而会降低代码的可读性与可理解性。

附

oasisV0.1 beta 版本目前已经完成大文件、重复代码块,圈复杂度、最佳实践、基础库落地、 npm 依赖库的安全分析功能,接入 0 成本目前已经上线欢迎大家体验、建议、吐槽。

12…7
lennonover

lennonover

一丿口石砳磊

70 日志
28 标签
© 2022 lennonover
由 Hexo 强力驱动
主题 - NexT.Muse