最近在梳理团队项目依赖和各个项目技术栈的时候,发现使用技术雷达的形式来进行呈现和管理是个不错的点子。但是没找到维护简单,界面又清爽好看的 UI。
正巧访问到 Thoughtworks 新版本技术雷达,发现在线版本十分漂亮,远比官方提供的生成版本漂亮。
那么,记录下折腾这个技术雷达的过程吧,本篇是第一篇,聊聊如何使用 Node.js 完成一个上述雷达的本地版本。
写在前面
访问 官方地址 可以看到下面的新版技术雷达的界面。
点击导航菜单中的【构建你的雷达】,即使我们不自己准备数据,使用官方默认数据,跟随官方提示,一路 “Next”,便会得到下图样式/交互的定制雷达。
然而不论是可阅读性,还是界面流畅度,亦或者在“技术点”变化展示上,生成版本都不如线上版本。
那么先定一个小目标,让这个漂亮的版本能够在本地跑起来吧。
获取网站相关资源
一切的开始,是我们需要有一套可以运行的本地代码,所以这里可以使用各种方式将网页和相关资源镜像到本地。过程略,如果你熟悉 Node ,应该二十多行脚本就能解决战斗了吧。
将访问技术雷达时请求的资源根据实际状况保存到本地,使用 tree
进行查看,目录结构如下:
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 服务很简单,不到二十行代码解决问题:
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
会看到下面的页面。
上面代码解决了这三个问题:
- 用户访问
localhost:3000
会自动跳转到技术雷达的“技术页面”,避免找不到缓存的页面文档。 - 完全模拟线上版本的应用,提供相同的页面访问路由。
- 使用本地的脚本样式资源,保持程序运行的“版本稳定”。
为了让我们对技术雷达中的数据有比较好的管理能力,我们需要对网站进行一定的程序抽象、数据解耦。
提取并整理页面中的数据
官网站点充分考虑了 SEO,以及浏览器渲染效率、禁用脚本情况页面的呈现状态,所以我们会看到大量数据和页面模版耦合在一起的情况。
这种状况对于维护来说,因为数据量比较大、数据之间有排序和关联,所以当前状态下对应用进行后续维护是比较麻烦的,为了后续进行数据管理,我们需要先将页面数据剥离出来。
<div class="blip-link" data-blip-id="9142" data-blip-status="c" data-event-action="Click to Summary" data-event-category="Tech Radar - Techniques" data-event-label="Adopt - Infrastructure as code" data-movement="c" data-radar-id="2" data-radius="60" data-theta="155" data-url="techniques/infrastructure-as-code" id="blip-link-9142" style="background-color: white; color: black;">
<span class="blip-graphic-id">2. </span>
<span class="blip-name" style="display: inline;">基础设施即代码</span>
<a class="non-js-blip-desc-link" href="techniques/infrastructure-as-code" style="display: none;">基础设施即代码</a>
</div>
...
使用解析网页文档 DOM 结构的思路,将类似上面的片段进行序列化,并根据页面路由名称分别生成接下来需要使用的 JSON 文件。
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 文档内容会类似下面这样:
[
{
"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 语法):
<% source.forEach((item) => { const {type, category,title,tips, data} = item; %>
<div id="<%-type%>">
<h3 class="ring <%-type%>">
<span class="dot <%-category%>">●</span>
<%-title%><span class="tooltip-icon" title="<%-tips%>">?</span>
</h3>
<ul>
<% data.forEach((subItem) => { const {id, name, status, eventAction, eventCategory, eventLabel, movement, radarId, radius, theta, url, desc, changelog} = subItem; %>
<li>
<div class="blip-link" data-blip-id="<%-id%>" data-blip-status="<%-status%>" data-event-action="<%-eventAction%>" data-event-category="<%-eventCategory%>" data-event-label="<%-eventLabel%>" data-movement="<%-movement%>" data-radar-id="<%-radarId%>" data-radius="<%-radius%>" data-theta="<%-theta%>" data-url="<%-url%>" id="blip-link-<%-id%>">
<span class="blip-graphic-id"><%-radarId%>. </span>
<span class="blip-name"><%-name%></span>
<a class="non-js-blip-desc-link" href="<%-url%>"><%-name%></a>
</div>
<div class="blip-description" id="blip-description-<%-id%>">
<%-desc%>
<% if (changelog) { %>
<div>
<a href="<%-changelog%>">历史信息</a>
<span class="right social-share"></span>
</div>
<% } %>
</div>
</li>
<% }) %>
</ul>
</div>
<% }) %>
编写数据转换模版程序
当模版抽象完毕后,我们就需要考虑如何将模版转换为页面内容,并提供给用户了。
这里通常有两个方案,第一种是启动一个 Node 服务,在服务运行时,动态渲染模版内容;第二种则是和我们镜像后的官方网站一样,提供多个页面模版供用户浏览访问。
本文考虑后续会使用静态服务器托管,脱离技术栈依赖,所以选择方案二。我们可以再编写一个简单的数据转换工具,让前面小节中抽象出的 JSON 数据再次转换为“模版”。(即使选择方案一,其实也可以使用下面的程序,做页面缓存,提高访问性能)
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
命令查看内容:
public
├── languages-and-frameworks.html
├── platforms.html
├── techniques.html
└── tools.html
到现在为止,我们做到了使用我们自己的 JSON 数据和模版生成这些技术雷达页面。
启动新的 HTTP 服务器
在配置 Nginx 静态服务器规则之前,我们可以先将之前的测试服务器程序进行简单修改,来让我们接下来的程序定制调整更加容易。
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", ...)
路由的内容,就可以完成从本地镜像到我们自己生成页面模版的切换。
最后
当前我们可以通过修改生成的 JSON 数据,以及执行刚刚编写的模版生成程序来完成页面内容的更新,但是这样对于使用者体验太差了,也无法容易的做到对在图表中的数据点的管理。
下一篇,我们继续针对这个技术雷达进行改造。
–EOF