Appearance
项目难点
难点 1:目录获取渲染区 dom 不同步问题
使用 MutationObserver 的原因是为了解决 React 状态更新和 DOM 渲染的时序问题。让我解释一下:
虽然 preview 内容更新会被设置到仓库中,但是 DOM 的实际渲染可能会有延迟。这是因为:
- React 的状态更新是异步的
- DOM 的实际渲染会在 React 完成虚拟 DOM 的计算后才进行
- 特别是在处理 Markdown 渲染这种复杂操作时,DOM 的更新可能会有一定延迟
如果不使用 MutationObserver ,可能会出现以下问题:
typescriptuseEffect(() => { if (!previewView) return // 这时候虽然 content 更新了,但 DOM 可能还没有渲染完成 const elements = previewView.querySelectorAll('h1, h2, h3, h4, h5, h6') setTitles(formatContents(elements)) // 可能会获取不到最新的标题 }, [content, previewView])使用 MutationObserver 的好处:
- 它能够直接监听 DOM 的变化
- 只有在 DOM 真正更新后才会触发回调
- 确保我们获取到的是最新的 DOM 结构 所以,虽然内容会更新到仓库,但使用 MutationObserver 可以确保我们在正确的时机(DOM 真正更新后)获取标题列表,避免出现标题列表不同步的问题。
最终实现:
tsx
useEffect(() => {
if (!preview) return
// 初始化时立即执行一次
if (rootElement.length > 0) {
const initialTitles = addAnchor()
setTitles(initialTitles)
if (initialTitles.length > 0) {
setActiveLink(initialTitles[0].href)
}
}
const observer = new MutationObserver(() => {
// 再获取一次元素,防止更新不及时
const elements = getRootElement()
if (elements && elements.length > 0) {
requestAnimationFrame(() => {
const newTitles = formatContents(elements)
setTitles(newTitles)
if (newTitles.length > 0 && !activeLink) {
setActiveLink(newTitles[0].href)
}
})
}
})
observer.observe(preview, {
childList: true,
subtree: true,
characterData: true,
attributes: true
})
return () => {
observer.disconnect()
}
}, [preview, rootElement])难点 2:编辑区优化
- 采用防抖的方式,减少编辑区渲染的频率,提高性能。
- 采用虚拟滚动(具体看虚拟列表)
- 使用
useDeferredValue优化预览区内容渲染
useDeferredValue它通过将某些状态的更新延迟到主渲染任务完成后再进行,确保高优先级的用户交互不会被低优先级的解析任务阻塞,从而提高应用的性能和响应速度。
tsx
const deferredContent = useDeferredValue(content) // content 为编辑区源内容
return <Preview content={deferredContent} />useDeferredValue 和 useTransition 的区别
useDeferredValue
useDeferredValue 用于延迟某些状态的更新,直到主渲染任务完成。这对于高频更新的内容(如输入框、滚动等)非常有用,可以让 U 更加流畅,避免由于频繁更新而导致的性能问题。
useTransition
useTransition 可以将一个更新转为低优先级更新,使其可以被打断,不阻塞 ui 对用户操作的响应,能够提高用户的使用体验,它常用于优化视图切换时的用户体验。
⚠️ 注意:传递给
startTransition的函数必须是同步的,React 会立即执行此函数
tsx
const [isPending, startTransition] = useTransition()
// startTransition包裹需要更新状态代码
startTransition(() => {
setCount(1)
})难点 3:工具栏插件化
使用 Map 结构,定义了基础的工具栏,对于工具栏的新增和删除,对外暴露了一个接口,方便插件开发者进行扩展。
难点 4:语法高亮
项目采用的 CodeMirror 实现的,原生实现如下:
首先需要解析输入的 JavaScript 代码文本,识别出不同的语法元素(如关键字、字符串、注释等);然后需要实时监听输入框的内容变化,对变化的内容进行解析和高亮处理;最后通过 CSS 样式或者动态插入 span 标签的方式来实现不同语法元素的颜色区分。(AST 解析的应用)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>JavaScript编辑器</title>
<style>
#editor {
width: 600px;
height: 400px;
border: 1px solid #ccc;
padding: 10px;
font-family: monospace;
white-space: pre-wrap;
overflow-y: auto;
background-color: #1e1e1e;
color: #d4d4d4;
}
.keyword {
color: #569cd6;
}
.string {
color: #ce9178;
}
.number {
color: #b5cea8;
}
.comment {
color: #6a9955;
}
.operator {
color: #d4d4d4;
}
.function {
color: #dcdcaa;
}
.variable {
color: #9cdcfe;
}
</style>
</head>
<body>
<div
id="editor"
contenteditable="true"
></div>
<script src="editor.js"></script>
</body>
</html>js
document.addEventListener('DOMContentLoaded', () => {
const editor = document.getElementById('editor')
// JavaScript关键字列表 列举部分
const keywords = [
'break',
'case',
'const',
'else',
'for',
'function',
'if',
'new',
'return',
'typeof',
'var',
'while',
'let',
'await'
]
// 操作符列表 列举部分
const operators = ['+', '-', '*']
function tokenize(code) {
let tokens = []
let current = 0
while (current < code.length) {
let char = code[current]
// 处理空白字符
if (/\s/.test(char)) {
tokens.push({
type: 'whitespace',
value: char
})
current++
continue
}
// 处理数字
if (/[0-9]/.test(char)) {
let value = ''
while (/[0-9\.]/.test(char)) {
value += char
char = code[++current]
}
tokens.push({
type: 'number',
value: value
})
continue
}
// 处理标识符和关键字
if (/[a-zA-Z_$]/.test(char)) {
let value = ''
while (/[a-zA-Z0-9_$]/.test(char)) {
value += char
char = code[++current]
}
tokens.push({
type: keywords.includes(value) ? 'keyword' : 'variable',
value: value
})
continue
}
// 处理字符串
if (char === '"' || char === "'") {
let value = char
let quote = char
char = code[++current]
while (char !== quote) {
if (char === undefined) break
value += char
char = code[++current]
}
value += quote
tokens.push({
type: 'string',
value: value
})
current++
continue
}
// 处理注释
if (char === '/' && code[current + 1] === '/') {
let value = ''
while (char !== '\n' && current < code.length) {
value += char
char = code[++current]
}
tokens.push({
type: 'comment',
value: value
})
continue
}
// 处理操作符
let op = char
if (code[current + 1] && operators.includes(char + code[current + 1])) {
op += code[++current]
}
if (operators.includes(op)) {
tokens.push({
type: 'operator',
value: op
})
current++
continue
}
// 其他字符
tokens.push({
type: 'other',
value: char
})
current++
}
return tokens
}
function highlight(code) {
const tokens = tokenize(code)
return tokens
.map((token) => {
if (token.type === 'whitespace') {
return token.value
}
return `<span class="${token.type}">${token.value}</span>`
})
.join('')
}
// 处理输入事件
editor.addEventListener('input', () => {
const selection = window.getSelection()
const range = selection.getRangeAt(0)
const offset = range.startOffset
const content = editor.innerText
editor.innerHTML = highlight(content)
// 恢复光标位置
const newRange = document.createRange()
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null,
false
)
let currentOffset = 0
let node
while ((node = walker.nextNode())) {
const nodeLength = node.length
if (currentOffset + nodeLength >= offset) {
newRange.setStart(node, offset - currentOffset)
newRange.setEnd(node, offset - currentOffset)
break
}
currentOffset += nodeLength
}
selection.removeAllRanges()
selection.addRange(newRange)
})
})