
Joplin 编码风格指南ESLint 强制约束、TypeScript 规范与 XSS 转义实践【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin本文基于 Joplin 仓库中的官方编码风格文档 readme/dev/coding_style.md完整覆盖其规定如何通过 pre-commit 钩子与 ESLint 强制代码风格、新增 TypeScript 文件的工作流、命名与导入约定、变量与函数写法、用户内容转义XSS 防护、React 组件规范、数据库与 API 命名约定、单元测试原则以及 GitHub Actions 安全写法并结合仓库中 eslint.config.js、package.json、packages/lib/markdownUtils.ts、packages/utils/html.ts 等真实源码说明每条规范背后的工具链实现与落地细节。读完后你在为 Joplin 贡献代码时能准确遵守其风格约定并理解哪些规范由 ESLint 规则自动执行、哪些依赖人工审查。由 pre-commit 钩子与 ESLint 强制的代码风格Joplin 的编码风格主要不是靠口头约定而是靠自动化手段强制执行的。文档首先指出风格由一个pre-commit 钩子执行钩子中运行eslint该钩子在任一应用目录application directory中执行yarn install时自动安装如果钩子因故未安装可在仓库根目录重新执行yarn install手动触发。从根目录 package.json 可以印证这一机制postinstall: husky gulp build即每次yarn install完成依赖安装后husky 会负责安装 git 钩子。如何手动运行 linter文档要求把编码风格尽可能转化为 ESLint 规则。添加新规则时把相应 rule 或 plugin 加入 ESLint 配置文件文档写的是eslintrc.js当前仓库已迁移到 ESLint v9 的 flat config 形式即根目录的 eslint.config.js。手动运行 linter 的命令为yarn linter ./在 package.json 中可以看到完整的 linter 脚本族linter-ci: eslint --quiet, linter-interactive: eslint-interactive --fix --quiet, linter-precommit: eslint --fix, linter: eslint --fix --quiet,其中linter-ci对应 CI 场景--quiet只报告 error 级别linter-interactive引入了eslint-interactive交互式工具见下文linter则带--fix自动修复格式类问题。添加新规则后的两种处理路径当新规则生效时往往大量既有文件不再通过 linter。文档给出两种处理方式逐个修复文件。如果文件不多、改动简单不太可能引入回归这是首选方案。用yarn linter-interactive ./禁用存量错误。这个交互式工具会处理所有文件你可以选择禁用它发现的每个存量错误做法是在报错行上方添加eslint-disable-next-line注释。这样做的好处是保留现有可工作的代码库原样同时让新代码必须遵守规则。使用这种方式时要在注释中加上说明文字Old code before rule was applied以便日后能方便地找到所有被自动禁用的行。这一约定体现了典型的棘轮式风格治理策略存量代码冻结、增量代码严格约束。当前 ESLint 配置的实际结构从源码结构看eslint.config.js 采用 ESLint v9 的defineConfigflat config并组合了多套插件核心与 TSeslint/js、typescript-eslint/parser、typescript-eslint/eslint-plugin格式化stylistic/eslint-pluginReacteslint-plugin-react与seiyab/eslint-plugin-react-hooks注释说明因上游 react-hooks 插件的 bug 使用了 fork见 eslint.config.js其他eslint-plugin-import、eslint-plugin-promise、eslint-plugin-jest、eslint-plugin-githubJoplin 自定义插件packages/tools/eslint-rules 在 eslint.config.js 中以joplin名称接入其中joplin/describe-filename规则强制测试文件使用与文件名匹配的顶层describe见 eslint.config.js 与 describeFilename.js。一个值得注意的细节ESLint v9 不再支持.eslintignore文件其存在会让yarn linter发出警告。Joplin 的解法是使用改名的忽略文件并通过eslint/compat的includeIgnoreFile注入见 eslint.config.js。TypeScript 规则创建新的.ts文件TypeScript 编译器会生成.js文件因此新建.ts文件时必须确保对应的.js文件被加入忽略清单。文档给出的操作步骤如果编译器已经为新的.ts文件生成了.js文件先删除它在项目根目录运行yarn updateIgnored或yarn postinstall。对照 package.jsonupdateIgnored映射到node packages/tools/gulp/tasks/updateIgnoredTypeScriptBuildRun.js即由 gulp 任务负责根据tsc的产出同步维护.eslintignore/.gitignore一类的忽略清单。修改前先把既有.js文件转成 TypeScript文档强调即使你只是修改一个原本是 JavaScript 的文件理想情况下也应先将其转换为 TypeScript 再动手。但如果文件很大请先询问是否需要转换——一些很旧的、很大的 JS 文件由于类型定义不明确而难以正确转换某些情况下留待以后或另一个 PR处理更好。优先使用import而非require在 TypeScript 文件中优先使用import以便从类型检查中获益。如果不生效可能需要用yarn add types/NAME_OF_PACKAGE补充类型声明。如果你要导入的是一个很旧的包它可能没有 TypeScript 类型此时使用require()是可以接受的。避免内联类型一般应当把类型单独定义以提升可读性、并让类型可以被复用。不推荐const config: { [key: string]: Knex.Config } { // ... }推荐type Config Recordstring, Knex.Config; const config: Config { // ... }在 eslint.config.js 中可以看到配套的机器约束typescript-eslint/no-explicit-any: [error]即禁止显式any类型迫使开发者认真写出可复用的类型定义。能被推断时不要显式标注类型TypeScript 可以自动推断类型很多情况下显式标注不仅没必要还让代码变得啰嗦。文档说明仓库已经启用了 ESLint 规则no-inferable-types但它只对string、number等简单类型生效对函数调用返回值并不生效因此需要人工遵守。不推荐const getSomething():string { return something; } const timestamp:number Date.now();推荐const getSomething() { return something; } const timestamp Date.now();该规则在当前配置中确实以 error 级别启用见 eslint.config.jstypescript-eslint/no-inferrable-types: [error]。文件名、导入与导出文件名约定文档规定了三类命名方式命名形式适用场景camelCase.ts导出多个成员的文件例如 checkForUpdates.tsPascalCase.ts仅当文件包含一个类且该文件以该类的默认导出为主时types.ts或fooTypes.ts共享类型定义文件例如 types.ts导入与导出成员保持同一种大小写如果你创建一个导出单个函数processData()的文件文件应命名为processData.ts导入时也应写成processData。基本就是命名保持一致——尽管 JavaScript 允许导出名与文件名不同。不推荐// ProcessDATA.ts export default const processData () { // ... }; // foo.ts import doDataProcessing from ./ProcessDATA; doDataProcessing(); ...推荐// processData.ts export default const processData () { // ... }; // foo.ts import processData from ./processData; processData(); ...只导入需要的东西只导入你真正需要的成员以便未来若实现 tree shaking 时能够受益tree shaking 需要按名导入才能识别未使用导出。不推荐import * as fs from fs-extra; // ... fs.writeFile(example.md, example);推荐import { writeFile } from fs-extra; // ... writeFile(example.md, example);变量与函数约定新代码中const常量使用camelCase不推荐// Bad! Dont use in new code! const GRAVITY_ACCEL 9.8;推荐const gravityAccel 9.8;变量在使用前就近声明不推荐// Bad! let foo, bar; const doThings () { // do things unrelated to foo, bar }; // Do things involving foo and bar foo Math.random(); bar foo Math.random() / 100; foo Math.sin(bar Math.tan(foo)); ...推荐... const doThings () { // do things unrelated to foo, bar }; // Do things involving foo and bar let foo Math.random(); let bar foo Math.random() / 100; foo Math.sin(bar Math.tan(foo)); ...文档同时提醒不要因此制造重复代码。如果某个常量被多处使用把它声明在文件顶部或放到单独导入的模块中是完全合理的。尽可能优先const而非let这一条由 ESLint 规则直接执行eslint.config.js 中配置了prefer-const: [error]同时配合no-var: [error]禁用var。优先() {}而非function() { ... }使用箭头函数可以避免处理this关键字。没有this使类组件重构为 React Hooks 更容易——类组件中对this的使用会被 TypeScript 正确检测为不合法从而暴露需要重构的位置。不推荐// Bad! function foo() { ... }推荐const foo () { ... };该规范同样由规则强制执行eslint.config.js 中的prefer-arrow-callback: [error]。避免默认参数与可选字段尽可能避免在函数定义中使用默认参数以及接口定义中的可选字段。当所有参数都是必填时重构会容易得多因为编译器会自动捕获任何缺失的参数。变量转义XSS 是日常代码中最常见的漏洞之一文档用相当篇幅讲转义变量这是全文最贴近安全实战的部分。背景XSS 是最常见的漏洞类型之一。这类漏洞很难被发现因为它不是错误——单元测试通常不会失败程序对 99% 的输入表现正常但剩下的 1% 可以被利用来窃取用户信息、使应用崩溃等。即使你认为自己控制着输入、输入一定符合某种格式也应转义未来格式可能变化或者该输入可能经由另一个 bug 被利用。最后转义数据常常也是防止 markup 代码损坏所必需的——例如 HTML 中的引号、尖括号不转义markup 就很可能被破坏。转义方式取决于数据最终要插入的位置因此没有单一函数能覆盖所有场景。插入 JS 脚本JSON.stringify()const jsCode const data ${JSON.stringify(dynamicallyGeneratedData)};插入 HTML 字符串html-entities需要把特殊字符转换为 HTML 实体通常使用html-entities包// Historically we used a conversion of the PHP htmlentities function, thus the // unusual (non-camelCase) name but since a lot of code use that function we keep // it that way. import { htmlentities } from joplin/utils/html; const html a href${htmlentities(attributes)}${htmlentities(content)}/a;这一写法可以在 packages/utils/html.ts 中得到印证htmlentities就是html-entities包AllHtmlEntities实例的encode方法的直接导出htmlentitiesDecode则是其decode方法。同文件中的attributesHtml()packages/utils/html.ts也展示了属性值逐个转义后再拼进 HTML 的典型用法。插入 URL插入查询参数时用encodeURIComponentconst url https://example.com/?page${encodeURIComponent(page)};编码完整 URL 时用encodeURIencodeURI(https://domain.com/path to a document.pdf); // https://domain.com/path%20to%20a%20document.pdf插入 Markdown 代码使用lib/markdownUtils的转义函数文档列出了 packages/lib/markdownUtils.ts 提供的四个函数逐一核对源码实现escapeTableCell()表格——把、转为实体、换行替换为br/、转义|见 markdownUtils.tsescapeInlineCode()行内代码——把反引号替换为见 markdownUtils.tsescapeTitleText()与escapeLinkUrl()链接——前者转义[和]后者把(、)、空格替换为%28、%29、%20见 markdownUtils.ts。用法示例const markdown ${markdownUtils.escapeTitleText(linkTitle)}});这些函数在仓库内被真实调用例如 Enex 导入生成器 import-enex-md-gen.ts 中的escapeLinkUrl、escapeInlineCode以及笔记导出逻辑 BaseItem.ts 中的escapeTitleText——说明导出/导入管线正是在边界处转义原则的实际落点。尽可能晚地转义理想情况下应用内部只处理原始、未编码的数据即解码与编码只发生在应用边界。这样可以避免意外双重转义也避免在应用内部频繁 encode/decode容易出错。实践含义是一获得用户输入就把它解码成应用内部格式例如对输入调用JSON.parse同理只有在数据需要输出或导出时才做转义。不推荐let parameters id${encodeURIComponent(id)}time${encodeURIComponent(Date.now())}; // Clumsy string concatenation because were dealing with already escaped data. // and we have to remember to encode every time: parameters other${encodeURIComponent(otherParam)}; const url https://example.com?${parameters}推荐// Keep the data as an object const parameters { id: id, timestamp: Date.now(), }; // Then we can easily add to it without string concatenation: parameters.other otherParam; // We escape only when it is needed: const url https://example.com?${new URLSearchParams(parameters).toString()}让错误代码看起来就是错的文档采用Make wrong code look wrong的思路给已转义变量加后缀标明它的内容类型和已转义状态。这样如果一个变量被插入字符串却没有相应后缀代码在视觉上就会显得不对劲。不推荐const userContent queryParameters.page; // ... // later: // ... const html div${userContent}/div // 上面这段代码看起来就有问题因为表面上 // 我们在直接把用户输入插入文档——而事实也确实如此。推荐// 在这里立即转义数据并加 Html 后缀表示 // 数据已转义、内容是真正的 HTML。 const userContentHtml htmlentities(queryParameters.page); // ... // later: // ... const html div${userContentHtml}/div // 这样才是正确的因为加了 Html 后缀 // 我们就知道这个变量可以安全地拼进 HTML 字符串。这套后缀约定...Html、...Md等在仓库代码中可以见到实际痕迹例如 Note.test.ts 中的resourceDirE风格命名。React 约定新代码使用函数组件新代码应使用 React Hooks 与function组件而不是继承Component的类。不推荐// Dont do this in new code! class Example extends React.Component { public constructor(props: { text: string }) { super(props); } public render() { return ( div${text}/div ); } }推荐const Example (props: { text: string }) { return ( div${text}/div ); };用自定义 Hook 简化长代码可以用 React 自定义 Hook 把冗长逻辑收敛成可复用单元。文档特别提醒如果 ESLint 报在组件外调用useFoo之类的错误先检查自定义 Hook 的命名是否符合use前缀规范这正是 hooks 规则判定这是 Hook的依据。这一规则由seiyab/eslint-plugin-react-hooks执行eslint.config.js 中启用了rules-of-hookserror与exhaustive-depserror并忽略props依赖。数据库约定使用snake_case表名与列名均用snake_case一律NOT NULL所有列都应定义为NOT NULL可带默认值见下条说明。这样查询更简单不需要同时检查NULL与0或空字符串谨慎使用默认值不要自动给列赋予默认值——很多情况下最好要求使用者显式设置否则值会被设置成使用者不知道或不想要的默认。例外是一些不那么重要的列如时间戳或系统会自动写入的列枚举式取值用整型如果一列只能取固定的一组值类型设为 integer代码中用 TypeScript enum 定义每个值的含义例如export enum Action { Create 1, Update 2, Delete 3, }文档解释了不用数据库内置 enum 的原因内置 enum 会让迁移变得困难直接查询数据库时它带来的可读性提升不足以抵消额外的麻烦。优先tinyint(1)而非bool在许多常见 DBMS包括 Joplin 使用的 SQLite 和 MySQL中布尔并不是独立类型因此优先使用tinyint(1)。Web 请求与 API 命名端点endpoints与查询参数统一使用snake_case。这与前端/移动端代码普遍使用camelCase形成明确分界JSON 之外的传输层边界遵循snake_case。单元测试约定避免 mock 对象被测对象可能依赖其他复杂的对象。为了只关注被测对象的行为可以用 mock 来替代这些依赖模拟真实对象的行为。当真实对象难以纳入单元测试时mock 是有用的。但不应过度使用这一模式因为它意味着真实代码没有被测到。尽可能直接测试算法的真实输入与输出。例如与其 mock 文件写入操作不如创建一个临时目录验证文件确实被写入了该目录。这不是硬性规则——mock 有时确有用处但只应在没有其他选择时使用。不推荐jest.spyOn(fs, readFile).mockImplementation(() { return { version: 1 }; }); const data await service.readConfig(/path/to/file.json); expect(data.version).toBe(1);推荐// Create the actual file await fs.writeFile(/path/to/file.json, { version: 1 }); // Now you can test the real implementation const data await service.readConfig(/path/to/file.json); expect(data.version).toBe(1);避免 spy 特定方法单元测试中spy 是为对象的某个特定方法创建 mock 函数监视器。与 mock 对象同理spy 应尽可能避免因为它们通常测试的是未来可能变化的实现细节。大量 spy 会让重构变得困难——你需要更新那些本不该被破坏的测试因为算法的输入与输出并没有改变。这同样不是硬性规则但 spy 只应在没有其他选择时使用。不推荐jest.spyOn(db, executeSql).mockReturnValue([ [1, row 1], [2, row 2], ]); const rows await service.fetchAll(); expect(rows[0][1]).to(row 1); expect(rows[1][1]).to(row 2);推荐// Create the actual rows instead of mocking the data. Of course // that requires setting up the database for testing. await service.saveObject(row 1); await service.saveObject(row 2); // Now you can test the real implementation const rows await service.fetchAll(); // ...GitHub Actions 安全run块中避免${{ }}当使用不当run块中的${{ }}表达式可能允许脚本注入script injection进入 GitHub Actions。为了让 workflow 更容易审计应优先把参数作为环境变量传给run块。不推荐- name: Print PR title run: | echo Title: ${{ github.event.pull_request.title }}推荐- name: Print PR title env: PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} run: | echo Title: $PULL_REQUEST_TITLE这一条针对的正是不可信输入如 PR 标题、issue 正文拼接进 shell 命令这一经典注入面与上文让错误代码看起来就是错的是同一种防御哲学把不可信数据隔离在模板展开之外。补充参考与其他项目的风格指南文档在See also一节说明Joplin 并不直接采用其他项目的风格指南但它们仍然可能有参考价值例如 TypeScript Deep Dive 的风格指南、Google TypeScript 风格指南以及基于它整理的 ts.dev 风格指南、JavaScript Standard Style 等原文给出了对应外链本文遵循规范不重复外部网址读者可按名称自行检索。与 Joplin 风格直接相关的资料还包括仓库中的 GSoC PR 指南readme/dev 目录下的 GSoC 文档以及本文多处引用的 eslint.config.js 本身——它是这套编码风格机器可执行部分的权威来源。小结哪些靠机器、哪些靠人结合文档与当前仓库源码可以把 Joplin 的风格约束分成两层ESLint 自动强制见 eslint.config.jsprefer-const、no-var、prefer-arrow-callback、no-inferrable-types、typescript-eslint/no-explicit-any、stylistic/indenttab 缩进eslint.config.js、import/prefer-default-export、命名约定typescript-eslint/naming-conventionenum 成员与接口要求StrictPascalCaseeslint.config.js、自定义joplin/describe-filename测试命名规则等依赖人工审查与文档约定就近声明变量、避免默认参数/可选字段、...Html后缀的转义命名法、尽量晚转义的边界原则、数据库NOT NULL与tinyint(1)约定、APIsnake_case命名、避免 mock/spy 的测试哲学、GitHub Actions 注入防护。理解这两层的边界是阅读 Joplin 源码与向其提交高质量 PR 的前提被规则锁定的部分交给工具未被规则覆盖的部分则严格遵循 readme/dev/coding_style.md 中的人为约定。【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考