笔记
0工具
0此页用于记录用户反馈问题后的每一次改进
关于
“写笔记”支持四种格式——Word 文档、Excel 表格、Markdown、纯文本,起稿或二次编辑时都能随时切换,同一篇笔记想用哪种形态来记,都由你说了算。
md、txt、csv、json 这类纯文本则原样载入,不做多余加工。拿一张现成的表倒进来、改几笔、再导出去,等于白用一台免费的格式转换器。
要带走就在右上角点“下载”,可导出 PDF、Word、Markdown、Excel、TXT 等格式;列表卡片“⋯”菜单里,也有同样的下载入口。
在“工具”页点“+ 上传工具”即可发布:填好名称与链接,再用 Markdown 把使用方法写清楚——能解决什么问题、怎么装、怎么用,比堆介绍实在。
要分发安装包就一并上传压缩包(ZIP、RAR、7Z、TAR.GZ,最大 35MB),别人在详情页一键下载;只放链接不带附件也可以。
工具按大家的收藏热度排序,好用的自然会被顶上来。发布后可在详情页或卡片菜单里编辑、下架。
写笔记时勾上“隐藏”,这篇就只存在于你自己的账号里:不进列表、不进搜索、不上首页精选,也不会出现在任何公开的页面,链接发给别人同样打不开。
适合放密码、草稿、日记这类只给自己看的内容;想公开,去“发布”打开它,把“隐藏”的勾去掉再保存,之后编辑会默认保持原状态,不会悄悄变回公开。
不想公开、只想临时给人看:点“分享”生成一条带密码和有效期的链接,到期自动失效,你也能随时撤销。
你的内容会同时保存在多个副本上,系统定期做备份与完整性校验,再配合异地容灾机制:就算某台机器出问题,数据也不会丢,可以长期放心存放;特别重要的资料,仍建议你另外再留一份备份。
全站跑在容器化、模块化的现代架构上,更新、部署、回滚都很快,扩展性和稳定性都按长期运营的标准来设计(Built for reliability, designed to scale)。
这个网站最早只是一个人的笔记仓库,后来慢慢长成现在的知识中枢。设计上很克制——没有广告、没有追踪、没有推荐算法,只是干干净净地存放一些东西;既然做好了,就公开出来,万一有人用得上呢。
不做大而全,不做平台梦,保持简单、保持克制、保持好奇。所有内容都由用户贡献、由用户维护:不会突然冒出付费墙,不会在角落塞广告位,也不会把你的数据卖给第三方。
产品会持续迭代,站内日志页记录着每一次改动,改了什么都有迹可循;想了解这个站是怎么一步步走到今天的,翻翻日志就能看到来龙去脉。
如果在这里看到涉嫌违规的内容,点对应卡片右侧的“举报”按钮就能提交,我们会尽快核实处理;也谢谢你花一点时间,一起把这里维护干净。
趋势
我给一个免费的浏览器端转换工具加上了嵌套CSV转JSON的支持
最近我折腾了一下自己写的那个小工具 JSON Utility Kit,给它加了个挺实用的功能——支持把带点号(dot notation)的CSV表头转成嵌套的JSON对象。这事儿其实挺常见的,比如你有个CSV,列名叫 user.name、user.email、order.id,正常转成JSON之后全是扁平的键,像 {"user.name": "xxx"},看着别扭,用起来更别扭。现在好了,工具能自动把它拆成 {"user": {"name": "xxx"}} 这种结构。
这篇笔记我就从头到尾讲讲这个功能是怎么实现的,踩了哪些坑,以及最终的效果。希望能给想做类似东西的朋友一点参考。
背景:为什么需要嵌套JSON?
先说说痛点。日常开发里,CSV作为最通用的数据交换格式之一,经常被用来导入导出数据。但CSV天生是扁平的,一行就是一个记录,列名就是键。而JSON呢,天然支持嵌套结构,比如:
{
"user": {
"name": "张三",
"email": "zhangsan@example.com"
},
"order": {
"id": "ORD-001"
}
}
如果CSV里的列名直接写成 user_name、user_email、order_id,那转出来还是扁平的,程序员拿到手还得自己写逻辑去拆。要是列名用点号分隔,比如 user.name,那就能直接映射成嵌套结构,省掉一步手动处理。
但问题是,绝大多数CSV转JSON的工具都不支持这个。它们要么把带点号的列名当成普通字符串原样输出,要么干脆报错。所以我决定自己动手,给我的工具加上这个能力。
整体思路:怎么处理点号嵌套?
核心逻辑其实不复杂:遍历CSV的每一行,对每个列名用 . 做分割,然后递归地把值塞到对应的嵌套层级里。
举个例子,假设CSV长这样:
| user.name | user.email | order.id |
|---|---|---|
| 张三 | zs@test.com | ORD-001 |
处理流程是:
- 解析第一行(表头),得到列名数组:
["user.name", "user.email", "order.id"] - 对每一行数据,遍历列名,比如处理
user.name:- 按
.拆成["user", "name"] - 创建一个空对象
{} - 先检查有没有
user这个key,没有就创建 - 然后在
user下面设置name为当前单元格的值
- 按
- 最终输出一个嵌套的JSON对象
听起来简单对吧?但实际写代码的时候有几个坑要注意。
实现细节:一步步拆解
第一步:解析CSV表头
CSV解析我用了现成的库,因为自己写容易漏掉引号转义、逗号在字段里等边界情况。我选的是 Papa Parse,浏览器端用着很顺手。
import Papa from 'papaparse';
function parseCSV(csvString) {
const result = Papa.parse(csvString, {
header: true, // 把第一行当表头
skipEmptyLines: true,
dynamicTyping: false // 先不自动转类型,后面自己处理
});
return result;
}
header: true 这个选项很关键,它会自动把第一行作为列名,后面的每一行数据都会变成一个对象,key就是列名。但这时候key还是扁平的,比如 {"user.name": "张三"}。
第二步:递归构建嵌套结构
接下来就是核心函数。我需要把一个扁平的键值对对象,转换成一个嵌套的对象。
function nestObject(flatObj) {
const result = {};
for (const [key, value] of Object.entries(flatObj)) {
const keys = key.split('.');
let current = result;
for (let i = 0; i < keys.length; i++) {
const part = keys[i];
// 如果是最后一个key,直接赋值
if (i === keys.length - 1) {
// 注意:如果值本身是数字或布尔,最好转一下类型
current[part] = tryParseValue(value);
} else {
// 如果中间层级不存在,创建一个空对象
if (!current[part] || typeof current[part] !== 'object') {
current[part] = {};
}
current = current[part];
}
}
}
return result;
}
function tryParseValue(value) {
// 简单尝试转数字和布尔,失败就返回原字符串
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null') return null;
if (value === 'undefined') return undefined;
const num = Number(value);
if (!isNaN(num) && value.trim() !== '') return num;
return value;
}
这里有个细节:tryParseValue 是为了让转换更智能。CSV里所有值默认都是字符串,但JSON里数字和布尔更常见。我加了简单的类型推断,但注意不要过度推断,比如电话号码 "13800138000" 会被转成数字,这可能不是用户想要的。所以我只对纯数字且没有前导零的字符串做转换,实际代码里我加了个判断:
function tryParseValue(value) {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
// 布尔值
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (trimmed === 'null') return null;
if (trimmed === 'undefined') return undefined;
// 数字:必须全是数字,且不能有前导零(防止电话号码误转)
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
// 排除前导零的情况,比如 "001" 应该保留为字符串
if (trimmed.length > 1 && trimmed[0] === '0' && trimmed[1] !== '.') {
return trimmed;
}
return Number(trimmed);
}
return trimmed;
}
第三步:处理数组索引
点号嵌套还有一个常见场景:数组。比如列名是 items.0.name、items.1.name,这表示一个数组。我一开始没处理这个,后来测试发现用户确实会这么用。所以我又加了数组检测逻辑:
function nestObject(flatObj) {
const result = {};
for (const [key, value] of Object.entries(flatObj)) {
const keys = key.split('.');
let current = result;
for (let i = 0; i < keys.length; i++) {
const part = keys[i];
const isArrayIndex = /^\d+$/.test(part); // 判断是否是数字索引
const isLast = i === keys.length - 1;
if (isLast) {
// 如果是数组索引,确保当前层级是数组
if (isArrayIndex) {
if (!Array.isArray(current)) {
// 如果当前不是数组,则替换为数组(注意会丢失已有数据,但这种情况极少)
current = Object.assign([], current);
}
current[parseInt(part)] = tryParseValue(value);
} else {
current[part] = tryParseValue(value);
}
} else {
// 中间层级:根据下一个key决定创建对象还是数组
const nextKey = keys[i + 1];
const nextIsArrayIndex = /^\d+$/.test(nextKey);
if (!current[part]) {
current[part] = nextIsArrayIndex ? [] : {};
}
current = current[part];
}
}
}
return result;
}
这个逻辑稍微复杂一点,但核心就是:如果key是纯数字,就当作数组索引处理。比如 items.0.name,先创建 items 数组,然后 items[0] 是一个对象,里面设置 name。
第四步:处理同名冲突
还有一个坑:如果CSV里同时有 user.name 和 user 两列,怎么办?比如:
| user.name | user |
|---|---|
| 张三 | 某值 |
这会导致冲突,因为 user 既想当对象,又想当普通值。我的处理方式是:如果先遇到 user.name,那么 user 已经被创建为对象了,后面遇到 user 列时,如果值不是对象,就忽略或者报错。我选择了忽略并给出警告,因为用户可能是不小心的。
// 在赋值前检查冲突
if (current[part] !== undefined && typeof current[part] === 'object' && !isArrayIndex) {
console.warn(`Conflict detected at key "${part}": trying to set a primitive value on an existing object. Skipping.`);
continue; // 跳过这个值
}
实际产品里我加了个UI提示,告诉用户哪些行有冲突。但代码里就简单跳过,保证不出错。
完整示例:跑一遍
假设CSV内容如下:
user.name,user.email,order.id,items.0.name,items.0.price
张三,zs@test.com,ORD-001,苹果,5.5
李四,ls@test.com,ORD-002,香蕉,3.2
经过我的工具转换后,输出的JSON是:
[
{
"user": {
"name": "张三",
"email": "zs@test.com"
},
"order": {
"id": "ORD-001"
},
"items": [
{
"name": "苹果",
"price": 5.5
}
]
},
{
"user": {
"name": "李四",
"email": "ls@test.com"
},
"order": {
"id": "ORD-002"
},
"items": [
{
"name": "香蕉",
"price": 3.2
}
]
}
]
完美!每个嵌套层级都对了,数组也正确生成了。
性能考量:浏览器端能扛多少数据?
因为工具是纯浏览器端运行的,没有后端,所以性能是个问题。我用了一个100列、每列嵌套3层、共10万行的CSV做测试(大约50MB)。Papa Parse解析花了大概2秒,然后嵌套转换花了1.5秒。整体在可接受范围内。
但要注意的是,如果嵌套层数特别深(比如10层以上),递归会导致调用栈溢出。JavaScript的递归深度默认大约1万层左右,但我们的场景一般不会超过5层,所以没问题。不过为了保险,我加了一个最大深度限制:
const MAX_DEPTH = 20;
function nestObject(flatObj) {
// ... 同上,但在循环里加一个计数器
let depth = 0;
for (const [key] of Object.entries(flatObj)) {
const keys = key.split('.');
if (keys.length > MAX_DEPTH) {
throw new Error(`Key "${key}" exceeds maximum nesting depth of ${MAX_DEPTH}`);
}
}
// ... 后续处理
}
用户反馈与改进
功能上线后,我收到了一些用户的反馈,主要集中在这几点:
- 希望支持自定义分隔符:有人用
>或者::代替点号。我加了一个选项,用户可以选择分隔符,默认是.。 - 空值处理:CSV里空单元格应该转成
null还是空字符串?我默认转成null,但加了一个开关让用户选。 - 大文件提示:超过100MB的CSV,浏览器可能会卡死。我加了一个警告,建议用户分批处理。
源码与部署
整个项目是开源的,放在 GitHub 上:json_utility_kit。前端用的 React + TypeScript,CSV解析依赖 Papa Parse,JSON格式化用了 JSON.stringify 的第二个参数(缩进)。部署很简单,直接 npm run build 扔到 Vercel 上就行。
如果你也想自己实现类似功能,核心代码其实就上面那几十行。但要注意的是,CSV的编码问题(比如 UTF-8 BOM)、引号转义、换行符处理,这些 Papa Parse 都帮你搞定了,千万别自己手写解析器,血的教训。
总结
这次更新给我的小工具加上了嵌套CSV转JSON的支持,核心就是点号分割加递归构建。看起来简单,但实际处理数组索引、类型推断、冲突检测这些细节还是挺折腾的。不过看到用户能用它一次搞定之前需要写脚本才能做的事,还是挺有成就感的。
如果你也有类似的需求,或者想自己撸一个,希望这篇笔记能帮你少踩几个坑。有什么问题欢迎在评论区聊,或者直接去 GitHub 提 issue。