blog icon indicating copy to clipboard operation
blog copied to clipboard

浏览器插件开发之-sheetToCode

Open Rhan2020 opened this issue 2 years ago • 1 comments

背景

在开发中经常会遇到需要将配置数据转换成代码的情况,如果只有几个配置的话还好, ( ̄▽ ̄)~* 我们直接 ctrl + cctrl + v 操作就好了。(ಥ_ಥ) 然而,产品大佬通常只会甩一个几百行数据的谷歌表格给到前端,让前端自行录入奖励配置、图片配置等映射关系。 four.png o(´^`)o作为一枚有追求(能动脑就不动手)的切图仔,肯定不能一行行录入或者复制到记事本改数据格式的,费时耗力不说还容易出错。若针对每个谷歌表格都写一个读取脚本,显然也是不可取的(每次都要改代码也不行),所以这时候就需要一个高效的开发工具可以满足:

  • 选中谷歌表格的数据内容,按下神秘按键 ctrl + c,就得到我们需要的数据格式 JsonArray
  • 能跨浏览器页面使用。

综上,打算撕一个浏览器插件工具来提效我们的研发,让开发者把时间花在更有意义的事情上。

准备工作

作为还没入门过浏览器插件开发的萌新,于是就去扫盲了下 chrome 插件开发的基础知识:

什么是 chrome 插件?
  • chrome 插件其实跟普通的 web 应用一样,是由 html + css + js 经过 zip 打包组成的,在这过程中 chrome 提供了丰富的浏览器级别的 api 供开发者调用,提供一个跨页面脚本执行环境,来满足开发者的定制化需求。chrome 插件可以出现在地址栏、工具栏中。
chrome 插件能做什么?
  • 书签控制
  • 下载控制
  • 窗口控制
  • 标签控制
  • 网络请求控制
  • 各类事件监听
  • 自定义原生菜单
  • 完善的通信机制
  • 等等
chrome 插件由什么组成?
  • 如下是一个简单的浏览器插件目录及其对应的说明:
chrome-plugin-demo
├── background.js  // 后台执行脚本
├── images
│   └── 128.png    // 插件图标
├── manifest.json  // 清单文件,用于配置插件相关信息,声明脚本事件、用途,声明资源信息等
├── popup.html     // 点击插件图标后的弹窗页面
└── popup.js       // 弹窗页面执行的 js
  • backgroundcontent_scriptspopup 之间的关系: five.png
chrome 插件开发流程是什么样的?
  • 普通的开发流程是:
    • 按照官方开发文档创建对应文件目录
    • manifest.json 中声明插件信息、各资源、脚本用途等
    • 然后再开发对应的脚本功能
    • 编码保存后使用浏览器扩展中加载已解压的插件功能来进行预览和查看。
  • 工程化开发流程:
    • 相较于原始的开发流程,本次参考了Jcanno/vue-chrome-extension 搭建好的开发模板。好处是我们可以通过开发 vue 页面的形式来写 UI 跟交互,模板会给我们打包成对应的 manifest.jsonbackgroundcontent_scriptsbrowser_action 等。
    • 而且还提供了 webpack hot reload 的方案,原理是通过 chrome.tabs.query 去查找当前的活动标签页,再使用 chrome.tabs.reload 进行刷新页面,不用每次保存完再去按 F5 刷新页面跟插件了。

着手开发

1、基础配置

首先,我们先在 manifest.json 配置下插件相关的信息:

"manifest_version": 2, // 清单版本
"name": "chrome-sheetToCode", // 应用名称
"description": "transform sheet to code", // 应用描述,会显示在插件管理页面中
"version": "1.0.0", // 插件版本号
"browser_action": { // 浏览器工具栏配置
  "default_title": "chrome-sheetToCode", // 弹窗标题
  "default_icon": "assets/logo.png", // 插件图标
  "default_popup": "popup.html"  // 点击插件图标后,显示的UI弹窗页面
},
"icons": { // 不同尺寸下的图标
  "16": "assets/logo.png",
  "48": "assets/logo.png",
  "128": "assets/logo.png"
}

background 是运行在插件后台的脚本,整个浏览器插件的生命周期都会存在,且提供了丰富的 chrome API 供调用,这里配置一下我们的background脚本文件(虽然没用到):

// 后台执行脚本文件配置
"background": {
  "scripts": ["js/background.js"]
},

然后我们再配置下content_scriptscontent_scripts是页面执行脚本,属于页面的一部分,只是浏览器在打开页面的时候自动帮你执行而已,跟页面共用一个 dom,使得我们可以操作页面元素,在第三方网页执行我们自己的 js 代码。我们还可以自定义 js 加载的条件,这里我们配成谷歌表格的域名 https://docs.google.com/,说明只有匹配到这个域名的时候才会执行我们的 js,且在页面加载完成后进行执行:

"content_scripts": [
  {
    "matches": [
      "https://docs.google.com/*" // 映射到谷歌文档的域名才会加载js
    ],
    "css": [
      "css/content.css"           // css 路径
    ],
    "js": [
      "js/content.js"             // 注入的 js 文件路径
    ],
    "run_at": "document_end"      // js 文件加载的时机
  }
],

2、功能开发

开发 popup 弹窗

由于弹窗页面其实只提供了一个入口,用来触发我们注入到页面的代码,所以这里只写了一个按钮来控制页面侧边栏的显示隐藏:

<template>
  <div>
    <button id="open-btn" @click="handleClick">open panel</button>
  </div>
</template>
#open-btn {
  color: black;
  border-radius: 20px;
  height: 40px;
  width: 100px;
  padding: 10px 20px;
  margin: 20px auto;
  display: flex;
  align-items: center;
  justify-content: center;
}

再声明下点击回调,使用 chrome.tabs.sendMessage 来与当前选中页面的 content_scripts 脚本进行通信:

  methods: {
    handleClick() {
      chrome.tabs.query(
        {
          active: true,
          currentWindow: true
        },
        tabs => {
          let message = {
            info: "open-panel"
          };
          chrome.tabs.sendMessage(tabs[0].id, message, () => {
          });
        }
      );
    }
  },

写完之后长这样: popup.png

开发 content_scripts

首先我们写 UI 界面,让它position:fixed;固定在谷歌表格的最右边、最顶层,并给个关闭按钮: first.png 然后在这个侧边栏渲染到 dom 的时候使用 chrome.runtime.onMessage 添加事件监听,用来接受 popup 弹窗的指令:

mounted() {
  chrome.runtime.onMessage.addListener(request => {
    if (request.info === "open-panel") {
      // 显示侧边栏面板
      this.showPanel = true;
      // 调用页面的 focus 来关闭 popup
      window.focus();
    }
  });
}

监听整个谷歌文档页面的 copy 事件:

created() {
  document.addEventListener("copy", this.copyEvent);
}

待触发ctrl+c回调后我们就可以从用户的剪切板拿到选中的单元格元素,并给到封装好的 sheetToCode 插件来进行数据格式化处理:

copyEvent(event) {
  var clipboardData = event.clipboardData || window.clipboardData;
  if (!clipboardData) {
    return;
  }
  var text = clipboardData.getData("text/html");
  if (!text) {
    return;
  }
  const result = sheetToCode(text);
  // 将处理后的返回结果更新到当前实例
  this.data = result;
}

封装 sheetToCode 插件来应对多种情况的数据格式处理:

export default function htmlTransform(text) {
  const arr = htmlToArr(text);
  if (arr.length === 0) {
    alert(
      "操作可能失败!如果文档表格有背景色,请将删除背景色或者将该文档**剔除格式**拷贝到新文档"
    );
  }
  const dbkeyJson_row = arrToJson_doublekey(arr, "row");
  const dbkeyJson_col = arrToJson_doublekey(arr, "col");
  const json_row = arrToJson(arr, "row");
  const json_col = arrToJson(arr, "col");
  const dbkeyPhpRow = dbkeyJsonToPhpCode(dbkeyJson_row);
  const dbkeyPhpCol = dbkeyJsonToPhpCode(dbkeyJson_col);
  const jsonPhpRow = jsonToPhpCode(json_row);
  const jsonPhpCol = jsonToPhpCode(json_col);
  return {
    dbkeyJson_row,
    dbkeyJson_col,
    json_row,
    json_col,
    dbkeyPhpRow,
    dbkeyPhpCol,
    jsonPhpRow,
    jsonPhpCol,
    xmlObj,
  };
}

我们从用户剪切板拿到表格数据后,可以通过遍历每个单元格生成带详细信息的数组列表:

function htmlToArr(text) {
  let dom = document.createElement(`div`);
  dom.innerHTML = text;
  dom = dom.querySelector("table tbody");
  if (!dom) {
    return [];
  }
  // raw arr
  const cellArr = Array.prototype.map.call(dom.children || [], (it) => {
    return Array.prototype.map.call(it.children || [], (cell) => {
      return {
        row: cell.getAttribute("rowspan") - 0 || 1,
        col: cell.getAttribute("colspan") - 0 || 1,
        val: cell.innerText,
      };
    });
  });

  // map arr
  for (let i = 0; i < cellArr.length; i++) {
    const row = cellArr[i];
    for (let j = 0; j < row.length; j++) {
      const cell = row[j];
      const id = cell.id || `${i}-${j}`;
      if (cell.col > 1) {
        row.splice(j + 1, 0, {
          ...cell,
          id,
          col: cell.col - 1,
        });
      }
      if (cell.row > 1) {
        cellArr[i + 1].splice(j, 0, {
          ...cell,
          id,
          row: cell.row - 1,
        });
      }
      row[j] = {
        id,
        val: cell.val,
      };
    }
  }
  return cellArr;
}

生成普通 json 格式的数据:

// 单键json
function arrToJson(arr, major = "col") {
  if (arr.length < 2 || arr[0].length < 2) {
    return {};
  }
  if (major === "col") {
    return arr.reduce((res, cur) => {
      res[cur[0].val] = cur.slice(1).map((it) => it.val);
      return res;
    }, {});
  }
  if (major === "row") {
    const body = arr.slice(1);
    return arr[0].reduce((res, cur, idx) => {
      res[cur.val] = body.map((it) => it[idx].val);
      return res;
    }, {});
  }
}

因为在生成的数据格式中,还有键值对的映射形式,所以我们还需要处理合并单元格的情况:

// 双键json
// row0,col0不应该有重复键 todo
function arrToJson_doublekey(arr, major = "col") {
  if (arr.length < 2 || arr[0].length < 2) {
    return {};
  }
  if (major === "row") {
    const body = arr.slice(1);
    const obj = arr[0].slice(1).reduce((res, cur, idx) => {
      const colsObj = body.reduce((cols, it) => {
        cols[it[0].val] = it[idx + 1].val;
        return cols;
      }, {});
      res[cur.val] = colsObj;
      return res;
    }, {});
    return obj;
  } else {
    const subKey = arr[0].slice(1);
    const body = arr.slice(1);
    const obj = body.reduce((res, cur) => {
      const rowObj = cur.slice(1).reduce((rows, it, idx) => {
        rows[subKey[idx].val] = it.val;
        return rows;
      }, {});
      res[cur[0].val] = rowObj;
      return res;
    }, {});
    return obj;
  }
}

适配一下生成 php 代码格式的数据:

// 双键json转php
function dbkeyJsonToPhpCode(json) {
  const keys = Object.keys(json);
  const subKeys = Object.keys(json[keys[0]]);
  return keys
    .map((it) => {
      const item = json[it];
      const val = subKeys
        .map(
          (subKey) =>
            `\t"${(subKey || "").replace(/"/g, '\\"')}"=>"${(
              item[subKey] || ""
            ).replace(/"/g, '\\"')}"`
        )
        .join(",\n");
      return `"${(it || "").replace(/"/g, '\\"')}" => [\n${val}\n]`;
    })
    .join(",\n");
}

// 单键json转php
function jsonToPhpCode(json) {
  const keys = Object.keys(json);
  const rows = keys
    .map((it) => {
      const item = json[it];
      return `\t"${(it || "").replace(/"/g, '\\"')}" => [${item
        .map((it) => `"${(it || "").replace(/"/g, '\\"')}"`)
        .join(", ")}]`;
    })
    .join(",\n");
  return `[\n${rows}\n]`;
}

最后来看一下我们最终生成的数据格式: second.png php 代码格式: third.png

打包

我们可以使用crx来进行插件的打包,首次使用浏览器打包的话会生成 .pem 密钥,这个对我们之后发布到插件商城、更新插件版本都要用到,所以需要妥善备份。这里我们可以直接运行 crx 脚本来进行打包:

npm run build:crx
const fs = require("fs");
const path = require("path");
const manifest = require(path.resolve(__dirname, "../chrome/manifest.json"));
const ChromeExtension = require("crx");
const crxName = `${manifest.name}-v${manifest.version}.crx`;
const crx = new ChromeExtension({
  privateKey: fs.readFileSync(path.resolve(__dirname, "../../dist.pem")),
});

crx
  .load(path.resolve(__dirname, "../../dist"))
  .then((crx) => crx.pack())
  .then((crxBuffer) => {
    fs.writeFile(crxName, crxBuffer, (err) =>
      err
        ? console.error(err)
        : console.log(`>>>>>>>  ${crxName}  <<<<<<< 已打包完成`)
    );
  })
  .catch((err) => {
    console.error(err);
  });

本来打算发布到谷歌商城的,但发现需要绑定信用卡支付,再缴纳个 $5 来进行开发者注册。觉得太麻烦就算了,反正解压后的代码也同样可以加载并使用,此工具也更新了使用文档到公司内部 wiki 供大伙使用。

总结

到此我们的浏览器插件算是开发完成了,本人也是从上午还是 0 基础开始的浏览器插件开发,通过一顿 google 的学习、参考多方入门文章、官方文档,到下午插件撸出来可以正常使用才正式完工。若文中有理解/描述不当处欢迎及时指正,共同交流学习。 同时也印证我们身为前端开发者的学习能力、资源检索能力、总结输出能力也需要在实践中不断培养和锻炼,这些都会在今后的职业生涯中不断累积个人的影响力,提升自己的核心竞争力。

Rhan2020 avatar Oct 27 '21 12:10 Rhan2020

请问选用转换为php格式的原因是项目中用的是php吗?

fayeah avatar Dec 28 '21 02:12 fayeah