如何成为一个优秀的复制粘贴工程师

2,577 阅读12分钟

组件复用的困境

在平时的搬砖过程中,当我们拿到新需求的设计稿时,为了加快开发的速度,通常会看看是否有曾经写过的组件可以复用。如果能找到类似的组件,有很大概率可以在原有的基础上修修补补就能直接用了。

对于自己最常开发的工程,当看到设计稿往往可以立马想起来,某个可以复用的组件位于工程的哪个位置。但是随着工程的壮大,从 10+个路由配置,增长到 100+甚至 200+的时候,就有些力不从心了。

另外一个问题是,团队中其他同学很可能已经开发过和设计稿的非常相像的组件,但由于你不熟悉别人的工程,无法快速地找到哪个组件是可以拿来使用的。即使知道是哪个组件,这个组件可能会有一堆工具代码的依赖,想要完整地将组件搬到目标工程中,需要将组件依赖的文 件一并复制。有可能同事还在忙着上线,没法及时给你回应,这样一来沟通成本就高了。

转转是一个二手电商平台,如果仔细观察每条业务线的页面 UI,可以发现它们大部分遵循统一的 UI 风格。实际上很多电商平台都有类似的UI结构。下图是转转三条业务线的首页,仔细观察就可以发现,它们有非常多相似的地方:顶部的搜索框、金刚位、轮播图、运营卡片、商品卡片。虽然它们非常相似,却在一些细节上有微小的差异,并且这些页面由不同的前端同学负责,就导致了相似的功能被不同的成员多次重复实现。

那么是否可以就这个问题添加一些自动化的流程,来提升代码的复用率,增强搬砖的幸福感呢?

方案设计

首先明确目的:在新需求出现后,让前端同学知道是否存在已有的相似组件,并迅速得到相应的代码文件。

那么问题就转化为,如何将设计稿与已有组件进行对比,找出相似的组件。想要比较,就必须有量化的标准。转转内部的设计师主要使用 sketch 作为设计工具,而 sketch 内部使用 json 格式存储数据。如果将 .sketch 文件后缀改为 .zip,然后使用压缩工具解压,就会发现里面实际上存储了大量的 json 格式数据,描述了节点之间的嵌套关系以及节点的样式。那么就可以采取某种算法,根据 sketch 文件中的数据,产出一份可以用于比较的量化标准,这样就完成了设计稿的处理。

那么该如何处理各个工程中已存在的组件呢?事实上对于这些组件,只要拿到它们实际渲染出来的 dom 结构,就可以根据 dom 结构来提取出一份特征数据。有了设计稿和已有组件的数据,后续只要比较这两份数据,计算出相似度,就可以快速找到前端开发需要的组件代码了。 对于上面的说明,可以类比哈希算法来帮助理解。JS 中常使用对象,以 key-value 的形式来存储数据。哈希算法会根据一个 key 值,产生一个唯一的结果,并根据这个结果将数据存储对应的地址上。这个根据 key 产生结果的过程,就类似于上文根据设计稿的节点/dom 结构,产出可用于比较的特征数据。

假设已经根据一份设计稿,找到了 5 个相似组件后,怎么让前端同学知道这些组件中,哪一个组件是 ta 最需要的那个呢?如果让 ta 看一遍每个组件的代码再决定,那效率就太慢了。所以需要对每个组件进行截图,当看到 5 个组件的截图,哪个与设计稿长得最像,基本上就是 ta 需要的组件了。

总结一下方案,就是**「根据设计稿和已有组件的 dom,产出一份用于比较的数据,初步筛选出几个相似组件,然后让业务开发根据截图与代码,在初筛组件中挑选合适的组件,导入到自己的工程。」**

架构设计

整体涉及到四个部分:

  • 浏览器运行时 分析组件 dom 特征、截图,将结果传递给 Vite 插件。

  • Vite 插件

    • 提取组件的所有依赖,发布为 npm 包。
    • 整合 dom 特征和截图,上传数据到服务端。
  • npm 服务器

  • 服务端(马良后台):将数据存储到 MySQL。 详细的数据流见下图,图中的“组件哈希”和上文的“组件特征数据”是同一含义。 架构设计图

下面解释一下这张图可能产生的几个疑问:

Q: 浏览器中是如何递归 Vue 组件的?

A:Vue 在 mount 过程中,会将组件的实例放在 dom 的 __vue__ 属性上。由于 Vue 应用的惯例是将根组件挂载到 #app 上,所以可以通过 const root = document.getElementById('app').__vue__ 获取 Vue 应用的根组件。然后通过 root.$children 递归访问所有的 Vue 组件。

Q:如何对组件进行截图?

A:截图方案考虑过 puppeteerhtml2canvas,在这个场景中,html2canvas 更符合需求。因为 html2canvas 虽然在性能可能比不上 puppeteer,但它的优点是可以生成指定 dom 的截图。假设页面中有一个全屏弹窗,将所有的 dom 元素覆盖了,html2canvas 依然可以排除弹窗的影响,生成任意出现在 html 中的 dom 元素的截图。

Q: 为什么会有 Vite 插件这个环节?

A: 一个组件有很多的依赖,在收集组件代码时,必须连同其依赖一起收集,所以需要构建一个依赖树。构建依赖树有两种办法,一个是通过 ast 分析所有的导入语句,另一个是通过构建工具已有的依赖树。由于通过 ast 分析的实现有较多的困难,比如导入语句中存在别名,文件类型众多(less、scss、vue、js)。如果希望有较高的准确率,需要解析构建工具的别名配置,并使用每种文件类型对应的 ast 转换库。其实现难度远大于借助构建工具能力的实现方案。 那么剩下的就是 Vite 插件和 Webpack 插件的抉择。由于之前给团队主体项目接入过 Vite,熟悉程度大于 Webpack,故选择 Vite 插件作为依赖收集的手段。

Q:浏览器运行时代码如何与 Vite 插件进行通信?

A:Vite2.0 使用了 connect 框架作为 http server,该框架的用法与 express 非常类似,支持相同的中间件语法。在为 connect 引入 body-parser 等必要的中间件后,就可以像写服务端代码一样,为其添加接口。

遍历组件

这一步的目的是得到三个数据:

  • 组件的截图
  • 组件的特征
  • 组件在工程中的路径 截图和特征好理解,但是为什么需要组件在工程中的路径呢?这就需要注意此时代码运行的环境。由于需要访问 dom 数据,这段代码必须要在浏览器中运行。但是在浏览器中获取的组件实例,如何与工程中的 .vue 文件对应起来呢?答案就是在开发环境中,Vue 实例上 vm.$options.__file 属性就是 vue 文件在工程中的相对路径(如下图)。 但是这个属性是哪来的呢?通过阅读相关的插件和 Vue 的源码可以知道为什么(后两段包含 Vue 源码分析,不熟悉的同学可以跳过)。 在 Vite 中对 Vue2 的支持是由 vite-plugin-vue2 插件提供的,在它的源码中有这么一段:
// Expose filename. This is used by the devtools and Vue runtime warnings.
if (!options.isProduction) {
  // Expose the file's full path in development, so that it can be opened
  // from the devtools.
  result += `\n__component__.options.__file = ${JSON.stringify(
  path.relative(options.root, filePath).replace(/\/g, '/')
  )}`
}

可以看出,该插件在非生产模式下,会将组件相对于根目录的路径,暴露在 __component__.options.__file 中。这里的 __component__ 就是 Vue 组件的构造函数。熟悉 Vue2 源码的同学应该知道,Vue2 中通过 Vue.extend 实现了类似于 es6 extends 关键字的功能。在 Vue 子组件构造函数中有这么一段逻辑:

function initInternalComponent (vmComponentoptionsInternalComponentOptions) {
  // vm.construct 就是 Vue.extend 的返回值。
  // vm.$options 的原型就是构造函数的 options 对象。
  const opts = vm.$options = Object.create(vm.constructor.options)
  // 省略后面的代码
}

梳理一下流程,就是:

  • vite-plugin-vue2 向构造函数的 options 属性注入文件在工程中的相对路径。
  • vm.$options 对象的原型是构造函数的 options 对象。
  • vm.$options 本身没有 __file 属性,但是通过原型访问到了构造函数的 options 对象中的 __file 属性。 所以在浏览器环境中,可以通过 vm.$options.__file,来访问到 vite-plugin-vue2 注入的文件相对路径。

下面展示遍历 Vue 组件实例的伪代码:

// Vue 根组件实例
const appInstance = document.querySelector('#app').__vue__
/** @type {import('vue-router').default} */
// 获取 Vue router 实例
const router = appInstance.$router
// 当前路由匹配的Vue组件实例
const pageVM = router.currentRoute.matched[0]?.instances?.default
if (pageVM) {
  traverseAppInstance(pageVM)
}

function traverseAppInstance(vm) {
  // 截图伪代码
  const image = html2canvas(vm.$el)
  // 组件特征分析伪代码,具体算法见后文。
  const characteristic = analyzeComponent(vm.$el)
  const filePath = vm.$otpions.__file

  // 将数据传递给 Vite 插件
  sendToVitePlugin({
    image,
    characteristic,
    filePath
  })
}

特征提取与比较算法

在实际的实现过程中,为了快速实现效果,借助了转转内部名为马良的 d2c (design to code) 平台(一个可以将 sketch 文件转化为组织良好的代码的平台)但这并不决定性地影响本文想法的整体实现。

想要知道设计稿中的一个模块和组件库里面的哪些模块是相似的,我们就需要一个对比算法,其实最简单的方案是相似图像对比。

方案一:相似图像对比

使用图像相似对比相关的算法,我们虽然可以比较容易的找出相似组件,但这种方案在实际场景中会有明显的缺陷:我们是在真实页面中提取组件,而这时组件里面的数据已经使用了真实的业务数据,会跟设计稿的内容存在很大差异,这就导致相似图像对比的方案几乎无法发挥作用,所以方案一不可取。

方案二:组件特征对比

我们可以用设计稿生成代码的结构样式特征与组件来对比,这里我们看一个例子。 设计稿例子

上图左侧是设计稿中的模块,右侧是项目中真实的组件,我们人脑会根据自然思维认定这两个模块是相似的模块,而这个思维过程是什么样的呢,我们可以将上图内的信息进行抽象和提取,以骨架屏的形式绘制成下图: 骨架屏 这样是不是就更确信他们是相似了呢?

基于这个简单的抽象过程,我们来实现特征对比算法。

步骤一:特征提取

任何一个模块的实际开发,工程师可能会有多种层级嵌套方式来实现,而不同人可能会有不同的嵌套设计,因此我们需要过滤掉层级这个维度,我们要首先通过遍历到达一个 DOM 结构的所有叶子节点,也就是 DOM 节点的最底层,而我们通常情况下,叶子节点可能是以下几种类型:

  1. 文字
  2. 图片
  3. 背景图
  4. 有视觉占位的样式节点,例如:按钮、图形、表单等

类型 4 稍复杂,我们先以类型 1、2、3 为例,我们需要计算提取以下特征:

  1. 节点类型:可能会有 text、img、bgimg
  2. 节点关键样式:
  • 字体相关样式
  • 图片相关样式
  • 背景图相关样式
  1. 每个叶子节点**「相对于组件中心点」**的坐标 第 3 点,为什么我们要提取节点相对于中心点的坐标呢,这就要涉及到对比算法:

步骤二:对比算法

特征对比算法的整体思路是:

  1. 对比两个组件中相似类型的叶子节点比例
  2. 对比每个叶子节点在另一个组件中有类型相同且位置相同的叶子节点的比例
  3. 对比在类型和位置都相同的情况下,关键样式也相似的叶子节点的比例
  4. 通过一个打分算法计算出两个组件的相似分数
  5. 最后通过一个权衡算法挑选超过一定得分的组件认定为相似组件

其中思路 2 中的关键点就是位置相同,而实际对比中我们会发现,即使是同一个组件在不同页面可能会有不同的尺寸和相对位置,我们先将上面的骨架屏右侧图放大一下:

这样可以很清晰的看到,虽然他们大体相似,但位置几乎不一样,因此我们就不能用绝对位置来作为衡量标准,那么我们可以用相对于中心的的坐标来衡量:

image-20220309155514143

image-20220309155514143

我们计算出每个叶子节点相对于中心的的坐标(offsetX,offsetY),然后把两个组件缩放到宽度一致的尺寸:

image-20220309155945056

image-20220309155945056

这时我们再去比较相对位置是不是就更容易一些?当然实际算法会远比这个复杂。 细心的同学会发现,两个组件其实并不完全一致,右侧组件多了一个 HOT 图标:

这一定程度上会影响相似评分,在上面的算法思路中我们都会提到,我们计算的是各项条件相似的比例,也就是我们可以知道任何一个条件下每个节点和另一个节点相似和不相似的比例分别是多少,那就依赖最终的打分和权衡算法来判定对比结果了,在上面这个 case 中,实际上的分数并不影响我们对相似的判断。

Vite 插件

Vite 插件在获取到浏览器发送的数据后,通过 filePath(文件的相对路径)定位到具体的 .vue 文件,并分析其依赖。

在 Vite 插件中,可以获取到 Vite 内部的模块依赖表,里面是几个 map,可以通过文件路径获取到对应的模块。 以图中的 src/main.ts 模块为例,importedModules 就是 main.ts 文件引入的所有依赖。通过浏览器发送的 filePath 属性,获取对应的 vue 文件模块。vue 文件会引入其他文件,其他文件又会引入另外的文件,所以模块其实是一棵 n 叉树。遍历这棵 n 叉树,就可以 vue 文件的所有依赖文件。拿到所有的文件内容后,通过 npm publish 命令,可以将其作为 npm 包发布。后续想要使用这个组件的话,下载这个 npm 包即可。

这个过程会有很多细节,比如一个页面有很多组件,如果每个组件都遍历一次,就会有很多组件被重复遍历到,存在不必要的性能损耗。采用二叉树的后序遍历可以达成一次遍历,就收集完所有 vue 组件各自的依赖。再比如,每个 vue 组件的依赖文件不同,npm publish 通常被用于某个固定工程的发布,发布文件的范围是不变的。但当前的场景是需要动态地决定需要发布的文h还有许许多多的其他细节问题。

Nodejs 服务端 & MySQL

服务端是用于最终存储数据的地方,包括截图 url、组件特征、npm 包名等全过程中收集的所有数据。因为只是简单的 CRUD,这里不再赘述。

效果展示

在做完上述的一切后,当前端同学拿到一个新的设计稿,上传到马良系统上,就会和已提取的所有组件特征做一个相似度的匹配,推荐给前端同学使用。在马良系统上的最终形式是:

总结

在整个过程中,通过运行时代码提取了组件的特征和截图,通过 Vite 插件获取了 Vue 组件代码以及所有的依赖,并整合数据上传至 Nodejs 服务端,存储到数据库中。最终在马良系统中,用户上传一份 sketch 设计稿,通过对比已有组件与设计稿的相似度,向用户推荐相似的组件。

对用户而言,在一份已经写好模板和 css 的文件上修改,比从零开始的速度要快得多。并且这打破了各种不同的工程之间的代码分享壁垒,让业务页面的开发更加顺畅。

致谢

本文提及的方案由@张所勇 (组件特征提取与比较) @强敏 (浏览器端代码) @陈亦涛 (Vite 插件)共同完成。