一、今日整体工作内容
今天完成的是做了国际化支持,实现中英文语言切换。首先看了官方的 API 文档,它支持不同的编辑器语言。然后我用 React Next 18 引入了相关包,自己维护了一个 locale 键值数组,对应中文和英文字段。
全文国际化不是简单调用 API,它需要配置台注入内容。因为我一开始觉得比较麻烦,我用了很多文本的hard code,还有一些组件,包括主编辑区也是基于 BlockNote 这个开源库开发的,其实理一下也还好,因为后面用了框架。
我用的是 i18n 框架(react-i18next),能自动读取我维护的中英文 JSON 文件。
i18n 框架的核心逻辑:本质是 JSON 语言文件的“管理器”和“分发员”。
核心解决了三件事:语言文件加载、字段映射、语言切换时的状态同步。
它提供了一个 t ( )函数。我在实际代码里写了很多 t ( )函数,填入对应字段就可以切换中英文,我也不太清楚底层原理。我们要做的就是翻译工作和工程化基建。
然后又做了一个 UI 组件,只是一个按钮。这个东西我没找到现成的,用 segmented 写,并且补充localstorage 持久化,记住之前的选择。
在 main 里然后把 i18n 实例放上。
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "next-themes";
import App from "./App.tsx";
import "./locales/i18.ts";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
);
二、国际化支持
(一)编辑器语言切换异常
后面我就是在做一些很没用的工作。编辑区为什么不能跟着切换语言?编辑器语言为什么不会改?我看 BlockNote 原生是支持的,然后我就一直在弄这个...
来看官方文档是怎么介绍这块
Integration with i18n Libraries You can integrate BlockNote with popular i18n libraries like react-i18next or next-intl:
import { useCreateBlockNote, BlockNoteView } from "@blocknote/react";
import { useTranslation } from "react-i18next";
import * as locales from "@blocknote/core/locales";
function I18nEditor() {
const { i18n } = useTranslation();
const editor = useCreateBlockNote({
dictionary: locales[i18n.language as keyof typeof locales] || locales.en,
});
return <BlockNoteView editor={editor} />;
}
现在代码里一个问题:全局创建了实例,但编辑器的原则是重新创建实例就会把之前内容丢掉。
我要做的,一是切换语言,二是之前内容不丢失。
切换语言这一块,一开始偶尔能切换成功,偶尔不行,应该是渲染时机或者竞态条件的问题。有可能拿到切换数据时,组件已经渲染好了或者还没渲染,反正很玄学,根本不知道有没有被激活。
一开始 AI 给我的方案是可能太卡没加载出来。因为实例创建在 App 根组件里,切换语言会导致页面重绘。
AI 让我把 i18n 实例移到 Content 里,再做状态分发。我觉得很麻烦,之前用的都是 props 传递,改成 Context 很多地方都要改,传递实例的地方要重新声明。
后来想到原生钩子应该遵循 React Hook 规则,要有依赖数组,把 language 变量放进依赖数组里。如果当前 language 因切换改变,就重新渲染,问题就解决了。
const editor = useCreateBlockNote(
{
dictionary: lang === "zh" ? zh : en,
uploadFile,
},
[lang],
);
还有默认设置,默认打开是中文,切换后要记住,刷新后状态也保留,这些都在 i18n 的 TS 配置里。
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import en from "./en.json";
import zh from "./zh.json";
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
export const i18nPromise = i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: "zh",
nonExplicitSupportedLngs: true,
supportedLngs: ["zh", "en"],
detection: {
order: ["localStorage"],
caches: ["localStorage"],
},
interpolation: {
escapeValue: false, // react already safes from xss
},
});
export default i18n;
(二)切换语言时编辑区内容不丢失
使用 useRef 保存一份“当前的文档内容”。
- 内存缓存(Ref):使用 useRef 保存一份“当前的文档内容”。无论 editor 怎么变,Ref 里的内容都不会丢失。
- 卸载前同步:利用 useEffect 的清理函数(cleanup function),在 editor 卸载前,强制从 editor.document 中提取最新内容并更新到 Ref 中。
- 重建后填充:当新的 editor 实例挂载时,读取这个 Ref 里的内容,而不是仅仅依赖 useEditorStorage 里的 data。
// 1. 在 Editor 渲染层创建一个 Ref 存储最新内容
const latestContentRef = useRef(null);
// 2. 利用 useEffect 监听 editor 变化,在旧 editor 销毁前保存内容
useEffect(() => {
return () => {
// 销毁前的清理:将 editor 里的内容同步到 Ref
latestContentRef.current = editor.document;
};
}, [editor]);
// 3. 在初始化新 editor 后,优先使用 latestContentRef 的内容
useEffect(() => {
const content = latestContentRef.current || data?.content;
if (editor && content) {
editor.replaceBlocks(editor.document, content);
}
}, [editor]);
(三)白屏问题
-
问题:
- if (!ready) 条件内渲染完整 UI,ready 为 true 时无返回值,导致初始化完成后白屏;
- i18n.init() 是异步操作,但组件渲染时直接读取 i18n.language,此时 i18n 可能未初始化完成;
- useState(i18n.language) 初始化时机过早,LanguageDetector 浏览器语言检测也需要时间。
-
修复方案:
// 等待 i18n 初始化完成,显示 Loading 状态
if (!ready) {
return <div>Loading...</div>;
}
// ready 为 true 时,渲染完整应用
return (
<ThemeBridge>
<div className="fixed-viewport">
<Toolbar editor={editor} />
<Splitter style={{ flex: 1, height: "calc(100% - 48px)", overflow: "hidden" }} >
<Splitter.Panel className="sidebar-container sidebar-trigger" style={{ overflow: "hidden" }} collapsible={{ start: true, end: true }} >
<div className="sidebar-hidden sidebar-scrollable">
<Sidebar editor={editor} />
</div>
</Splitter.Panel>
<Splitter.Panel className="main-content" min="20%" defaultSize="80%" style={{ display: "flex", flexDirection: "column", overflow: "hidden", }} >
<div className="main-content-scrollable" style={{ padding: "15px 20px", flex: 1, overflow: "auto" }} >
<Editor key={lang} editor={editor} onSave={save} noteId={docId} />
</div>
<Footbar editor={editor} />
</Splitter.Panel>
</Splitter>
</div>
</ThemeBridge>
);
- 额外优化(解决 i18n 异步初始化问题):
const { i18n, ready } = useTranslation();
const [lang, setLang] = useState(i18n.language);
const [isI18nReady, setIsI18nReady] = useState(i18n.isInitialized);
useEffect(() => {
// 等待 i18n 初始化完成
if (i18n.isInitialized) {
setIsI18nReady(true);
setLang(i18n.language);
} else {
const unsubscribe = i18n.on("initialized", () => {
setIsI18nReady(true);
setLang(i18n.language);
});
return () => unsubscribe();
}
}, [i18n]);
// 等待 ready 后再渲染
if (!ready || !isI18nReady) {
return <div>Loading...</div>;
}
三、性能优化与打包
除此之外还做了性能优化,跟之前策略差不多,也是把导入的包做成动态加载。
不知道为什么 Web 端性能很快,但移动端测试很慢,说是 JS 包太大,我按提示重新打包,在 Vite 配置里重新配置,反而更慢了,越改越错,越改越难用()这一块之后我还要补一补性能优化。
后面也花了很多时间做 Git 版本回退,vercel回退版本再部署。
小结
这一部分很简单但是我却真的头大,终于好了。
不知道为什么 AI 给我一些错误答案,也可能我自己没描述好,乱七八糟的,有点浪费时间。瞎忙活,重复性大规模重构的要求扔给ai改代码,改完也不知道会变成什么样,上下文不够的时候拆东墙补西墙各种崩溃,感觉很蠢,效率很低。
我学到的是要去看源码、看配置。之前依赖数组,useCreateBlockNote源码里其实已经说过支持dependency 配置项,可以填依赖,要去看源码,不能只看接口文档,官方文档不会写那么细的需求。
总之,读源码很重要,要看源码,看原始数据流,要理清当前代码在做什么,比盲目翻文档重要太多!