在ASP.NET MVC中对表进行通用的增删改

钩段誓滥项目背景

最近我们团队自研了一个基于 React 的 H5 前端框架,领导让我来负责编写框架的使用文档。我选择了 dumi 来搭建文档站点,大部分内容都是手动写 Markdown 来介绍各种功能,包括:初始化、目录结构、生命周期、状态管理、插件系统 等等。

框架里有个很重要的子包,主要负责多个 App 的桥接能力,深度集成了各端环境的监测和桥接逻辑。这个子包对外提供了一个 App 实例对象,里面封装了很多原生能力,比如: 设置导航栏、录音、保存图片到相册 等

这些 API 代码格式都比较统一,领导希望避免在框架源码和文档里重复定义相同的接口,最好能直接从源代码自动生成文档内容。需要提取的信息包括:API支持的App版本、功能描述、开发状态、使用方式,如果是函数的话还要有参数说明和返回值说明。

我的解决方案

经过一番思考,我想到了一个方案:

核心思路:在不改动源代码逻辑的前提下,通过增加注释信息来补充文档需要的元数据

具体实现路径:

定义一套规范的注释标签

编写解析脚本提取信息,生成 JSON 文件

在文档项目中读取 JSON,动态渲染成 API 文档

定义注释规范

我定义了一系列标准的注释标签:

@appVersion ------ 支持该API的App版本

@description ------ API的功能描述

@apiType ------ API类型,默认是函数,可选property(属性)和function(函数)

@usage ------ 使用示例

@param ------ 函数参数说明(只有函数类型需要)

@returns ------ 函数返回值说明(只有函数类型需要)

@status ------ 发布状态

在实际代码中这样使用,完全不会影响原来的业务逻辑:

const app = {

/**

* @appVersion 1.0.0

* @description 判断设备类型

* @apiType property

* @usage app.platform // notInApp | ios | android | HarmonyOS

* @status 已上线

*/

platform: getPlatform(),

/**

* @appVersion 1.0.6

* @description 注册事件监听

* @param {Object} options - 配置选项

* @param {string} options.title - 事件名称

* @param {Function} options.callback - 注册事件时的处理函数逻辑

* @param {Function} options.onSuccess - 设置成功的回调函数(可选)

* @param {Function} options.onFail - 设置失败的回调函数(可选)

* @param {Function} options.onComplete - 无论成功失败都会执行的回调函数(可选)

* @usage app.monitor({ eventName: 'onOpenPage', callback: (data)=>{ console.log('端上push消息', data ) } })

* @returns {String} id - 绑定事件的id

* @status 已上线

*/

monitor: ({ onSuccess, onFail, onComplete, eventName = "", callback = () => { } }) => {

let _id = uuid();

// 业务代码省略

return _id;

},

}

解析脚本

接下来要写一个解析脚本,把注释内容提取成键值对格式,主要用正则表达式来解析注释:

const fs = require('fs');

const path = require('path');

/**

* 解析参数或返回值标签

* @param {string} content - 标签内容

* @param {string} type - 类型 ('param' 或 'returns')

* @returns {Object} 解析后的参数或返回值对象

*/

function parseParamOrReturn(content, type = 'param') {

const match = content.match(/{([^}]+)}\s+(\w+)(?:\.(\w+))?\s*-?\s*(.*)/);

if (!match) return null;

const paramType = match[1];

const parentName = match[2];

const childName = match[3];

const description = match[4].trim();

const isParam = type === 'param';

if (childName) {

// 嵌套参数或返回值 (options.title 或 data.result 格式)

return {

name: parentName,

type: 'Object',

description: isParam ? `{parentName} 配置对象\` : \`{parentName} 返回对象`,

required: isParam ? true : undefined,

children: [{

name: childName,

type: paramType,

description: description,

required: isParam ? (!paramType.includes('?') && !description.includes('可选')) : undefined

}]

};

} else {

// 普通参数或返回值

return {

name: parentName,

type: paramType,

description: description,

required: isParam ? (!paramType.includes('?') && !description.includes('可选')) : undefined

};

}

}

/**

* 合并嵌套对象

* @param {Array} items - 参数或返回值数组

* @returns {Array} 合并后的数组

*/

function mergeNestedItems(items) {

const merged = {};

items.forEach(item => {

if (item.children) {

// 嵌套对象

if (!merged[item.name]) {

merged[item.name] = { ...item };

} else {

// 合并子元素

if (!merged[item.name].children) merged[item.name].children = [];

merged[item.name].children.push(...item.children);

}

} else {

// 普通参数

if (!merged[item.name]) {

merged[item.name] = item;

}

}

});

return Object.values(merged);

}

/**

* 保存标签内容到注解对象

*/

function saveTagContent(annotation, tag, content) {

// 确保 parameters 和 returns 数组存在

if (!annotation.parameters) annotation.parameters = [];

if (!annotation.returns) annotation.returns = [];

switch (tag) {

case 'appVersion':

annotation.appVersion = content;

break;

case 'sxzVersion':

annotation.sxzVersion = content;

break;

case 'mddVersion':

annotation.mddVersion = content;

break;

case 'description':

annotation.description = content;

break;

case 'status':

annotation.status = content;

break;

case 'usage':

annotation.usage = content.trim();

break;

case 'apiType':

// 解析类型:property 或 method

annotation.type = content.toLowerCase();

break;

case 'param':

const param = parseParamOrReturn(content, 'param');

if (param) {

annotation.parameters.push(param);

// 合并嵌套对象

annotation.parameters = mergeNestedItems(annotation.parameters);

}

break;

case 'returns':

const returnItem = parseParamOrReturn(content, 'returns');

if (returnItem) {

annotation.returns.push(returnItem);

// 合并嵌套对象

annotation.returns = mergeNestedItems(annotation.returns);

}

break;

}

}

/**

* 解析 JSDoc 注释中的注解信息 - 逐行解析

*/

function parseJSDocAnnotation(comment) {

if (!comment) return null;

const annotation = {};

// 按行分割注释

const lines = comment.split('\n');

let currentTag = '';

let currentContent = '';

for (const line of lines) {

// 清理行内容,移除 * 和首尾空格,但保留内部的换行意图

const cleanLine = line.replace(/^\s*\*\s*/, '').trimRight();

// 跳过空行和注释开始结束标记

if (!cleanLine || cleanLine === '/' || cleanLine === '*/') continue;

// 检测标签开始

const tagMatch = cleanLine.match(/^@(\w+)\s*(.*)$/);

if (tagMatch) {

// 保存前一个标签的内容

if (currentTag) {

saveTagContent(annotation, currentTag, currentContent);

}

// 开始新标签

currentTag = tagMatch[1];

currentContent = tagMatch[2];

} else if (currentTag) {

// 继续当前标签的内容,但保留换行

// 对于 @usage 标签,我们保留原始格式

if (currentTag === 'usage') {

currentContent += '\n' + cleanLine;

} else {

currentContent += ' ' + cleanLine;

}

}

}

// 保存最后一个标签的内容

if (currentTag) {

saveTagContent(annotation, currentTag, currentContent);

}

// 确保 parameters 和 returns 数组存在(即使为空)

if (!annotation.parameters) annotation.parameters = [];

if (!annotation.returns) annotation.returns = [];

return Object.keys(annotation).length > 0 ? annotation : null;

}

/**

* 使用 @apiType 标签指定类型

*/

function extractAnnotationsFromSource(sourceCode) {

const annotations = { properties: {}, methods: {} };

// 使用更简单的逻辑:按行分析

const lines = sourceCode.split('\n');

for (let i = 0; i < lines.length; i++) {

const line = lines[i].trim();

// 检测 JSDoc 注释开始

if (line.startsWith('/**')) {

let jsdocContent = line + '\n';

let j = i + 1;

// 收集完整的 JSDoc 注释

while (j < lines.length && !lines[j].trim().startsWith('*/')) {

jsdocContent += lines[j] + '\n';

j++;

}

if (j < lines.length) {

jsdocContent += lines[j] + '\n'; // 包含结束的 */

// 查找注释后面的代码行

for (let k = j + 1; k < lines.length; k++) {

const codeLine = lines[k].trim();

if (codeLine && !codeLine.startsWith('//') && !codeLine.startsWith('/*')) {

// 解析注解

const annotation = parseJSDocAnnotation(jsdocContent);

if (annotation) {

// 从注解中获取类型(property 或 method)

let itemType = annotation.type;

let name = null;

// 如果没有明确指定类型,默认设为 method

if (!itemType) {

itemType = 'method';

}

// 提取名称

const nameMatch = codeLine.match(/^(\w+)\s*[:=]/);

if (nameMatch) {

name = nameMatch[1];

} else {

// 如果没有匹配到名称,尝试其他模式

const funcMatch = codeLine.match(/^(?:async\s+)?(\w+)\s*\(/);

if (funcMatch) {

name = funcMatch[1];

}

}

if (name) {

if (itemType === 'property') {

annotations.properties[name] = annotation;

} else if (itemType === 'method') {

annotations.methods[name] = annotation;

} else {

console.warn(`未知的类型: {itemType},名称: {name}`);

}

} else {

console.warn(`无法提取名称: ${codeLine.substring(0, 50)}`);

}

}

break;

}

}

i = j; // 跳过已处理的行

}

}

}

return annotations;

}

/**

* 从文件提取注解

*/

function extractAnnotationsFromFile(filePath) {

if (!fs.existsSync(filePath)) {

console.error('文件不存在:', filePath);

return { properties: {}, methods: {} };

}

const sourceCode = fs.readFileSync(filePath, 'utf-8');

return extractAnnotationsFromSource(sourceCode);

}

/**

* 提取所有文件的注解

*/

function extractAllAnnotations(filePaths) {

const allAnnotations = {};

filePaths.forEach(filePath => {

if (fs.existsSync(filePath)) {

const fileName = path.basename(filePath, '.js');

console.log(`\n=== 处理文件: ${fileName} ===`);

const annotations = extractAnnotationsFromFile(filePath);

if (Object.keys(annotations.properties).length > 0 ||

Object.keys(annotations.methods).length > 0) {

allAnnotations[fileName] = {

fileName,

...annotations

};

}

}

});

return allAnnotations;

}

module.exports = {

parseJSDocAnnotation,

extractAnnotationsFromSource,

extractAnnotationsFromFile,

extractAllAnnotations

};

集成到构建流程

然后创建一个脚本,指定要解析的源文件,把生成的 JSON 文件 输出到 build 目录里:

const { extractAllAnnotations } = require('./jsdoc-annotations');

const fs = require('fs');

const path = require('path');

/**

* 主函数 - 提取注解并生成JSON文件

*/

function main() {

const filePaths = [

path.join(process.cwd(), './app.js'),

path.join(process.cwd(), './xxx.js'),

path.join(process.cwd(), './yyy.js'),

].filter(fs.existsSync);

if (filePaths.length === 0) {

console.error('未找到任何文件,请检查文件路径');

return;

}

const annotations = extractAllAnnotations(filePaths);

const outputPath = path.join(process.cwd(), './build/api-annotations.json');

// 保存为JSON文件

fs.writeFileSync(outputPath, JSON.stringify(annotations, null, 2));

}

main();

在 package.json 里定义构建指令,确保 build 的时候自动运行解析脚本:

{

"scripts": {

"build:annotations": "node scripts/extract-annotations.js",

"build": "(cd template/main-app && npm run build) && npm run build:annotations"

},

}

执行效果:运行 npm run build 后,会生成结构化的 JSON 文件:

1_json结构

在文档中展示

框架项目和文档项目是分开的,把 JSON 文件生成到 build 文件夹,上传到服务器后提供固定访问路径。

有了结构化的 JSON 数据,生成文档页面就很简单了。在 dumi 文档里,把解析逻辑封装成组件:


title: xxx

order: 2


```jsx

/**

* inline: true

*/

import JsonToApi from '/components/jsonToApi/index.jsx';

export default () => ;

```

渲染效果如图所示

2_渲染效果

在将 JSON 数据解析并渲染到页面的过程中,有两个关键的技术点需要特别关注:

要点一:优雅的代码展示体验

直接使用 dangerouslySetInnerHTML 来呈现代码片段会导致页面样式简陋、缺乏可读性。我们需要借助代码高亮工具来提升展示效果,同时添加便捷的复制功能,让开发者能够轻松复用示例代码。

import React from 'react';

import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';

import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

const CodeBlock = ({

children,

language = 'javascript',

showLineNumbers = true,

highlightLines = []

}) => {

const [copied, setCopied] = React.useState(false);

// 可靠的复制方法

const copyToClipboard = async (text) => {

try {

// 方法1: 使用现代 Clipboard API

if (navigator.clipboard && window.isSecureContext) {

await navigator.clipboard.writeText(text);

return true;

} else {

// 方法2: 使用传统的 document.execCommand(兼容性更好)

const textArea = document.createElement('textarea');

textArea.value = text;

textArea.style.position = 'fixed';

textArea.style.left = '-999999px';

textArea.style.top = '-999999px';

document.body.appendChild(textArea);

textArea.focus();

textArea.select();

const success = document.execCommand('copy');

document.body.removeChild(textArea);

return success;

}

} catch (err) {

console.error('复制失败:', err);

// 方法3: 备用方案 - 提示用户手动复制

prompt('请手动复制以下代码:', text);

return false;

}

};

const handleCopy = async () => {

const text = String(children).replace(/\n$/, '');

const success = await copyToClipboard(text);

if (success) {

setCopied(true);

setTimeout(() => setCopied(false), 2000);

}

};

return (

{/* 语言标签 */}

background: '#1e1e1e',

color: '#fff',

padding: '8px 16px',

borderTopLeftRadius: '8px',

borderTopRightRadius: '8px',

borderBottom: '1px solid #333',

fontSize: '12px',

fontFamily: 'monospace',

display: 'flex',

justifyContent: 'space-between',

alignItems: 'center'

}}>

{language}

onClick={handleCopy}

style={{

position: 'absolute',

top: '8px',

right: '8px',

background: copied ? '#52c41a' : '#333',

color: 'white',

border: 'none',

padding: '4px 8px',

borderRadius: '4px',

fontSize: '12px',

cursor: 'pointer',

zIndex: 10,

transition: 'all 0.3s'

}}

>

{copied ? '? 已复制' : '?? 复制'}

{/* 代码区域 */}

language={language}

style={vscDarkPlus}

showLineNumbers={showLineNumbers}

wrapLines={true}

lineProps={(lineNumber) => ({

style: {

backgroundColor: highlightLines.includes(lineNumber)

? 'rgba(255,255,255,0.1)'

: 'transparent',

padding: '2px 0'

}

})}

customStyle={{

margin: 0,

borderTopLeftRadius: 0,

borderTopRightRadius: 0,

borderBottomLeftRadius: '8px',

borderBottomRightRadius: '8px',

padding: '16px',

fontSize: '14px',

lineHeight: '1.5',

background: '#1e1e1e',

border: 'none',

borderTop: 'none'

}}

codeTagProps={{

style: {

fontFamily: '"Fira Code", "Monaco", "Consolas", "Courier New", monospace',

fontSize: '14px'

}

}}

>

{String(children).replace(/\n$/, '')}

);

};

export default CodeBlock;

要点二:锚点导航方案

由于我们是通过组件方式动态渲染内容,无法直接使用 dumi 内置的锚点导航功能。这就需要我们自主实现一套导航系统,并确保其在不同屏幕尺寸下都能保持良好的可用性,避免出现布局错乱的问题。

import React, { useEffect, useRef } from 'react';

import { Anchor } from 'antd';

export default function readJson(props){

const anchorRef = useRef(null);

const anchorWrapperRef = useRef(null);

useEffect(() => {

// 使用更长的延迟确保 DOM 完全渲染

const timer = setTimeout(() => {

const contentElement = document.querySelector('.dumi-default-content');

const anchorElement = anchorRef.current;

if (!contentElement || !anchorElement) return;

// 创建锚点容器

const anchorWrapper = document.createElement('div');

anchorWrapper.className = 'custom-anchor-wrapper';

Object.assign(anchorWrapper.style, {

position: 'sticky',

top: '106px',

width: '184px',

marginInlineStart: '24px',

maxHeight: '80vh',

overflow: 'auto',

overscrollBehavior: 'contain'

});

// 插入到内容元素后面

if (contentElement.nextSibling) {

contentElement.parentNode.insertBefore(anchorWrapper, contentElement.nextSibling);

} else {

contentElement.parentNode.appendChild(anchorWrapper);

}

// 移动锚点

anchorWrapper.appendChild(anchorElement);

// 记录锚点容器,用于清理

anchorWrapperRef.current = anchorWrapper;

}, 500); // 500ms 延迟,确保 DOM 完全渲染

return

targetOffset={80}

items={[

{

key: 'properties',

href: '#properties',

title: '属性',

children: Object.keys(properties).map(item => ({

key: item,

href: `#${item}`,

title: item

}))

},

{

key: 'methods',

href: '#methods',

title: '方法',

children: Object.keys(methods).map(item => ({

key: item,

href: `#${item}`,

title: item

}))

}

]}

/>

}