[TOC]
通过为前端项目接入GeoGebra,初步研究AI时代数学教案的生成方案(已部署GitHub Pages)
引言
我偶然刷到了这个视频,它给我一种强烈的感觉,就是下一代的每一个学生,只要会说HTML、Tailwind CSS、JavaScript这少数几个词,就能轻松生成属于自己的互动性极强的数学课件。我感受到了极其强烈的焦虑,下一代的学生,肯定能比我们这一代人更快地更新迭代自己的知识体系。我们一天才能学会的东西,他们可能一小时就能学会。以后他们的工作能力肯定能轻松碾压我们。所以至少在LLM尚未十分成熟的2025,我想走在下一代人的前面,哪怕只是用行动骗骗自己,有能力比下一代人更快驾驭LLM……
咳咳,先不抒情了。我写这个开源项目的初衷主要有:
- 研究如何用大语言模型快速生成覆盖从小学到数学专业/研究生难度的,不同类型的数学教案。
- 研究如何在前端页面嵌入GeoGebra,增强数学教案的互动性。
本项目GitHub传送门
注意:为了减少该项目的占用空间,本项目并未包含GeoGebra源码。如果想要在本地跑起来这个项目,请自行下载GeoGebra Math Apps Bundle,并复制里面的web3d文件夹和css文件夹到本项目的public\geogebra。
250726:本项目已部署到 GitHub Pages
传送门。欢迎参观~
现阶段的整体效果-PC端:

移动端:

本文 52pojie:https://www.52pojie.cn/thread-2048343-1-1.html
本文 CSDN:https://blog.csdn.net/hans774882968/article/details/149613117
本文 juejin:https://juejin.cn/post/7530464752063348782
作者:hans774882968以及hans774882968以及hans774882968
【困难】如何给React项目接入GeoGebra
翻了下高中数学必修一(进入 https://jc.pep.com.cn/ ,选择高中数学必修第一册B版),现在已经升级为使用GeoGebra了。我还清楚地记得,15年的数学课本还是用几何画板举例的。

为什么这个项目要接入GeoGebra不用多说了吧~伟大,无需多言!
参考GeoGebra官方文档,我们主要需要:
- 导入:
<script src="GeoGebra/deployggb.js"></script>。
- 如果是自托管,需要加上这句:
applet.setHTML5Codebase('GeoGebra/HTML5/5.0/web3d/');。否则忽略。
封装 Geogebra.jsx
为了方便在React中使用GeoGebra,我们不妨封装一个Geogebra.jsx。我找到了一个叫react-geogebra的npm包,但看了眼那个源码。天哪!代码质量不太得,eslint报错有十几个!索性复制下来,自己改改。
src\component\Geogebra.jsx, copy from https://github.com/pfaffmann/react-geogebra/blob/master/src/index.js
import { useEffect, useState, useRef, useCallback } from 'react';
import styles from './Geogebra.module.scss';
import { cn } from '@/lib/utils';
const Geogebra = (props) => {
const defaultProps = {
appName: 'classic',
height: 600,
showToolBar: true,
showAlgebraInput: true,
showMenuBar: true,
};
const mergedProps = { ...defaultProps, ...props };
const refProps = useRef(mergedProps);
const { onReady, appletOnLoad } = refProps.current;
let { id, LoadComponent, debug } = refProps.current;
if (!id) {
id = 'ggb-applet';
}
if (!debug) {
debug = false;
}
// if a JSX Component is not given as a prop, use h3 with children
if (!LoadComponent) {
LoadComponent = ({ children }) => <h3>{children}</h3>;
}
const [watchPropsChange, setWatchPropsChange] = useState(false);
// gets called by Geogebra after the Applet is ready
const
(ggbApi) => {
if (appletOnLoad) appletOnLoad(ggbApi);
if (onReady) onReady();
debug && console.log(`Applet with id "${id}" is ready`);
},
[appletOnLoad, debug, id, onReady]
);
useEffect(() => {
if (window.GGBApplet) {
const parameter = JSON.parse(JSON.stringify(refProps.current));
parameter.appletOnLoad = onAppletReady;
const ggbApp = new window.GGBApplet(parameter, true);
ggbApp.setHTML5Codebase('/geogebra/web3d/');
ggbApp.inject(id);
setWatchPropsChange(false);
debug &&
console.log(`applet with id "${id}" successfully injected into the DOM`);
}
return () => {
const tag = document.getElementById(`${id}-holder`);
if (tag) {
tag.lastChild.textContent = '';
}
};
}, [watchPropsChange, debug, id, onAppletReady]);
return (
<div id={`${id}-holder`} className={cn(styles.geogebraWrapper, props.className)}>
<div id={id} style={{ width: '100%' }}></div>
</div>
);
};
export default Geogebra;
这里的核心代码是:
const ggbApp = new window.GGBApplet(parameter, true);
ggbApp.setHTML5Codebase('/geogebra/web3d/');
ggbApp.inject(id);
调用:
<Geogebra
id="geogebra"
width={1200}
height={600}
showToolbar={true}
showMenuBar={true}
allowStyleBar={true}
showAlgebraInput={true}
enableLabelDrags={false}
enableShiftDragZoom={true}
capturingThreshold={null}
showToolBarHelp={false}
errorDialogsActive={true}
showTutorialLink={true}
/>
然后就到踩坑时间!
GeoGebra 的自托管解决方案
如果把ggbApp.setHTML5Codebase('/geogebra/web3d/')去掉,那么geogebra已经能正常工作,但静态资源必须通过网络下载,而且资源总共有几十MB,所以加载时间有点长。于是我们不得不考虑自托管解决方案。
首先,按官方文档指示下载GeoGebra Math Apps Bundle,接着设置ggbApp.setHTML5Codebase('/geogebra/web3d/'),然后把下载的包的web3d文件夹复制到本项目的public\geogebra\web3d。
到此为止都自我感觉良好。运行!不出所料,报错了!这是因为它请求了 http://localhost:5215/261BBF4225A3B6C8FD1B8B949B793666.cache.js 而非预期的 http://localhost:5215/geogebra/web3d/261BBF4225A3B6C8FD1B8B949B793666.cache.js 。直接挪动这个cache.js似乎可以解决,但不太优雅。
于是我排查源码,发现这个cache文件的路径是由web3d.__moduleBase(位于geogebra\web3d\web3d.nocache.js)决定的。搜索这个变量名,发现web3d.__moduleBase = B()这句赋值决定了其值,外部没法直接修改它。所以我们继续看B函数,发现最终是这句代码决定其路径:
function e(a) {
var b = a.lastIndexOf('#');
if (b == -1) {
b = a.length;
}
var c = a.indexOf('?');
if (c == -1) {
c = a.length;
}
var d = a.lastIndexOf('/', Math.min(c, b));
return d >= 0 ? a.substring(0, d + 1) : '';
}
if(k == W && j()) {k = e(o.location.href);}
k = f(k);
return k;
我们把它改成k = e(o.location.href + 'geogebra/web3d/');,问题解决!
250629更新
接入react-router后,需要改为k = e('/geogebra/web3d/');。
250726更新
为了能够部署到GitHub Pages,我们进一步更新为:k = e(window.GGB_WEB3D_NO_CACHE_BASE_PATH);。这个全局变量我们约定在main.jsx设置:
window.GGB_WEB3D_NO_CACHE_BASE_PATH = getGeogebraHTML5Codebase();
export function getGeogebraHTML5Codebase() {
const isGitHubPages = import.meta.env.VITE_DEPLOY_TARGET === 'github-pages';
const codebase = isGitHubPages ? `${GITHUB_PAGE_BASE}geogebra/web3d/` : '/geogebra/web3d/';
return codebase;
}
详见后文《部署到GitHub Pages》一节。
之后发现,控制台没有报错了,但样式不对劲。这是因为它请求了geogebra/css/...。所以我们不能只复制web3d文件夹,还要把同级的css文件夹复制过去。至此搞定!
GeoGebra 的神秘 bug
打开控制台,窗口自动缩小,此时点击设置,选择坐标轴的颜色,会发现点击对应格子,选中的颜色却不是鼠标对应的格子的颜色。把控制台关了,窗口无法回到原来的大小,于是问题还在。控制台从未开启则没有这个问题。
GeoGebra 接入总结
- 按官方文档说的做。
- 复制web3d和css两个文件夹到
public\geogebra。
geogebra\web3d\web3d.nocache.js的k = e(o.location.href)改成k = e('/geogebra/web3d/');。
用GeoGebra进行数学教学
可参考docs\Geogebra组件文档.md。下面仅给出验证椭圆第一定义的示例脚本:
const drawEllipse = (applet) => {
applet.evalCommand('ellipse: x^2/25 + y^2/9 = 1'); // 绘制椭圆
applet.setColor('ellipse', 255, 0, 0);
applet.setLineThickness('ellipse', 3);
applet.setCaption('ellipse', '椭圆: \\frac{x^2}{25} + \\frac{y^2}{9} = 1');
applet.evalCommand('A: Point(ellipse)'); // 绘制椭圆上的动点 A
applet.evalCommand('C1: (4, 0)'); // 绘制椭圆的右焦点 C1
applet.evalCommand('C2: (-4, 0)'); // 绘制椭圆的左焦点 C2
applet.evalCommand('s1: Segment(C1, A)'); // 线段 C1A
applet.evalCommand('s2: Segment(C2, A)'); // 线段 C2A
applet.evalCommand('lenSum: s1 + s2'); // 验证椭圆上任意一点 A 到两个焦点的距离和为定值
applet.setCoordSystem(-6, 6, -4, 4);
};
<Geogebra
id="geogebra"
width={1200}
height={600}
appletOnLoad={drawEllipse}
/>
如何快速学习GeoGebra的语法?以“双曲线的反函数”为例
除了看官方文档以外,还有一些更简单的方式:
- 操作一下软件的上方工具栏,比如“Reflect about Point”(设置关于点A关于点B对称的点),然后点击平面直角坐标系中已有的两个对象,比如在这个例子中,就是两个点,就能看到等式列表新增了一条等式。
- 可以猜测“Reflect about Point”的命令含有Reflect字样,在等式输入框输入“Reflect”,便能根据软件的提示方便地补全命令。
另外,我还尝试问DeepSeek:
大佬,我有如下Geogebra代码:/* 省略 */。请问如何写代码验证A0点确实在hyperbola2上
它确实回答出来了,但给的回答太复杂了。其实,“验证双曲线的反函数还是双曲线”可以通过Reflect和Distance命令简单实现。如下(来自src\hyperbolaDefinition\config.jsx):
(applet) => {
applet.evalCommand('hyperbola1: x^2/16 - y^2/9 = 1');
applet.setColor('hyperbola1', 255, 0, 0);
applet.setLineThickness('hyperbola1', 3);
applet.evalCommand('hyperbola2: y^2/16 - x^2/9 = 1');
applet.setColor('hyperbola2', 0, 0, 255);
applet.setLineThickness('hyperbola2', 3);
applet.evalCommand('A: Point(hyperbola1)');
applet.evalCommand('l: y = x');
applet.evalCommand('A\': Reflect(A, l)');
applet.setLineThickness('l', 1);
applet.evalCommand('Distance(A\', hyperbola2)');
applet.setCoordSystem(-10, 10, -8, 8);
}
在React项目中用framer-motion实现动画
framer-motion其实很强大,但是我在此只是把它当作CSS动画的语法糖。看一个例子就秒懂了(src\TeachingPlanList.jsx):
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ scale: 1.03 }}
className={cn(
styles.lessonCard,
styles.bounceInAnimation,
'bg-white rounded-xl overflow-hidden shadow-lg'
)}
/>
y控制盒子往上飞,鼠标放上去盒子会变大。
【常规】React项目如何支持Katex公式
在 ReactNode 中:@matejmazur/react-katex
相关文件:
src\ellipseDefinition\EllipseDefinition.jsx
src\ellipseDefinition\config.jsx
首先
bun add katex @matejmazur/react-katex
接着在src\App.jsx:
import 'katex/dist/katex.min.css';
然后直接引用:
import TeX from '@matejmazur/react-katex';
<TeX>{'c = \\sqrt{a^2 - b^2}'}</TeX>
<TeX block>{String.raw`\frac{x^2}{a^2} + \frac{y^2}{b^2} = 1`}</TeX>
因为我希望DeepSeek生成一个config.js的Schema方便后续配置,所以我实际上是在src\ellipseDefinition\config.jsx导入的TeX。大致方案如下:
config配置示例:
export const config = {
properties: {
title: '椭圆的重要性质',
items: [
<><strong className="highlight">离心率</strong>:<TeX>{'e = \\frac{c}{a} \\ (0 < e < 1)'}</TeX>,表示椭圆的扁平程度</>,
<><strong className="highlight">焦点性质</strong>:从椭圆一个焦点发出的光线,经椭圆反射后会经过另一个焦点</>,
<><strong className="highlight">对称性</strong>:椭圆关于长轴、短轴和中心对称</>,
<><strong className="hard">面积公式</strong>:<TeX>{'S = \\pi \\times a \\times b'}</TeX></>,
],
},
}
EllipseDefinition.jsx调用:
<div className="knowledge-point">
<h3>🔍 {config.properties.title}</h3>
{config.properties.items.map((item, i) => (
<p key={i}>{i + 1}. {item}</p>
))}
</div>
在 Markdown String 中:marked + marked-katex-extension
为了方便LLM生成课件,我们需要让项目支持渲染Markdown。之后我们只需要在配置文件中写Markdown字符串即可。为了在marked中支持Katex公式,我们可以自己写一个renderer(我确实有搜到采用这种方案的),也可以用现成的包:marked-katex-extension。
首先
bun add marked highlight.js marked-katex-extension
接着我们可以直接实现一个React组件src\component\MarkdownRenderer.jsx,调用marked,渲染Markdown String:
import { processMarkdown } from '@/lib/marked';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import 'highlight.js/styles/paraiso-light.css';
import useCodeBlockSetup from '../hooks/useCodeBlockSetup';
import './MarkdownRenderer.scss';
export default function MarkdownRenderer({ className, content, ...rest }) {
const [htmlContent, setHtmlContent] = useState('');
const [isLoading, setIsLoading] = useState(false);
const tpmMdContainerRef = useCodeBlockSetup();
useEffect(() => {
setIsLoading(true);
const parseResult = processMarkdown(content);
parseResult
.then(result => {
setHtmlContent(result);
setIsLoading(false);
})
.catch(error => {
console.error('Error processing markdown:', error);
setHtmlContent(content);
setIsLoading(false);
});
}, [content]);
if (isLoading) {
return <div className={className} {...rest}>加载中...</div>;
}
if (typeof content === 'string') {
return (
<div
ref={tpmMdContainerRef}
dangerouslySetInnerHTML={{ __html: htmlContent }}
className={cn('tpm-markdown-container max-w-none', className)}
{...rest}
/>
);
}
return content;
}
我们完全可以把marked初始化marked-katex-extension等插件的代码写在jsx文件里,但为了让代码更清晰,我们不妨抽出一个文件src\lib\marked.js,专门处理marked的初始化:
import { marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
marked.use(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
})
);
marked.use(markedKatex({
throwOnError: false,
}));
export const processMarkdown = async (_content) => {
if (typeof _content !== 'string') {
return _content;
}
const content = _content.trim();
const result = marked(content);
const html = typeof result === 'string' ? result : await result;
return html;
};
这里有一个悬而未决的问题:接入sanitize-html会导致 style 属性被吞,导致Katex出现样式错误。官方文档说设置allowedAttributes就行,但我设置以后没生效。我懒得研究这个了,先放着吧。
【常规】数学教案生成方案探究:从抽象出搭积木的组件,到彻底Schema化
我最初的想法是,先用DeepSeek直接生成第一个教案《椭圆的定义与性质》的HTML代码,接着将其改造为React代码,然后编写规范,让LLM根据规范生成其他数学教案的React组件和config.jsx。但我发现了不少痛点:
- LLM生成的教案和已有的教案代码采用完全不一样的代码规范、不一样的技术选型、不一样的CSS代码。要在一个单页应用中统一它们存在困难。
- 看似让LLM生成完整HTML代码的方案可以激发其创造力,但实测发现,LLM真的很懒,写东西语焉不详,也不怎么生成第一个教案完全没有的新东西、新模式。
但我的幻想还没完全磨灭。我想着,抽象出一些组件,让LLM像搭积木一样搭出页面,也许能激发其创造力。于是有了docs\新课件提示词\生成jsx.md这个提示词文件,以及下面这些组件:
import Section from '@/component/teachingPlan/Section';
import KnowledgePoint from '@/component/teachingPlan/KnowledgePoint';
import Card from '@/component/teachingPlan/Card';
import Header from '@/component/teachingPlan/Header';
import LearningPartnerCard from '@/component/teachingPlan/LearningPartnerCard';
import Think from '@/component/teachingPlan/Think';
import Footer from '@/component/teachingPlan/Footer';
- @/src\component\teachingPlan\Section.jsx :包裹每个模块
- @/src/component/teachingPlan/KnowledgePoint.jsx :包裹一个知识点
- @/src\component\teachingPlan\Card.jsx :可用于包裹各种内容
- @/src\component\teachingPlan\Header.jsx :标题栏
- @/src\component\teachingPlan\LearningPartnerCard.jsx :学习伙伴
- @/src/component/teachingPlan/Think.jsx :包裹一道思考题。该组件可出现在 KnowledgePoint 或 Card 中。如果在 Card 中,则它是 KnowledgePoint 的 sibling
- @/src\component\teachingPlan\Footer.jsx :页脚
但我发现,只要保留自由度,仍然允许LLM生成整个页面React组件,上面的痛点1和2就无法解决。我仍然需要手动更改大量代码。既然LLM在教案生成方面就是没啥创造力,我就想,干脆彻底Schema化,页面一律用标准页面生成算了。这样,LLM也能专注于内容,就像在生成用于Marp产出PPT文件的Markdown文档一样。
于是有了:
- docs\新课件提示词\生成schema.md
- 页面结构的类型描述:src\component\teachingPlan\StandardPageStructure.d.ts
- 标准页面:src\component\teachingPlan\StandardPage.jsx
我决定先用提示词《生成schema.md》生成《平面向量的定义及其线性运算》课件的src\planeVectorDefinition\config.jsx,再看DeepSeek的反馈慢慢调整提示词。实验表明,这条路走得通,只需要依据DeepSeek欠考虑的点微调上述几个文档即可。TODO: 开发一个网页,方便地完成提示词的拼接。左边是编辑器,右边是预览区。仍然是用vite的虚拟模块实现。
【常规】Vite的虚拟模块:在页面中展示提示词的Markdown文档
我希望在我的网站展示项目用到的提示词。这是一类静态站点生成的需求,vitepress之类的框架都有这个能力,但我们在此想要一个足够轻量的方案。我通过搜索引擎已经了解到,Vite的虚拟模块借用了Vite的开发服务器的能力,可以达到类似于后端接口的效果。
于是我问DeepSeek:“大佬,你是一名专家前端工程师,精通前端工程化。请叫我hans7。我有一个React+vite+react-router-dom+marked的项目,希望实现以下功能:在打包时能够读取一个本地文件系统的markdown文件的内容,如README.md,通过marked渲染,然后生成一个新的组件,这个组件的路由是/prompt-display。请问如何用自定义vite插件实现?”DeepSeek给了我完整代码,可惜它的代码不能跑,因为Vite的虚拟模块不支持JSX。所以我改了下代码,让虚拟模块仅返回markdown字符串。src\plugins\vite-plugin-prompt-display.js:
import fs from 'fs';
import path from 'path';
const genSchemaPromptPath = path.resolve(process.cwd(), 'docs', '新课件提示词', '生成schema.md');
const genSchemaRelativePath = path.relative(process.cwd(), genSchemaPromptPath);
const genJsxPromptPath = path.resolve(process.cwd(), 'docs', '新课件提示词', '生成jsx.md');
const genJsxRelativePath = path.relative(process.cwd(), genJsxPromptPath);
const promptFilePaths = [genSchemaPromptPath, genJsxPromptPath];
function getEncodedPromptContent(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
return encodeURI(content);
}
export default function promptDisplayPlugin() {
const virtualModuleId = 'virtual:prompt-display';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: 'vite-plugin-prompt-display',
configureServer(server) {
promptFilePaths.forEach((promptFilePath) => {
server.watcher.add(promptFilePath);
});
server.watcher.on('change', (file) => {
if (!promptFilePaths.includes(file)) {
return;
}
const module = server.moduleGraph.getModuleById(resolvedVirtualModuleId);
if (module) {
server.moduleGraph.invalidateModule(module);
}
server.ws.send({
type: 'full-reload',
path: '*',
});
console.log('[tpm] 📄 提示词文件更新', file);
});
},
resolveId(id) {
if (id === virtualModuleId) return resolvedVirtualModuleId;
},
load(id) {
if (id === resolvedVirtualModuleId) {
const genSchemaPromptContent = getEncodedPromptContent(genSchemaPromptPath);
const genJsxPromptContent = getEncodedPromptContent(genJsxPromptPath);
return `
export const genSchemaRelativePath = String.raw\`${genSchemaRelativePath}\`;
export const genJsxRelativePath = String.raw\`${genJsxRelativePath}\`;
export const genSchemaPrompt = decodeURI(\`${genSchemaPromptContent}\`);
export const genJsxPrompt = decodeURI(\`${genJsxPromptContent}\`);
`.trim();
}
},
};
}
一些说明:
- 这个虚拟模块的格式挺标准的,适合教学。
- 之所以使用
decodeURI是因为原始字符串有单引号、双引号之类的字符,无法直接拼接为JS代码,我们需要找一种最简单的方式转义这些字符。
- 这版代码和最新版代码功能一致,区别是最新版封装了两个函数。
在React组件中,直接像import其他文件一样import即可:
import {
genSchemaPrompt,
genSchemaRelativePath,
} from 'virtual:prompt-display';
查看打包产物可知,Markdown字符串会在构建阶段被完整地打包进JS文件。
在网站中展示README
类似地,我还在网站中展示了README.md,传送门:src\plugins\vite-plugin-readme-display.js。我项目的README有图片,所以还需要手动把图片复制到dist文件夹。配置rollup-plugin-copy:
import copy from 'rollup-plugin-copy';
copy({
targets: [
{ src: 'README_assets', dest: 'dist' },
],
hook: 'writeBundle',
}),
提示词缺陷修复技巧举例
写一个多次使用的模板提示词(在本节中特指docs\新课件提示词\生成schema.md)就像写代码一样,不太可能一次性就考虑到所有的细节。所以我们需要根据LLM的反馈,尤其是在“深度思考”过程中的反馈,优化自己的模板提示词。比如:
一、我在生成《函数及其表示方法》课件时,看到DeepSeek在深度思考中输出:“我们在配置文件中只需要按照规范写类名,比如<h4 className={styles.teachingPlanH4}>,但是这里我们无法获取styles,所以实际上主组件会处理样式类。但是,为了类型正确,我们按照规范写类名字符串?不,这样不行。因为styles是一个模块,我们必须在配置文件中使用与主组件相同的样式模块。但配置文件是独立的,所以主组件在渲染时会传入styles。(省略更多废话)”就立刻意识到我忘记import styles了,而DeepSeek能力太差,推不出实际情况,需要补充:
import styles from '@/component/teachingPlan/basic.module.scss'; // 补上
<h4 className={styles.teachingPlanH4}></h4>
<h5 className={styles.teachingPlanH5}></h5>
<h6 className={styles.teachingPlanH6}></h6>
二、发现DeepSeek没有输出appletOnLoad函数。一开始有点恼火,但后来注意到,DeepSeek在深度思考中输出:“具体的绘制命令在appletOnLoad中,但这里我们只写命令字符串,因为配置对象中的函数无法序列化,所以实际上在GeogebraItem中,config是一个普通的对象,而绘制命令我们将在描述中说明,或者在conclusion中分析。”
后来我们在docs\Geogebra组件文档.md中补充了“如何在JS Config中使用”一节,它也就知道如何实现appletOnLoad方法了。
【常规】支持路由
我有不止一个课件,所以这个项目自然要支持路由。支持路由挺常规的,跟往常一样bun add react-router-dom即可。主要需要注意改一下这句k = e('/geogebra/web3d/');(详见《geogebra的自托管解决方案》一节)。src\App.jsx:
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
const routes = [
{ path: '/', element: <TeachingPlanList /> },
{ path: '/plane-vector-definition', element: <PlaneVectorDefinition /> },
// ...
{ path: '/404', element: <NotFound /> },
{ path: '*', element: <NotFound /> },
];
function App() {
return (
<HelmetProvider>
<Router>
<Layout>
<Routes>
{
routes.map((route, index) => (
<Route
key={route.path || `route-${index}`}
path={route.path}
element={route.element}
/>
))
}
</Routes>
</Layout>
</Router>
</HelmetProvider>
);
}
【常规】AI生成教案网页场景下如何解决样式冲突
我们先生成第一个教案网页,然后研究如何生成其他教案网页。这就有一个绕不过去的问题:如何解决样式冲突?我想到两条路:
- 尽量少更改AI生成的第一个教案网页,将其放入shadow dom。我尝试了一下,发现主要的困难在于geogebra。这条路也许能走通,但风险太高,舍弃。
- 它的源码引用了
document.getElementById、document.querySelector等方法。这些方法都会失效。这个是可解的,hook它们就行。
- geogebra引入了5个CSS文件(可以用
document.querySelectorAll('link.ggw_resource')拿到)。它们无法作用到shadow dom内部。这个也好处理,写段JS手动把它们插入到shadow dom里即可。
- 处理上面两点了,就不再有大错误了,但还有一些隐蔽的报错。比如:
:root指定的CSS变量无法引用到,导致设置坐标轴颜色的OK按钮失去背景色。修改geogebra/css/bundles/bundle.css的:root为:root, :host可解决。
- 等式栏的每个条目左上角的三个点点击两次才能出现菜单栏。这个确实不懂怎么解了。
- 引入scss和css modules。手动或者让AI改好第一个教案网页。然后用第一个教案网页的代码生成开发规范,在后续生成其他教案网页时将开发规范一起输入到提示词里。最后我选择了这条路。
附:如何hook document.getElementById:
export function getShadowRootWrap() {
return document.querySelector('.shadow-root-div')?.shadowRoot;
}
const settings = {
showLog: false,
};
export function hookGetEleById() {
settings.showLog && console.log('%c[teaching-plan-analytic-geometry] 正在 hook document.getElementById', 'background: #3a2e00; color: #e2c041; padding: 3px 6px;');
const originalGetEleById = document.getElementById;
document.getElementById = function (id) {
settings.showLog && console.log('[teaching-plan-analytic-geometry] document.getElementById matched param', id);
const shadowRootWrap = getShadowRootWrap();
if (!shadowRootWrap) {
return originalGetEleById.call(document, id);
}
const res = shadowRootWrap?.querySelector(`#${id}`);
return res || null;
};
}
【常规】接入shadcn-ui组件库
我发现这个项目还是需要一些表单组件的,所以我决定接入shadcn-ui组件库。我们已经配过Tailwind CSS了,根据官方文档,接下来大致要做:
- 引入shadcn用到的几十个CSS变量
- 引入
jsconfig.json
- 新建
components.json
引入CSS变量:我们不妨去 https://tweakcn.com/editor/theme 直接拿好看的主题,我选择的是modern-minimal,目前和我项目的蓝色主题比较配。
components.json不需要特别配置,直接从官方文档复制粘贴,改下css和components属性就行。
jsconfig.json可以理解为tsconfig.json在无TS的开发环境的简化版,可以方便IDE实现智能补全等特性,但shadcn的CLI也会用到,所以我们不得不配置一下。
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"src/**/*",
"tests/**/*"
]
}
接下来可以直接尝试下载 21st.dev 这个网站提供的一些好看的组件:bunx --bun shadcn@latest add "https://21st.dev/r/originui/numbered-pagination"。
【常规】Think组件
效果:

这个组件唯一值得说的就是如何实现展开/收起的过渡动画。我们用一个变量记录是否展开:const [isOpen, setIsOpen] = useState(false);,然后用CSS transition或者framer-motion控制max-height的增减。
<motion.div
className={styles.answerRow}
initial={{ maxHeight: 0, marginTop: '0' }}
animate={{
maxHeight: isOpen ? answerRowMaxHeight || '100px' : 0,
marginTop: isOpen ? '8px' : '0',
}}
transition={{ duration: 0.3 }}
>
<MarkdownRenderer content={answer} />
</motion.div>
我们必须要指定一个具体的max-height,否则不会有动画。因此我选择让外界传入这个属性,然后手工调整。
另外,上面设置的marginTop是为了照顾问题和答案之间的分割线。
【困难】Markdown 代码块交互升级:展示行号、支持展开代码块、复制代码、下载代码
呜呜呜,我确实后悔没有做足够多技术调研,一直没发现有react-markdown这个包,所以现在也只能将就用marked渲染Markdown。
效果:

如果只使用marked-highlight插件,拿到的代码块就只展示了代码块。但我们看DeepSeek官网等人们熟悉的页面,里面的代码块都有行号,支持复制,支持展开代码等功能。我问LLM以及在互联网上搜,都没有搜到现成的解决方案,所以我认定这是要古法手作编程的。网上冲浪一小会以后,我觉得掘金的“展开代码块”的设计和vitepress的复制按钮的点击效果不错,因此决定把它们抄过来。
【困难】不得不在React中写原生HTML代码
我翻了marked-highlight的源码,发现它提供的自定义能力太弱了,所以我决定抛弃marked-highlight,自定义renderer实现(这其实也是marked-highlight的实现方式)。我们不妨把相关逻辑单独提取出一个文件,于是有了src\lib\hljsRenderer.js:
import hljs from 'highlight.js';
function getLineNumbersHtml(lineLength) {
const lineNumbersCode = [...Array(lineLength)]
.map(
(_, index) =>
`<span class="line-number">${index + 1}</span><br>`
)
.join('');
const lineNumbersWrapperCode = `<div class="line-numbers-wrapper" aria-hidden="true">${lineNumbersCode}</div>`;
return lineNumbersWrapperCode;
}
export default {
code({ lang, text: code }) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(code, { language }).value;
const dataCode = encodeURI(code);
const lineLength = code.split('\n').length;
const lineNumbersHtml = getLineNumbersHtml(lineLength);
return `
<div class="code-block-wrapper" data-language="${language}" data-line-count="${lineLength}">
<div class="code-header">
<div class="header-left-part">
<div class="svg-chevron-down-wrapper" title="展开代码块"></div>
<span class="language-tag">${language}</span>
</div>
<div class="header-right-part">
<button title="复制代码" class="copy-button" data-code="${dataCode}"></button>
<div class="svg-download-wrapper" title="下载代码"></div>
</div>
</div>
<div class="code-body">
${lineNumbersHtml}
<pre class="code-pre"><code class="highlighted-code hljs language-${language}">${highlighted}</code></pre>
</div>
</div>`.trim();
},
};
在renderer里拼接一大段HTML确实挺难绷的,而且有XSS风险,但我确实没找到能在React中优雅地完成这件事的方案。可以看到我在上面设置了一个留空的svg-chevron-down-wrapper,这是为了方便JS插入“展开代码块”的svg图标。
- 为了给这些HTML代码加上CSS,我写了
src\styles\code-block.scss,供入口main.jsx调用。
- 为了实现复制代码等功能,我们需要写大段大段的原生JS。为此,我们写一个自定义hook(
src\hooks\useCodeBlockSetup.js),供MarkdownRenderer组件调用。
src\hooks\useCodeBlockSetup.js的大致框架
import { useEffect, useRef } from 'react';
import ChevronDown from '@/assets/fa-chevron-down.svg';
import { CODE_BODY_INITIAL_MAX_HEIGHT } from '@/common/consts';
export default function useCodeBlockSetup() {
const tpmMdContainerRef = useRef(null);
useEffect(() => {
const codeBlockWrapperSetup = () => {
tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper')?.forEach((codeBlockWrapper) => {
});
tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper .svg-chevron-down-wrapper')?.forEach((svgChevronDownWrapper) => {
if (svgChevronDownWrapper.children.length) {
return;
}
});
tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper .copy-button')?.forEach((button) => {
});
};
codeBlockWrapperSetup();
const setupObserver = new MutationObserver(codeBlockWrapperSetup);
tpmMdContainerRef.current && setupObserver.observe(tpmMdContainerRef.current, {
subtree: true,
childList: true,
});
return () => {
setupObserver.disconnect();
};
}, []);
return tpmMdContainerRef;
}
在useEffect里用MutationObserver是基础操作了,而对tpmMdContainerRef.current的操作就是在管理一个Markdown块下所有的code-block-wrapper。
MarkdownRenderer调用:
const tpmMdContainerRef = useCodeBlockSetup();
<div ref={tpmMdContainerRef} />
【常规】让code-block-wrapper的样式与代码块保持一致
250811更新:该方案已被废弃,请参考《编写 postcss 插件:如何在 marked + highlight.js 中实现切换代码块风格》。
我之前引用了paraiso-light主题,这个主题会设置代码块的背景色和默认文字颜色,所以我的code-block-wrapper需要拿到这个主题设置以后的CSS。这个活我认为只能用JS实现。
const codeNode = codeBlockWrapper.querySelector('.highlighted-code');
const codeNodeComputedStyle = getComputedStyle(codeNode, null);
const codeNodeBgColor = codeNodeComputedStyle.getPropertyValue('background-color');
const codeNodeColor = codeNodeComputedStyle.getPropertyValue('color');
codeBlockWrapper.style.backgroundColor = codeNodeBgColor;
codeBlockWrapper.style.color = codeNodeColor;
【常规】展开、收起代码块支持过渡动画
这个可以直接挪用我之前的Think组件的实现方案。我们让DeepSeek把motion转为原生CSS:
大佬,你是一名专家前端工程师,精通前端工程化。请叫我hans7。我有一个react + framer-motion项目,有如下代码:
请忽略其他代码,将motion.span和motion.div的initial、animate、transition属性翻译为css代码,类名分别叫.span和.div。
src\styles\code-block.scss相关代码大致如下:
.code-body {
// ...
transition: max-height 0.3s ease;
max-height: var(--code-body-initial-max-height);
.line-numbers-wrapper {
// ...
}
.code-pre {
flex: 1;
overflow-x: auto;
.highlighted-code {
max-height: var(--code-body-initial-max-height);
// pre code.hljs 已经设置 overflow-x ,在此把 x 和 y 的都写上了
overflow-x: auto;
overflow-y: hidden;
}
}
}
然后在JS中(src\hooks\useCodeBlockSetup.js):
const img = document.createElement('img');
img.classList.add('svg-chevron-down-wrapper-img');
img.src = ChevronDown;
img.addEventListener('click', () => {
const hasExpanded = img.classList.contains('expanded');
img.classList.toggle('expanded');
svgChevronDownWrapper.title = hasExpanded ? '展开代码块' : '收起代码块';
const codeBody = targetCodeBlockWrapper.querySelector('.code-body');
if (!codeBody) return;
const maxHeight = hasExpanded ? CODE_BODY_INITIAL_MAX_HEIGHT : (28 + targetCodeBlockWrapper.dataset.lineCount * 28);
codeBody.classList.toggle('expanded');
codeBody.style.maxHeight = `${maxHeight}px`;
const codeNode = codeBody.querySelector('.highlighted-code');
if (!codeNode) return;
codeNode.style.maxHeight = `${maxHeight}px`;
});
svgChevronDownWrapper.appendChild(img);
【常规】复制按钮
为了减轻工作量,我决定抄袭参考vitepress的源码。结合vitepress渲染出的HTML,不难定位到复制按钮的HTML位于src\node\markdown\plugins\preWrapper.ts,搜copied类名,不难定位到其CSS位于src\client\theme-default\styles\components\vp-doc.css。
复制按钮的样式:
.copy-button {
// 默认的 svg
background-image: url("svg1");
&.copied {
/*rtl:ignore*/
// 复制成功的 svg
background-image: url("svg2");
}
// 复制成功时,展示“已复制”文字
&.copied::before {
content: '已复制';
}
}
为了拿到原汁原味的代码,我们并没有直接取code标签的innerText,而是给button添加一个data-code属性。所以相关代码如下:
button.addEventListener('click', () => {
const code = decodeURI(button.dataset.code);
navigator.clipboard.writeText(code);
button.classList.add('copied');
setTimeout(() => button.classList.remove('copied'), 2000);
});
【常规】下载代码
我们发现 DeepSeek官网 的Markdown代码块有一个“下载”按钮,我们不妨也实现一个。src\hooks\useCodeBlockSetup.js:
tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper')?.forEach((codeBlockWrapper) => {
const downloadWrapper = codeBlockWrapper.querySelector('.svg-download-wrapper');
if (downloadWrapper.children.length) {
return;
}
const img = document.createElement('img');
img.classList.add('svg-download-wrapper-img');
img.src = DownloadSvg;
img.addEventListener('click', () => {
downloadCode(codeBlockWrapper);
});
downloadWrapper.appendChild(img);
});
downloadCode函数把字符串装进Blob里,然后创建a标签、设置download属性,属于模板了,已经刻在每一位前端工程师的DNA里。
function downloadCode(codeBlockWrapper) {
const copyBtn = codeBlockWrapper.querySelector('.copy-button');
if (!copyBtn) {
console.error('[tpm] failed to find .copy-button');
return;
}
const language = codeBlockWrapper.dataset.language;
const extName = hljsLangToExtName(language);
const code = decodeURI(copyBtn.dataset.code);
const blob = new Blob([code], { type: 'text/plain' });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `tpm-${document.title}.${extName}`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
toast.success('下载成功!');
}, 100);
}
这里用到sonner包的toast实现消息提示,以及hljsLangToExtName函数获取正确的文件后缀名,我将在下面两个小节分别细讲。
【常规】sonner包配置
查sonner官方文档可知,可以用style配外层盒子的样式,用classNames写Tailwind CSS可以配置里面各个UI元素的样式。
<Toaster
position='top-center'
toastOptions={{
style: {
color: 'white',
backgroundColor: 'var(--vivid-blue-bg)',
borderColor: '#4cc9f0',
},
classNames: {
title: '!font-(family-name:--tpm-font-family) !font-bold !text-base',
},
}}
/>
Tailwind可以引用CSS变量,在src\index.css配一下就行。
【简单】hljsLangToExtName函数:将highlight.js的language信息转为恰当的文件后缀名
DeepSeek给了我两个方案:
前者不符合我需求,后者OK。src\lib\hljsLangToExtName.js:
import mimeTypes from 'mime-types';
export default function hljsLangToExtName(language) {
const contentType = mimeTypes.lookup(language) || 'txt';
const extName = mimeTypes.extension(contentType) || 'txt';
return extName;
}
直接运行会遇到报错:extname未定义。稍微瞟一眼源码,不难发现是因为在浏览器环境引用了node环境的path包。bun add -D vite-ping-plan-analytic-geometry,然后配置一下polyfill即可:
import { nodePolyfills } from 'vite-plugin-node-polyfills';
plugins: [
nodePolyfills(),
]
【常规】引入@tailwindcss/typography,一键美化已渲染为HTML的博客
照官方文档说的做就能轻松引入:
@plugin "@tailwindcss/typography";
然后在src\component\MarkdownRenderer.jsx里新增类名prose dark:prose-invert即可。但我希望自定义Markdown HTML中的一些元素,比如blockquote。完成这个需求最简单的做法是直接新开一个css文件,正常书写,覆盖typography的默认样式。src\styles\prose-overwrite.scss:
.prose {
blockquote {
background-color: var(--blockquote-bg);
overflow: auto;
p {
margin: 10px 0;
}
}
// ...
}
为了方便地更改blockquote的左边框颜色和背景色,我们需要新建一个tailwind.config.js,覆盖typography内置的变量值。
module.exports = {
theme: {
extend: {
typography: () => ({
DEFAULT: {
css: {
'--tw-prose-quote-borders': 'var(--tpm-primary)',
},
},
invert: {
css: {
'--tw-prose-quote-borders': 'var(--tpm-primary-invert)',
},
},
}),
},
},
plugins: [require('@tailwindcss/typography')],
};
然后还需要在src\index.css导入:@config "../tailwind.config.js";。
【困难】编写 postcss 插件:如何在 marked + highlight.js 中实现切换代码块风格
回顾一下我之前设想的更新颜色的方式:const codeNodeComputedStyle = getComputedStyle(codeNode, null);,然后写入对应DOM元素的style属性。现在我希望实现切换代码块风格的能力,却悲痛地发现之前设想的做法不可行。只要我需要在切换时发起请求,那getComputedStyle的可行的调用时机就取决于网络请求了,这就导致我们就无法确定一个合理的setTimeout的延迟秒数。详见我之前记录的废案文档:docs\样式类需求\【废案】hljsTheme废案记录.md。
后来我想到了一种不错的做法:把highlight.js提供的所有CSS代码都拿到,存在某个JS文件里,然后用postcss插件修改选择器为我们所期望的样子,最后把CSS代码插入到页面。这个方案的好处是,只要能看见网页,所有的CSS代码就都在本地,切换时不需要发起网络请求。但其坏处是,一个用户偏好的主题可能就那几种,但所有用户都被迫在看见网页前等待含有所有CSS代码的JS代码加载完毕。我看到highlight.js的CSS代码(我把它们存储在src\common\hljsThemeCssText.js)大概有115kb,感觉没有非常糟糕,决定就先这样做。
首先,我们写一个脚本:src\scripts\generate-new-hljs-theme-css.js生成所有需要的CSS代码。
- 同样使用了对键值对排序的技巧。在
highlight.js更新后,需要再次运行脚本时,可以减少 git diff 信息。
- 我们看看这个脚本用到的一个我自己编写的postcss插件。这个插件需要把:
.hljs {
background: #e7e9db;
color: #4f424c
}
改写为:
.code-block-wrapper, .hljs {
background: #e7e9db;
color: #4f424c
}
但实测发现,并非所有主题的CSS格式都这么简单。比如node_modules\highlight.js\styles\nord.css是这样的:
.hljs {
background: #2E3440
}
.hljs,
.hljs-subst {
color: #D8DEE9
}
但不符合上述简单格式的CSS主题只有2个,出于对快速实现需求的考虑,我决定在运行postcss插件后,手动修改这两个CSS主题的代码,详见docs\样式类需求\hljsTheme.md。
postcss插件的调用方式如下:
import postcss from 'postcss';
const result = await postcss([
modifyHljsSelectorPlugin,
]).process(cssContent, { from: undefined });
这个modifyHljsSelectorPlugin是一个没有参数的函数,并且需要设置modifyHljsSelectorPlugin.postcss = true。但为了优雅地收集我修改的选择器,我把它包了一层,变成了2阶函数,如下(src\scripts\modifyHljsSelectorPlugin.js):
export default function getModifyHljsSelectorPlugin(onModify) {
const modifyHljsSelectorPlugin = () => {
};
modifyHljsSelectorPlugin.postcss = true;
return modifyHljsSelectorPlugin;
}
插件的实现细节就不说啦,毕竟这是前端工程师的核心竞争力,比较简单,用DeepSeek生成(直接用网页版生成,会使用已经废弃的老写法),然后搜索引擎搜下最新用法,照着改即可。
咳咳,还是简单说说。Rule(rule)方法让我们可以通过rule.selector拿到每一个选择器,直接对它赋值就能修改选择器。我们用selectorParser(import selectorParser from 'postcss-selector-parser')拿到选择器的一些属性,筛选出只有.hljs的。然后用postcss遍历代码块的每一条声明:
let hasBackground = false, hasColor = false;
rule.walkDecls(decl => {
if (decl.prop === 'background' || decl.prop === 'background-color') hasBackground = true;
if (decl.prop === 'color') hasColor = true;
});
if (!hasBackground || !hasColor) return;
就完成了整个判定工作。
另外,我调试时发现主题pojoaque.css和brown-paper.css是有背景图片的,我们把代码复制到JS文件后,它们的背景URL的写法就需要改。最容易想到的办法就是引入一个占位符,在恰当时机更新。比如pojoaque.css:
.code-block-wrapper, .hljs {
color: #dccf8f;
background: url(__VITE_BASE_PATH__/pojoaque.jpg) repeat scroll left top #181914
}
我选择的更新时机是创建style标签插入页面时:
function getThemeCssContent(actualHljsTheme) {
const cssContent = hljsThemeCssText[actualHljsTheme] || '';
const basePath = getWebsiteBasePath().slice(0, -1);
return cssContent.replaceAll('__VITE_BASE_PATH__', basePath);
}
styleEl.textContent = getThemeCssContent(actualHljsTheme);
接下来我们把生成的CSS代码插入到页面:
// src\component\layout\SettingsDialog.jsx
const { hljsTheme, setHljsTheme } = useSettingsStore();
<div className={widthClassName}>
<TpmCombobox
value={hljsTheme}
setValue={setHljsTheme}
options={hljsThemeOptions}
/>
</div>
然后我们直接在所有页面都引入一个副作用:
export default function Layout({ children }) {
useHljsTheme();
}
为优雅起见,我们不妨封装一个自定义hook(src\hooks\useHljsTheme.js),这个hook做的事很简单:读取const { hljsTheme } = useSettingsStore()并插入一个style标签。
用插值算法确定网站的配色
作为一名前端工程师,没有设计基础,确定网站的配色这件事还是有点伤脑筋。
我的项目没有用以上组件库,但我们已经进入LLM时代,不妨借助LLM确定初始的网站样式风格。我目前采用的思路是:先让DeepSeek生成一个网站,看着样式OK,就用它的配色和字体了。之后再用插值算法确定一些配色的细节。
DeepSeek给我生成了一个天蓝色(b = RGB(178, 222, 236))背景的HTML,我的目标是确定导航栏等部分的配色。LLM生成的配色一般都是大渐变,我只要确定渐变的两端即可。我在DeepSeek的输出中找到了一种更鲜艳的天蓝色#4cc9f0,这个就作为起点,然后用下面这段代码在两者之间取点作为终点即可:
function getGradientColor(color1, color2, percent) {
const hexToRgb = hex =>
hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i, (_, r, g, b) => '#' + r + r + g + g + b + b)
.substring(1).match(/.{2}/g)
.map(x => parseInt(x, 16));
const rgbToHex = (r, g, b) =>
'#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
const [r1, g1, b1] = hexToRgb(color1);
const [r2, g2, b2] = hexToRgb(color2);
const ratio = percent / 100;
const r = Math.round(r1 + (r2 - r1) * ratio);
const g = Math.round(g1 + (g2 - g1) * ratio);
const b = Math.round(b1 + (b2 - b1) * ratio);
return rgbToHex(r, g, b);
}
const midColor = getGradientColor('#4CA1A3', '#82CDE6', 50);
console.log(midColor);
这段代码也不需要自己写,只需要问DeepSeek要:“大佬,如何获得CSS线性渐变的两个颜色之间的某个颜色值?以background: linear-gradient(135deg, #4CA1A3 0%, #82CDE6 100%);为例。”
字体选择
效果:

我选择在body标签设置默认字体为“站酷快乐体”,标题标签h1到h6我则选择了“荆南波波黑”(来源都是: https://zhuanlan.zhihu.com/p/690446851/ )。我觉得这两个字体的颜值都不错,但荆南波波黑默认就是加粗的,再设置加粗就会太拥挤了,所以我没有把它设为默认字体,而是仅用在标题。
public\chinese-fonts.css:
@font-face {
font-family: 'Jing Nan Bo Bo Hei';
font-display: swap;
src: url(jing-nan-bo-bo-hei-bold.ttf);
}
@font-face {
font-family: 'Zhan Ku Kuai Le Ti';
font-display: swap;
src: url(zhan-ku-kuai-le-ti.ttf);
}
index.html:
<link
href="/chinese-fonts.css"
rel="stylesheet"
>
使用:
body {
font-family: 'Zhan Ku Kuai Le Ti', 'Fredoka One', sans-serif;
}
.teachingPlanH1,
.teachingPlanH2,
.teachingPlanH3,
.teachingPlanH4,
.teachingPlanH5,
.teachingPlanH6 {
font-family: 'Jing Nan Bo Bo Hei', 'Fredoka One', cursive;
font-weight: bold;
color: var(--text-dark);
}
【困难】类似于组件库文档展示部分源码的需求:在课件中展示GeoGebra的appletOnLoad的代码
我的所有课件的appletOnLoad方法都是写在配置文件里的,因此我最开始采用了非常简单粗暴的实现(src\component\teachingPlan\StandardPage.jsx):
function Inner({ config }) {
// ...
const appletOnLoadCodeBlockList = useMemo(() => {
return config.geogebraSection.geogebraList.map((geogebra) => {
const appletOnLoadSrcCode = geogebra.config.appletOnLoad.toString();
const appletOnLoadFormattedCode = js_beautify(appletOnLoadSrcCode, JS_BEAUTIFY_OPTIONS);
const res = `
\`\`\`js
${appletOnLoadFormattedCode}
\`\`\`
`;
return res;
});
}, [config]);
// ...
}
这个实现的问题是,开发阶段能拿到源码,但构建阶段源码被压缩了,信息丢失了。因此我打算写一个nodejs脚本(传送门:src\scripts\geogebra-src-code-collect.js),用Babel读取这些配置文件,找到这些代码后把它们收集起来,放到一个叫src\appletOnLoadCollection.js的新文件里。简单总结一下想达到的效果:
- 收集所有
appletOnLoad函数,生成src\appletOnLoadCollection.js。它是一个哈希表。
- 为了让
StandardPage.jsx方便拿到代码,脚本需要自动修改课件配置文件,加上访问key(我命名为appletOnLoadId)。并且我们希望修改得尽量少,让git diff更好看。
- 监听课件配置文件的更改,自动执行以上流程。
这个自然不可能古法手作,当然是让DeepSeek生成。我们最终确定的技术栈如下:
- 用Babel提取信息,但不能直接使用
@babel/generator重新生成代码,因为会有大量git diff。我们需要以行或字符为单位,直接进行字符串拼接。为此,我们不得不依赖文件的特定格式,比如:每个键值对都单独占一行,且每个属性后都有逗号。幸好我们已经配置了eslint。
- 生成
src\appletOnLoadCollection.js时,我们不妨对键排序,以减少git diff。
- 用
glob定位所有的课件配置文件,用chokidar监听文件修改。
代码总共有300多行,就不完整贴出来了。有兴趣的同学可查看:src\scripts\geogebra-src-code-collect.js。
最后还需要配个命令:
"dev": "concurrently -n 'geog,vite' 'bun watch:geogebra' 'bunx --bun vite'",
"watch:geogebra": "bun src/scripts/geogebra-src-code-collect.js",
我们希望同时执行vite命令和watch:geogebra命令,所以需要下一个叫concurrently的包:bun add -D concurrently。
vitest单测
给DeepSeek的提示词:
大佬,你是一名专家前端工程师,精通前端工程化。请叫我hans7。我有一个react + vite项目,有src\scripts\geogebra-src-code-collect.js代码如下:
请编写里面除main函数以外的所有函数的单元测试。注意:框架为vitest。
它生成的代码挺敷衍的,但修改一下勉强能用。传送门:tests\geogebra-src-code-collect.test.js。
如何mock模块:
vi.mock('fs');
vi.mock('path');
vi.mock('glob');
vi.mock('chokidar', () => ({
default: {
watch: vi.fn().mockImplementation(() => {
const ret = {};
ret.on = vi.fn().mockImplementation(() => {
return ret;
});
return ret;
}),
},
}));
vi.mock('js-beautify', () => ({
js_beautify: vi.fn().mockImplementation(code => `beautified(${code})`),
}));
beforeEach(() => {
vi.clearAllMocks();
});
如何在VSCode中调试vitest:参考链接2。打开一个新的JavaScript Debug Terminal,然后正常执行bun run test即可。
250729:如何解决配置VITE_DEPLOY_TARGET环境变量后,vitest单测运行失败的问题
我问DeepSeek的Prompt:
大佬,我执行`bun run test`时发现,测试用例运行之前会运行vite.config.js,而它引用了`import.meta.env.VITE_DEPLOY_TARGET`环境变量,导致报错:
failed to load config from teaching-plan-analytic-geometry\vite.config.js
TypeError: Cannot read properties of undefined (reading 'VITE_DEPLOY_TARGET')
请问如何解决?
它给的解决方案大都不靠谱,但我选择了其中一个:新建一个空的vitest.config.js,亲测OK。
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
在vitest中如何测试组件
安装相关的包:
bun add -D @testing-library/react @vitejs/plugin-react jsdom @testing-library/user-event
修改vitest.config.js配置:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
],
test: {
environment: 'jsdom',
setupFiles: 'tests/rtlInVitest.setup.js',
},
});
这个 setup 文件不能省,否则运行会说不存在Chai(另一个测试框架)属性之类的。tests\rtlInVitest.setup.js:
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
cleanup();
});
踩坑:不建议使用fireEvent,建议使用userEvent
我在写`pagination-with-to
README 内容过长,已截取前 64 KB。完整内容请前往 GitHub 仓库查看。
评论