最近正在使用 Tanstack Start 写一个 YouTube 视频转技术文章的 AI 应用。这是我第一次使用该框架,缘起是阅读了一篇文章我用 10 种框架开发了同款应用:移动端性能框架评估,其中一个结论:如果你需要使用 React,即 React 包体积没法避免的情况下,选择 TanStack Start 优于 Next.js。
接下来基于实际用体验我们聊一聊 Tanstack Start 的创新之处。
按照哇哦程度从小到大排序。
创新一:路由类型提示 ⭐⭐⭐
Link 和 navigate 均可,这在其他框架目前是没有做到的。原理是根据路由文件自动生成路由类型文件 src/routeTree.gen.ts。
但是 search 和 location.state 目前还没有,这个有点遗憾。
创新二:文件自动生成 ⭐⭐⭐⭐
要将新路由添加到应用中,只需在 ./src/routes 目录下新建一个文件。
TanStack 将自动为您生成该路由文件的内容。
以前大家做模板生成是怎么做的?命令行手动执行 foo-cli gen new-page 然后生成模板文件,容易忘记。Tanstack 更高级或更智能的一点是你只要按照你自己的习惯去生成文件,内容自动给你填充好,这完全贴切我们的开发流程,一比较手动执行 cli 弱爆了,而且还可以做到内容动态生成,因为它知道你正在哪一个目录新增文件。
比如你在 routes 下则给你上传路由文件,在 services 则给你生成 service 代码,在 components 下则生成组件模板,......。真得太妙了,Next.js 得学我们也得学,学这种不违背自然规律"大音希声大象无形"的思想。
出自《道德经》"大方无隅,...,大音希声,大象无形。"这是由老子提出的中国古代文学理论中的一种美学观念,意在推崇自然的、而非人为的美。
------ 百度百科
foo-cli gen new-page 是人为的美,新增文件后自动生成根据目录而变化的模板内容是自然的美。
创新三:代码定位"一键打开源码" ⭐⭐⭐⭐⭐
先剧透下,最后效果绝对惊艳!宛如第一次看"一剑开天门"的震撼感。
比如我想要修改以下 header 样式,需要复制长长的 class 然后全局搜索,但有时候类名是动态拼接的,不一定能搜索到,这时候只能去除一部分 class,逐次删除尝试,是不是很麻烦?现在好了 Tanstack Start 直接在每一个元素增加了 data-tsd-source 复制到编辑器如 VSCode <math xmlns="http://www.w3.org/1998/Math/MathML"> C t r l + P Ctrl + P </math>Ctrl+P 直接精确到行列打开,直捣黄龙!
到这里 Tanstack 就停止了它的"美学追求"了吗?并没有!还有更厉害的绝对惊艳你,你看到后面一定会发出当初和我一样"哇哦 AMAZING"的惊叹 🤩。
代码实例:
html
<header
class="p-4 flex items-center bg-gray-800 text-white shadow-lg"
data-tsd-source="/src/components/Header.tsx:23:7"
>
<button
class="p-2 hover:bg-gray-700 rounded-lg transition-colors"
data-tsd-source="/src/components/Header.tsx:24:9"
>
<svg data-tsd-source="/src/components/Header.tsx:29:11">
<path d="M4 5h16"></path>
<path d="M4 12h16"></path>
<path d="M4 19h16"></path>
</svg>
</button>
<h1 data-tsd-source="/src/components/Header.tsx:31:9">
<a data-tsd-source="/src/components/Header.tsx:32:11" href="/">
<img
src="/tanstack-word-logo-white.svg"
alt="TanStack Logo"
data-tsd-source="/src/components/Header.tsx:33:13"
/>
</a>
</h1>
</header>

生成的每一个html 元素都有 data-tsd-source,已经非常方便定位源码了,唯一不方便是得删除开头的 / 否则直接输入 data-tsd-source 路径无法定位到具体文件。能否有编译设置?
我们来一步步了解。
首先 data-tsd-source 是 Tanstack Start 的特色,通过 @tanstack/devtools-vite injectSource 控制引入:
ts
// vite.config.ts
import { devtools } from "@tanstack/devtools-vite"
const config = defineConfig({
plugins: [
devtools({
injectSource: {
enabled: false,
},
}),
]
});
我们可以用 enabled: false 关闭。当然这里只是说明其确实是被 devtools 引入的。接下来我们要配置达到删除开头 /,在翻阅文档和 issue 我们发现关键词"Click-to-code",难道 Tanstack devtool 支持点击即可打开源码!
文档 Go to source,证实我们确实可以点击即打开源码!
Go to source 前往源代码
Allows you to open the source location on anything in your browser by clicking on it.
允许您通过点击在浏览器中打开任何内容的源代码位置。
To trigger this behavior you need the Devtools Vite plugin installed and configured and the Panel available on the page. Simply click on any element while holding down the Shift and Ctrl (or Meta) keys.
要触发此行为,您需要安装并配置 Devtools Vite 插件,并且页面上需要有面板可用。只需在按住 Shift 和 Ctrl(或 Meta)键的同时点击任何元素即可。
触发方式: <math xmlns="http://www.w3.org/1998/Math/MathML"> C t r l + S h i f t + C l i c k Ctrl + Shift + Click </math>Ctrl+Shift+Click(Windows)点击你想要定位的 HTML 元素,哇哦简直 AMAZING。
也就是我们无需配置删除开头 / 了,Tanstack Devtool 将体验再拔高一个档次!之前:
Ctrl + Shift + PChrome Devtool 定位到 HTML 元素- 复制
data-tsd-source属性内容 - 打开你常用编辑器(trae 或 VSCode):输入删除开头
/的 path
现在我们可以一步到位:
<math xmlns="http://www.w3.org/1998/Math/MathML"> C t r l + S h i f t + C l i c k Ctrl + Shift + Click </math>Ctrl+Shift+Click 点击 HTML 元素"一剑开天门"。

接下来是见证奇迹的时刻:一点自动打开源码。
但是实际上并没有,啥也没发生!等会讲原因。
还是回到这个 issue Click-to-code does not work when command run from different directory #281
我们尝试修改下 issue 内提供代码,将其改成我常用的 Trae:
ts
// vite.config.ts
// 改编自 https://github.com/TanStack/devtools/issues/281#issuecomment-3607468808
const open = async (filePath, lineNumber, columnNumber) => {
const filePathString = `${filePath.replaceAll("$", "\\$")}${
lineNumber ? `:${lineNumber}` : ""
}${columnNumber ? `:${columnNumber}` : ""}`;
const launch = (await import("launch-editor")).default;
// if trae is available, use it otherwise use the default editor
const editorCli: string | undefined = await (async () => {
try {
// trae is global command use which to check if it is available use execSync
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execPromise = promisify(exec);
const { stdout } = await execPromise("which trae1");
console.log("stdout", stdout)
if (stdout) {
return "trae";
}
return undefined; // use default editor
} catch (error) {
console.error("Error checking for trae:", error);
console.error("Error checking for trae fallback to default editor");
return undefined;
}
})();
console.log("launch-editor", {
filePath,
editorCli,
lineNumber,
columnNumber,
});
// https://bgithub.xyz/yyx990803/launch-editor?tab=readme-ov-file#usage
launch(filePathString, editorCli, (filename, err) => {
console.warn(`Failed to open ${filename} in editor: ${err}`);
});
};
日志:
ts
stdout { stdout: '/e/app2/TraeCN/bin/trae\n', stderr: '' }
launch-editor {
filePath: 'F:/workspace/github/my-tanstack-app-pnpm/src/routes/index.tsx',
editorCli: 'trae',
lineNumber: '97',
columnNumber: '15'
}

这下成功了,点击元素后 Trae 自动打开源码具体到行号和列号 🎉。
现在我也知道为什么刚开始不行因为我们还没打开 VSCode 呢(但 Trae 是打开)。当然前提条件必须将 code 或 trae 安装成全局命令。
如果你常用编辑器是 VSCode,那么这段配置也无需,不过首先你得打开 VSCode,后续 <math xmlns="http://www.w3.org/1998/Math/MathML"> C t r l + S h i f t + C l i c k Ctrl + Shift + Click </math>Ctrl+Shift+Click 才会起作用。因为我用 Trae 故仍需配置。
完整配置文件如下:
ts
// vite.config.ts
import tailwindcss from "@tailwindcss/vite"
import { devtools } from "@tanstack/devtools-vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react"
import { nitro } from "nitro/vite"
import { defineConfig } from "vite"
import viteTsConfigPaths from "vite-tsconfig-paths"
const config = defineConfig({
plugins: [
devtools({
editor: {
name: "Shift + Ctrl + Click to open element src in editor",
open: async (filePath, lineNumber, columnNumber) => {
const filePathString = `${filePath}${[
lineNumber && `:${lineNumber}`,
columnNumber && `:${columnNumber}`,
]
.filter(Boolean)
.join("")}`
const launch = (await import("launch-editor")).default
const { exec } = await import("node:child_process")
const { promisify } = await import("node:util")
const execPromise = promisify(exec)
const myEditor = "trae"
// if trae is available, use it otherwise use the default editor
const editorCli: string | undefined = await (async () => {
try {
// trae is global command use which to check if it is available use execSync
await execPromise(`which ${myEditor}`)
return myEditor
} catch (_error) {
// console.warn(`Error checking for ${myEditor}:`, error)
console.warn(
`Error checking for ${myEditor} fallback to default editor`,
)
return undefined
}
})()
console.info("launch-editor", editorCli, {
filePath,
lineNumber,
columnNumber,
filePathString,
})
// https://bgithub.xyz/yyx990803/launch-editor?tab=readme-ov-file#usage
launch(filePathString, editorCli, (filename, err) => {
throw new Error(`Failed to open ${filename} in editor: ${err}`)
})
},
},
}),
nitro(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config
这是最佳解决办法了吗?并非!
配置又长又臭,还能有别的办法让其在 trae 打开吗?
我们仔细阅读 launch-editor 这个 600 万周下载量的包,作者 yyx 尤雨溪,从 react-dev-utils 抽离成单独包:
从 Node.js 中在编辑器中打开带行号的文件。
主要功能是从 react-dev-utils 中提取的,经过轻微修改,以便可以作为独立包使用。原始源代码遵循 MIT 许可证。
也增加了列号支持。
------ github.com/yyx990803/l...
yyx 还提到:
然而,其他包需要设置环境变量如
EDITOR才能打开文件。该包在回退到环境变量之前,会检查当前运行进程以推断要打开的编辑器。
这就解释了,当我们并未打开 VSCode(进程未运行),且未设置环境变量 launch-editor 自然无法打开编辑器。

"一些漫不经心的说话,将我疑惑解开。一种莫名其妙的冲动,叫我继续追寻",到这里恍然大悟 💡,除了通过"又长又臭的"配置强制切换编辑器,还可以通过环境变量来指定!
环境配置存在两种方式:
- 私人环境变量
sh
// ~/.zshrc
export LAUNCH_EDITOR=trae
bash
❯ echo $LAUNCH_EDITOR
trae
点击页面元素,确实可以通过环境变量指定的编辑器打开源码。
- 项目环境变量
我们再试试 .env 文件。.env 的好处是动态修改动态生效,无需重启 Terminal 以及 dev server。
sh
// 项目根目录 .env
LAUNCH_EDITOR=trae
Trae 成功打开。
切换 LAUNCH_EDITOR:
sh
// 项目根目录 .env
LAUNCH_EDITOR=code
VSCode 成功打开。
.env 缺点是放到工程里面如果其他同事常用编辑器和你不一样就会有问题了。故我们还是选择放到 ~/.zshrc 因为它是私人的 ~ 表示个人目录。
故最终配置:出于尊重同事习惯,我们删除了 vite.config.ts 的 devtools editor:
ts
// vite.config.ts
import tailwindcss from "@tailwindcss/vite"
import { devtools } from "@tanstack/devtools-vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react"
import { nitro } from "nitro/vite"
import { defineConfig } from "vite"
import viteTsConfigPaths from "vite-tsconfig-paths"
const config = defineConfig({
plugins: [
devtools(),
nitro(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config
还以清爽的 vite.config.ts,在私人环境变量中配置:
sh
// ~/.zshrc
export LAUNCH_EDITOR=trae
这样配置代码量最少,又能尊重他人习惯,"和而不同"。
🔬 探究 TanStack Ctrl+Shift+Click 源码跳转功能
如果我们要做自己一个类似功能,应该怎么做呢。
元素点击后我们看到这样一个网络请求:
sh
http://localhost:3000/__tsd/open-source?source=%2Fsrc%2Froutes%2Fyoutube-article-generator%2Farticles%2F%24id.tsx%3A429%3A15
简化:
ts
GET /src/routes/youtube-article-generator/articles/$id.tsx:429:15
很简单其实就是点击的那一刻发送了一个 GET 请求,服务端接收到后调用 launch-editor 利用 Node.js 本地能力打开编辑器,我猜的。看看源码吧。
TanStack Start React 项目通过 @tanstack/devtools-vite 插件实现 Ctrl+Shift+Click 点击元素打开源码的功能。该功能包含两个核心部分:源码注入和点击处理。
1. 源码注入
Vite 插件在开发模式下为 JSX 元素注入 data-tsd-source 属性:
jsx
// 转换前
<div>Hello World</div>
// 转换后
<div data-tsd-source="src/App.tsx:5:1">Hello World</div>
插件通过 AST 转换实现,使用 Babel 解析 JSX 并添加位置信息。
2. 点击事件处理
DevTools 组件监听全局点击事件,检测 Ctrl+Shift 组合键:
javascript
const openSourceHandler = (e) => {
const isShiftHeld = e.shiftKey
const isCtrlHeld = e.ctrlKey || e.metaKey
if (!isShiftHeld || !isCtrlHeld) return
if (e.target instanceof HTMLElement) {
const dataSource = e.target.getAttribute('data-tsd-source')
if (dataSource) {
// 发送请求到开发服务器
fetch(`${location.origin}/__tsd/open-source?source=${encodeURIComponent(dataSource)}`)
}
}
}
3. 服务器端处理
Vite 插件的服务器中间件处理 __tsd/open-source 请求,调用编辑器打开文件。
默认使用 launch-editor 库打开 VS Code。
配置使用
在 vite.config.ts 中启用插件:
typescript
import { devtools } from '@tanstack/devtools-vite'
export default defineConfig({
plugins: [
devtools({
injectSource: { enabled: true }, // 启用源码注入
editor: { // 自定义编辑器配置
name: 'VSCode',
open: async (path, lineNumber, columnNumber) => {
// 自定义打开逻辑
}
}
})
]
})
!NOTE
- 该功能仅在开发模式下工作,生产构建时会自动移除相关代码
- 插件会跳过包含
{...props}属性展开的 JSX 元素,避免冲突- 支持自定义编辑器配置,可适配 WebStorm、Cursor 等其他编辑器
如果对你有所启发,不妨关注公众号"JavaScript与编程艺术"。
源码摘要
File: packages/devtools-vite/src/inject-source.ts (L110-152)
typescript
const transformJSX = (
element: NodePath<t.JSXOpeningElement>,
propsName: string | null,
file: string,
) => {
const loc = element.node.loc
if (!loc) return
const line = loc.start.line
const column = loc.start.column
const nameOfElement = getNameOfElement(element.node.name)
if (nameOfElement === 'Fragment' || nameOfElement === 'React.Fragment') {
return
}
const hasDataSource = element.node.attributes.some(
(attr) =>
attr.type === 'JSXAttribute' &&
attr.name.type === 'JSXIdentifier' &&
attr.name.name === 'data-tsd-source',
)
// Check if props are spread
const hasSpread = element.node.attributes.some(
(attr) =>
attr.type === 'JSXSpreadAttribute' &&
attr.argument.type === 'Identifier' &&
attr.argument.name === propsName,
)
if (hasSpread || hasDataSource) {
// Do not inject if props are spread
return
}
// Inject data-source as a string: "<file>:<line>:<column>"
element.node.attributes.push(
t.jsxAttribute(
t.jsxIdentifier('data-tsd-source'),
t.stringLiteral(`${file}:${line}:${column + 1}`),
),
)
return true
}
File: packages/devtools/src/devtools.tsx (L162-188)
typescript
createEffect(() => {
// this will only work with the Vite plugin
const openSourceHandler = (e: Event) => {
const isShiftHeld = (e as KeyboardEvent).shiftKey
const isCtrlHeld =
(e as KeyboardEvent).ctrlKey || (e as KeyboardEvent).metaKey
if (!isShiftHeld || !isCtrlHeld) return
if (e.target instanceof HTMLElement) {
const dataSource = e.target.getAttribute('data-tsd-source')
window.getSelection()?.removeAllRanges()
if (dataSource) {
e.preventDefault()
e.stopPropagation()
fetch(
`${location.origin}/__tsd/open-source?source=${encodeURIComponent(
dataSource,
)}`,
).catch(() => {})
}
}
}
window.addEventListener('click', openSourceHandler)
onCleanup(() => {
window.removeEventListener('click', openSourceHandler)
})
})
File: packages/devtools-vite/src/plugin.ts (L120-131)
typescript
server.middlewares.use((req, res, next) =>
handleDevToolsViteRequest(req, res, next, (parsedData) => {
const { data, routine } = parsedData
if (routine === 'open-source') {
return handleOpenSource({
data: { type: data.type, data },
openInEditor,
})
}
return
}),
)
File: packages/devtools-vite/src/editor.ts (L26-38)
typescript
export const DEFAULT_EDITOR_CONFIG: EditorConfig = {
name: 'VSCode',
open: async (path, lineNumber, columnNumber) => {
const launch = (await import('launch-editor')).default
launch(
`${path.replaceAll('$', '\\$')}${lineNumber ? `:${lineNumber}` : ''}${columnNumber ? `:${columnNumber}` : ''}`,
undefined,
(filename, err) => {
console.warn(`Failed to open ${filename} in editor: ${err}`)
},
)
},
}