ReScript 是一门函数式编程语言,而且它跟 OCaml 有很多相似的地方,语言本身有模式匹配,转出来的 JS 代码也有很高的性能,还能很轻松绑定 JS 的库,同时也是强类型语言,最最重要的是它语言本身支持 React JSX,非常适合 React。总之,ReScript 真好玩。
起因
公司有个客户端项目要做个省市区街级联的功能,后端同事给了我一个 CSV 文件,刚好该客户端我选择的技术栈是 Bun + Tauri + ReScript + Vite,那就用 ReScript + Bun 的方式做一个 CLI 工具。
项目配置
先装好 Bun,因为我讨厌 Cpp,所以我运行时更倾向于选择 Deno/Bun,由于上个月系统学习了一下 Zig,加之我了解到 Bun 是可以直接运行 TypeScript 及支持很多 ES 的新特性,所以我就想尝试下 Bun。
curl -fsSL https://bun.sh/install | bash
装好 Bun,然后正常创建一个前端项目,也就是包含 package.json 的项目,无论你是用 bun init
创建还是手动弄创建文件都可以,项目名我这里叫 csv2json
。
创建完成后,在 package.json 文件内写以下内容后,再执行 bun i
来安装依赖。
这里主要核心库除了 rescript
外,还要用到 nodemon
守护进程方便开发调试,还有 ReScript 版的 NodeJS 绑定包 rescript-nodejs
,譬如读写文件这类操作就要用到它,以及 csv-parser
解析 CSV 文件用。
{
"name": "csv2json",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "run-p start:**",
"start:re": "rescript build -w",
"start:dev": "nodemon --exec node src/index.js",
"install:re": "rescript build -with-deps",
"postinstall": "run-s install:**",
"exec": "rescript build -with-deps && node src/index.js"
},
"author": "",
"license": "MIT",
"devDependencies": {
"nodemon": "^3.1.7",
"npm-run-all": "^4.1.5",
"rescript-nodejs": "^16.1.0"
},
"dependencies": {
"@rescript/core": "^1.6.1",
"csv-parser": "^3.0.0",
"rescript": "^11.1.4"
}
}
总体项目的目录结构长这样
.
├── area.csv
├── area.json
├── bun.lockb
├── package.json
├── rescript.json
└── src
├── CSV.res
├── Index.res
└── index.js
这里有个 rescript.json 的文件需要注意,里面是关于 ReScript 的配置,详细配置请看 ReScript 官网
主要关注点在 bs-dependencies
跟 suffix
,bs-dependencies
是说明哪些依赖是给 ReScript 使用,suffix
是表示生成文件的文件副档名长啥样。
{
"name": "csv2json",
"sources": {
"dir": "src",
"subdirs": true
},
"package-specs": {
"module": "esmodule",
"in-source": true
},
"suffix": ".bs.js",
"ppx-flags": [],
"jsx": {
"version": 4,
"mode": "automatic"
},
"uncurried": true,
"namespace": true,
"bsc-flags": [
"-open RescriptCore"
],
"bs-dependencies": [
"@rescript/core",
"rescript-nodejs"
],
"warnings": {
"error": "+101"
}
}
当然还有个非常关键的 .gitignore 文件
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
dist-ssr
*.local
out/*
# Editor directories and files
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# ReScript
lib
.merlin
.bsb.lock
src/**/*.bs.js
# Intellij
.idea
# vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Vite
dist
# npm
node_modules
# macOS
.DS_Store
area.json
area.plist
挨下来打开 src/index.js 文件,因为最终生成文件已经设置成 .bs.js 结尾的,所以直接写上这行代码
import "./Index.bs.js"
分析 CSV 内容及目标 JSON 格式定义
来分析一下我们后端给我的 CSV 文件长啥样子
id,pid,deep,name,pinyin_prefix,pinyin,ext_id,ext_name
11,0,0,"北京","b","bei jing","110000000000","北京市"
1101,11,1,"北京","b","bei jing","110100000000","北京市"
110101,1101,2,"东城","d","dong cheng","110101000000","东城区"
110101001,110101,3,"东华门","d","dong hua men","110101001000","东华门街道"
然后我们需求是把最终上述文件内容转成以下内容的 JSON
[
{
"id": "11",
"name": "北京市",
"header": "B",
"children": [
{
"id": "1101",
"name": "北京市",
"header": "B",
"children": [
{
"id": "110101",
"name": "东城区",
"header": "D",
"children": [
{
"id": "110101001",
"name": "东华门街道",
"header": "D",
"children": []
}
]
}
]
}
]
}
]
实际编码
首先打开 csv-parser 在 npmjs 上的文档,可以看到基础使用是这副样子
const csv = require('csv-parser')
const fs = require('fs')
const results = [];
fs.createReadStream('data.csv')
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => {
console.log(results);
});
所以首先是使用 createReadStream
从一个路径以 Stream 的形式打开,然后 pipe 到 csv 实例,之后就是简单的取数据,结束进行一些后续操作。
所以变成 ReScript 代码就是下面这样,open NodeJs 模块,再用 打开后的 Fs 模块内的 createReadStream
先读取一下文件,结果暂时使用 ignore 忽略,因为后面我也不知道写啥
open NodeJs
Fs.createReadStream("./area.csv")
->ignore
Module
我现在假装还不知道 csv()
对应到 ReScript 应该写成啥。先找到 ReScript Module 这一章节。
一般的 Module 是这样的
module T = {
let message = "hello"
}
let msg = T.message
Module 里还能套 Module
module T = {
module T1 = {
let message = "hello"
}
}
let msg = T.T1.message
Module 还能有 Signatures,虽然乍一看不知道能干嘛
module type Read = {
type read
}
但是如果模块还有 Functions 会怎样?
module type Read = {
type read
}
module MakeRead = (T: Read) => {
let parse = (): T.read => {
// ...
}
}
module T = {
type read = {
id: string
}
}
module Read = MakeRead(T)
let read = Read.parse()
Console.log(read.id)
甚至有种泛型的感觉,而且 ReScript 这语言是强类型的,满满的安全感,所以我们现在其实可以用 ReScript 绑定一下 csv-parser。
将 CSV.res 文件内容补充完整,这里涉及到一个知识点,包装第三方库要用到 @module
、external
的组合拳,因为在 JavaScript 里应该是下面这种用法
import csv from "csv-parser";
csv();
现在的情况是我不知道 csv() 调用后的类型,幸好 csv-parser 是有 TypeScript 的类型文件的
import { Transform } from 'stream';
declare namespace csvParser {
type CsvParser = Transform;
interface Options {
readonly headers?: ReadonlyArray<string> | boolean;
// ...
}
}
declare const csvParser: (
optionsOrHeaders?: csvParser.Options | ReadonlyArray<string>
) => csvParser.CsvParser;
export = csvParser;
由于 ReScript 是不支持 TypeScript 的 Union Types,我决定只选保留 Options 的那块,同时 Transform 在 rescript-nodejs 包里是一个 Stream 的 subtype,那对应 ReScript 就要写成下面这种,甚至还利用上 Module Functions 特性从外部读取返回值类型
module type Read = {
type read
}
module MakeParser = (R: Read) => {
open NodeJs
type t
type options = {headers: array<string>}
@module("csv-parser")
external make: (~opts: options=?) => Stream.subtype<Stream.transform<'w, R.read>> = "default"
}
先回到 src/Index.res 文件,现在把内容改成以下内容,主要是增加了原始 csv 文件对应 ReScript record 的类型声明,同时调用 Stream.pipe 接收 csv-parser 实例,同时读取文件内容及取出内容后续解析操作
open NodeJs
module T = {
type read = {
id: string,
pid: string,
deep: string,
name: string,
pinyin_prefix: string,
pinyin: string,
ext_id: string,
ext_name: string,
}
}
module Parser = CSV.MakeParser(T)
let origin = []
Fs.createReadStream("./area.csv")
->Stream.pipe(
Parser.make(
~opts={headers: ["id", "pid", "deep", "name", "pinyin_prefix", "pinyin", "ext_id", "ext_name"]},
),
)
->Stream.onData(row => origin->Array.push(row))
->Stream.onEnd(() => {
// ...
})
->ignore
解析并映射 JSON 数据
现在主要任务是在 Stream.onEnd 里做点微小的工作。先用 origin->Array.sliceToEnd(~start=1)
过滤掉列头,同时把数据全 map 到另一个 record item
类型去,因为有些原始数据其实并没有用,譬如 ext_id
这种,那这种就简单地 Array.map
一下,这里又涉及一个支持点,因为我现在加了一个 children 属性,它是一个 array,同时 array 的内容又是 item 本身,属于递归声明了,所以加个 rec 关键字
type rec item = {
id: string,
pid: string,
name: string,
pinyin_prefix: string,
children: array<item>,
}
let fill = (a: array<T.read>) => {
a->Array.map(item => {
{
id: item.id,
pid: item.pid,
pinyin_prefix: item.pinyin_prefix,
name: item.ext_name,
children: [],
}
})
}
// ...
->Stream.onEnd(() => {
let filled = fill(origin->Array.sliceToEnd(~start=1))
})
->ignore
因为后端同事给我的数据是 pid 跟部分 id 是对应关系,所以我们要把上面的 filled 数组通过 Map
数据结构来解决这桩事,那这里又涉及一个知识点 Variant,m->Map.get(v.pid)
这个操作,拿到的其实是一个类型 option<item>
的值(这里可以理解成 Rust 的 enum Option,它们完全没啥大差别),我们可以通过 switch
来进行模式匹配用于代码防御,这里是把为 None 的不处理了,只处理 get 到的 item
let buildTree = (nodes: array<item>) => {
let m = Map.make()
nodes->Array.forEach(v => {
m->Map.set(v.id, v)
})
let results = []
nodes->Array.forEach(v => {
if v.pid == "0" {
results->Array.push(v)
} else {
switch m->Map.get(v.pid) {
| Some(node) => node.children->Array.push(v)
| _ => ()
}
}
})
results
}
// ...
->Stream.onEnd(() => {
let filled = fill(origin->Array.sliceToEnd(~start=1))
let tree = filled->buildTree
})
->ignore
其实到上面那一步已经完工了,但是由于数据量太大,最后生成的文件非常大,只好做一个瘦身操作。那此时又涉及到另一个知识点,那就是函数也是可以使用 rec
关键字来声明它具有递归行为
type rec results = {
id: string,
name: string,
header: string,
children: array<results>,
}
let rec slim = (a: array<item>) => {
a->Array.map(v => {
id: v.id,
name: v.name,
header: v.pinyin_prefix->String.toUpperCase,
children: slim(v.children),
})
}
// ...
->Stream.onEnd(() => {
let filled = fill(origin->Array.sliceToEnd(~start=1))
let tree = filled->buildTree->slim
})
->ignore
写入文件
最后是把处理好的内容写入到文件,那这里再介绍好玩的操作,我们先是把转出来的数据用 JSON.stringifyAny 来生成一个 JSON 对象,由于这个函数可能会失败,因为不用处理为 None 的情况,所以直接用 Option.map 来处理 Some(x) 的情况,挨下来就是常规操作了,利用 NodeJS 的 Fs.writeFileSync(...)
写文件就完事了。
// ...
->Stream.onEnd(() => {
let filled = fill(origin->Array.sliceToEnd(~start=1))
let tree = filled->buildTree->slim
JSON.stringifyAny(tree)
->Option.map(v => Fs.writeFileSync("area.json", Buffer.fromString(v)))
->ignore
})
->ignore
最后执行一下 bun run exec
来生成实际文件。