本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2020年09月05日 统计字数: 8561字 阅读时间: 18分钟阅读 本文链接: https://soulteary.com/2020/09/05/use-nodejs-to-customize-your-technology-radar-part-1.html ----- # 使用 Node.js 定制你的技术雷达:上篇 最近在梳理团队项目依赖和各个项目技术栈的时候,发现使用技术雷达的形式来进行呈现和管理是个不错的点子。但是没找到维护简单,界面又清爽好看的 UI。 正巧访问到 Thoughtworks 新版本技术雷达,发现在线版本十分漂亮,远比官方提供的生成版本漂亮。 那么,记录下折腾这个技术雷达的过程吧,本篇是第一篇,聊聊如何使用 Node.js 完成一个上述雷达的本地版本。 ## 写在前面 访问 [官方地址](https://www.thoughtworks.com/cn/radar/techniques) 可以看到下面的新版技术雷达的界面。 ![Thoughtworks 在线版技术雷达](https://attachment.soulteary.com/2020/09/05/online-version.png) 点击导航菜单中的【构建你的雷达】,即使我们不自己准备数据,使用官方默认数据,跟随官方提示,一路 “Next”,便会得到[下图样式/交互的定制雷达](https://radar.thoughtworks.com/?sheetId=https%3A%2F%2Fdocs.google.com%2Fspreadsheets%2Fd%2F1M7eklh1oDrQpn9c0MTBHYJIG8apr5ulax7fPwVS7_-s%2Fedit)。 ![默认生成器版本](https://attachment.soulteary.com/2020/09/05/online-generate-default.png) 然而不论是可阅读性,还是界面流畅度,亦或者在“技术点”变化展示上,生成版本都不如线上版本。 那么先定一个小目标,让这个漂亮的版本能够在本地跑起来吧。 ## 获取网站相关资源 一切的开始,是我们需要有一套可以运行的本地代码,所以这里可以使用各种方式将网页和相关资源镜像到本地。过程略,如果你熟悉 Node ,应该二十多行脚本就能解决战斗了吧。 将访问技术雷达时请求的资源根据实际状况保存到本地,使用 `tree` 进行查看,目录结构如下: ```TeXT app/page ├── static.thoughtworks.com │   ├── fonts │   │   ├── latin-normal-300.woff2 │   │   ├── latin-normal-400.woff2 │   │   ├── latin-normal-600.woff2 │   │   ├── latin-normal-700.woff2 │   │   └── latin-normal-800.woff2 │   └── js │   └── jquery-1.12.4.min.js └── www.thoughtworks.com ├── 1140_grid │   └── css │   └── 1140.css ├── cn │   └── radar │   ├── languages-and-frameworks │   ├── platforms │   ├── techniques │   └── tools ├── css │   └── k8s_production │   └── screen.css ├── imgs │   ├── 1x1-transparent.gif │   ├── favicons │   │   └── favicon.ico │   ├── languages-ring.svg │   ├── platforms-ring.svg │   ├── search.svg │   ├── techniques-ring.svg │   ├── tools-ring.svg │   └── tw-logo.svg ├── javascripts │   └── lib │   ├── d3.v3.min.js │   ├── lodash.min.js │   ├── socialite.min.js │   └── underscore.string.min.js └── js └── k8s_production ├── application.js ├── css3-mediaqueries.js ├── dropdown.js ├── isInViewport.min.js ├── radar │   ├── quadrant.js │   ├── radar.js │   └── radar_tooltip.js ├── shared.js └── wistia_cookie_manager.js 17 directories, 33 files ``` 获取完资源,我们试试先启动一个测试服务器,看看资源能否“离线运行”,评估下改动工作量有多少。 ## 启动测试服务器 启动一个能够离线模拟页面功能的 Web 服务很简单,不到二十行代码解决问题: ```js const express = require("express"); const app = express(); const port = 3000; const { readFileSync, existsSync } = require("fs"); app.get("/", (req, res) => res.redirect("/cn/radar/techniques")); app.get("/cn/radar/:page", (req, res) => { res.header("Content-Type", "text/html"); const file = `./public/${req.params.page}.html`; if (!existsSync(file)) return res.status(404).send("not found"); return res.send(readFileSync(file, "utf-8")); }); app.use(express.static("page/www.thoughtworks.com/")); app.listen(port, "0.0.0.0", () => console.log(`Example app listening at port: ${port}`)); ``` 将代码保存后,使用 Node 启动脚本,访问 `localhost:3000` 会看到下面的页面。 ![缓存后的本地版本](https://attachment.soulteary.com/2020/09/05/local-cache.png) 上面代码解决了这三个问题: - 用户访问 `localhost:3000` 会自动跳转到技术雷达的“技术页面”,避免找不到缓存的页面文档。 - 完全模拟线上版本的应用,提供相同的页面访问路由。 - 使用本地的脚本样式资源,保持程序运行的“版本稳定”。 为了让我们对技术雷达中的数据有比较好的管理能力,我们需要对网站进行一定的程序抽象、数据解耦。 ## 提取并整理页面中的数据 官网站点充分考虑了 SEO,以及浏览器渲染效率、禁用脚本情况页面的呈现状态,所以我们会看到大量数据和页面模版耦合在一起的情况。 这种状况对于维护来说,因为数据量比较大、数据之间有排序和关联,所以当前状态下对应用进行后续维护是比较麻烦的,为了后续进行数据管理,我们需要先将页面数据剥离出来。 ```html ... ``` 使用解析网页文档 DOM 结构的思路,将类似上面的片段进行序列化,并根据页面路由名称分别生成接下来需要使用的 JSON 文件。 ```js const { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } = require("fs"); const turndownService = new require("turndown")(); const { load } = require("cheerio"); function parser($, quadrantType = "adopt") { const container = $(`#quadrant-blip-list #${quadrantType}`); const titleContainer = container.find(`h3.ring.${quadrantType}`); const quadrantTips = titleContainer.find(".tooltip-icon").attr("title"); titleContainer.find("span").remove(); const quadrantTitle = titleContainer.text(); const blipList = container.find("ul li"); let result = []; blipList.map((i, el) => { const linkBlock = $(el).find(".blip-link"); const name = linkBlock.find(".blip-name").text(); const { blipId: id, blipStatus: status, movement, eventAction, eventCategory, eventLabel, radarId, radius, theta, url } = linkBlock.data(); let changelog = null; const descBlock = $(el).find(".blip-description"); const detectLastBlock = descBlock.children().last(); if (detectLastBlock[0].name === "div") { const changelogButton = detectLastBlock.find("a"); if (changelogButton.length) { if (detectLastBlock.find("a").text().indexOf("历史信息") > -1) { changelog = detectLastBlock.find("a").attr("href"); detectLastBlock.empty(); } } } result.push({ id, name, status, eventAction, eventCategory, eventLabel, movement, radarId, radius, theta, url, changelog, desc: turndownService.turndown(descBlock.html()) }); }); return { title: quadrantTitle, tips: quadrantTips, data: result }; } if (!existsSync("./app/data/")) mkdirSync("./app/data"); readdirSync("./app/page/www.thoughtworks.com/cn/radar/").forEach((page) => { const file = readFileSync(`./app/page/www.thoughtworks.com/cn/radar/${page}`, "utf-8"); const $ = load(file, { normalizeWhitespace: false, xmlMode: false, decodeEntities: false }); const result = ["adopt", "trial", "assess", "hold"].map((type) => { return { ...parser($, type), type, category: page }; }); writeFileSync(`./app/data/${page}.json`, JSON.stringify(result)); console.log(result); }); ``` 上面的脚本执行完毕之后,生成的 JSON 文档内容会类似下面这样: ```json [ { "title": "采纳", "tips": "我们强烈建议业界采用这些技术,我们将会在任何合适的项目中使用它们。", "data": [ { "id": 1133, "name": "将产品管理思维应用于内部平台", "status": "move_in", "eventAction": "Click to Summary", "eventCategory": "Tech Radar - Techniques", "eventLabel": "Adopt - Applying product management to internal platforms", "movement": "t", "radarId": 1, "radius": 90, "theta": 165, "url": "techniques/applying-product-management-to-internal-platforms", "changelog": "/cn/radar/techniques/applying-product-management-to-internal-platforms", "desc": "越来越多的公司正在构建内部平台,借此快速有效地推出新型数字化解决方案。成功实施这一战略的企业正 **将产品管理思维应用于内部平台** 。这意味着与内部消费者(开发团队)建立共情,并在设计上彼此协作。平台的产品经理要建立路线图,确保平台为业务交付价值,为开发者改善体验。不幸的是,我们也见到了一些不太成功的方式,团队在未经验证的假设、没有内部客户的情况下,打造出的平台犹如空中楼阁。这些平台尽管采用了激进的内部策略,但往往无法充分利用,还耗尽了组织的交付能力。和其他产品一样,好的产品管理就是为消费者创造喜爱的产品。" }, ... ] ``` 官方生成器中使用的 Google Docs 文档数据中,描述内容使用的是 HTML 代码片段,在内容数量多了之后并不是很好维护,尤其是让呈现的样式保持一致,所以这里将 HTML 转换为 Markdown 格式进行保存。 ## 抽象通用页面模版 通过观察发现 `cn/radar` 目录下的 `languages-and-frameworks`、`platforms`、`techniques`、`tools` 结构完全一致,如果我们将页面当前的数据去除,那么便可以只维护一个模版啦。 这里不论是使用手动处理,还是和镜像站点一样,写一段简单的脚本,都可以比较快的得到一个通用的模版结构。将处理后的模版单独保存(如 `app/template/base.html` 目录),稍后使用。 过程中可以根据自己需求,对页面模版、布局等进行适当修改,所以这里就不贴出完整代码实现啦,需要注意的是,为了后续数据能够再次比较容易的转换为代码,我们还需要单独抽象“技术列表”元素的模版,如(这里使用了 ejs 语法): ```html <% source.forEach((item) => { const {type, category,title,tips, data} = item; %>

<%-title%>?

<% }) %> ``` ## 编写数据转换模版程序 当模版抽象完毕后,我们就需要考虑如何将模版转换为页面内容,并提供给用户了。 这里通常有两个方案,第一种是启动一个 Node 服务,在服务运行时,动态渲染模版内容;第二种则是和我们镜像后的官方网站一样,提供多个页面模版供用户浏览访问。 本文考虑后续会使用静态服务器托管,脱离技术栈依赖,所以选择方案二。我们可以再编写一个简单的数据转换工具,让前面小节中抽象出的 JSON 数据再次转换为“模版”。(即使选择方案一,其实也可以使用下面的程序,做页面缓存,提高访问性能) ```js const { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } = require("fs"); const { load } = require("cheerio"); const markdownIt = require("markdown-it")(); const { compile } = require("ejs"); const template = compile(readFileSync("./app/template/list.ejs", "utf-8")); const $ = load(readFileSync("./app/template/base.html", "utf-8"), { normalizeWhitespace: false, xmlMode: false, decodeEntities: false }); const files = readdirSync("./app/data") .filter((file) => file.endsWith(".json")) .map((file) => { return { path: `./app/data/${file}`, name: file }; }); files.forEach((file) => { const source = JSON.parse(readFileSync(file.path, "utf-8")); const renderData = source.map((item) => { item.data = item.data.map((data) => { data.desc = markdownIt.render(data.desc); return data; }); return item; }); const content = $(template({ source: renderData })); $("#quadrant-blip-list").html(content); $("#radar") .find(".radar-quadrant-navigation__quadrant a") .each((id, el) => { if (file.name.includes($(el).attr("nav-entry"))) { $(el).addClass("selected"); } else { $(el).removeClass("selected"); } }); writeFileSync(`./app/public/${file.name}`.replace(".json", ".html"), $.html()); }); ``` 当程序执行完毕,应用目录会出现一个 Public 文件夹,使用 `tree` 命令查看内容: ```TeXT public ├── languages-and-frameworks.html ├── platforms.html ├── techniques.html └── tools.html ``` 到现在为止,我们做到了使用我们自己的 JSON 数据和模版生成这些技术雷达页面。 ## 启动新的 HTTP 服务器 在配置 Nginx 静态服务器规则之前,我们可以先将之前的测试服务器程序进行简单修改,来让我们接下来的程序定制调整更加容易。 ```js const express = require("express"); const app = express(); const port = 3000; const { readFileSync } = require("fs"); app.get("/", (req, res) => res.redirect("/cn/radar/techniques")); app.get("/cn/radar/:page", (req, res) => { res.header("Content-Type", "text/html"); res.send(readFileSync(`./public/${req.params.page}.html`, "utf-8")); }); app.use(express.static("page/www.thoughtworks.com/")); app.listen(port, "0.0.0.0", () => console.log(`Example app listening at port: ${port}`) ); ``` 只需要调整 `app.get("/cn/radar/:page", ...)` 路由的内容,就可以完成从本地镜像到我们自己生成页面模版的切换。 ![运行在本地的“新版本”技术雷达](https://attachment.soulteary.com/2020/09/05/local-version.png) ## 最后 当前我们可以通过修改生成的 JSON 数据,以及执行刚刚编写的模版生成程序来完成页面内容的更新,但是这样对于使用者体验太差了,也无法容易的做到对在图表中的数据点的管理。 下一篇,我们继续针对这个技术雷达进行改造。 --EOF