前言
语法高亮,是代码编辑器的重要功能,不仅可以有效提高代码可读性,还能在代码编译运行前做一些简单的语法校验和提示,降低运行出错的几率。
一般支持以下功能
-
根据关键字显示不同的颜色和字体。
-
对一些低级的语法错误能给出有效提示
搜索引擎搜索计算器,会有很多网页版的计算器,功能比较简单,支持一些预置函数,交互一般也是点选的方式,约束性较强,不允许用户自由输入,用户体验一般。而允许自由输入的不支持公式和运算符高亮,很容易产生低级的输入错误。
下面在公式计算器的输入框中实现语法高亮,包括
- 公式名高亮及运算符高亮
- 括号颜色匹配
- 公式名错误提示
正文
主要原理
根据用户输入代码做词法分析,不同token标记不同颜色,最终用span + style在编辑区显示高亮效果,一个token对应一个span。
效果如下
实现思路
- 编辑器选择
input+div or div+contenteditable - 公式名、运算符定义
需要配置公式名和运算符,不同类型高亮的颜色。 - 编译器核心逻辑实现
根据配置对输入代码进行词法分析,生成Tokens,根据配置进行公式名校验和添加颜色属性、括号匹配,生成中间代码 - 渲染
根据中间代码生成html模板并在输入区渲染。
编辑器选择
编辑器选择
两种方案
-
input+div input在z-index下层处理用户输入,div添加css事件穿透在上层负责渲染高亮效果
-
div + contenteditable属性 添加 contenteditable 属性,在同一个dom下进行输入和替换渲染
这里采用第一种,编辑区和高亮渲染区最好隔离,因为高亮的处理需在用户输入内容后马上进行编译重新渲染了,一个dom同时负责处理编辑和反复的重新渲染,一是会有性能问题,二是每次重新渲染都会导致光标丢失,需要手动维护selection。(也可以采用双div,即div+contenteditable + div)
下面实现中采用双div,即 div + contenteditable(editor) + div(render),下层输入也采用div,因为input需要处理样式保证和上层div一致,以免出现文字无法对齐,光标被遮盖等。
<div id="app">
<div class="editor" contenteditable="true" autocapitalize="off"></div>
<div class="render"></div>
</div>
<style>
#app {
width: 300px;
height: 300px;
background-color: #f9f9ff;
border-radius: 4px;
position: relative;
caret-color: #333;
border: 1px solid transparent;
}
.render {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
background: none;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: break-word;
letter-spacing: 1px;
}
.editor {
overflow: auto;
white-space: pre-wrap;
overflow-wrap: break-word;
letter-spacing: 1px;
outline: none;
}
</style>
公式名运算符定义,颜色配置
const formulas = ['SUM', 'AVERAGE', 'MAX', 'MIN', 'ROUND']
const operators = ['+', '-', '*', '/', '?', '&', '>', '=', '<', '!']
const colors = {
formula: 'purple',
grapheme: 'grey',
sign: '#6CA6CD'
}
编译器核心逻辑实现
因为不涉及语法分析等复杂的过程,不需要生成AST,仅做词法分割,对Tokens进行组装重新渲染即可
- 定义状态机
const state = { initial: Symbol('initial'), sign: Symbol('sign'), grapheme: Symbol('grapheme'), leftParen: Symbol('leftParen'), rightParen: Symbol('rightParen'), }
- 词法分割
主要根据上一步定义的状态对用户的输入进行切割
-
initial:初始状态
-
sign: 运算符状态
-
grapheme:普通字符状态
-
leftParen:左括号状态,遇到左括号代表chars存储的是一个公式名
-
右括号状态,代表公式的结
function tokenize(code) { code = code + ' ' const tokens = [] // 存储Tokens const chars = [] // 当前已处理未切割的字符 let currentState = state.initial // 当前状态 while (code) { const char = code[0] switch (currentState) { case state.initial: if (isGrapheme(char)) { chars.push(char) currentState = state.grapheme } else if (char === '(') { tokens.push({ type: 'formula', value: chars.join('') }) chars.length = 0 chars.push(char) currentState = state.leftParen } else if (char === ')') { tokens.push({ type: 'grapheme', value: chars.join('') }) chars.length = 0 chars.push(char) currentState = state.rightParen } else if(isSign(char)) { tokens.push({ type: 'grapheme', value: chars.join('') }) chars.length = 0 chars.push(char) currentState = state.rightParen } code = code.slice(1) break case state.grapheme: if (isGrapheme(char)) { chars.push(char) currentState = state.grapheme } else if (char === '(') { tokens.push({ type: 'formula', value: chars.join('') }) chars.length = 0 chars.push(char) currentState = state.leftParen } else if (char === ')') { tokens.push({ type: 'grapheme', value: chars.join('') }) chars.length = 0 chars.push(char) currentState = state.rightParen } else if(isSign(char)) { tokens.push({ type: 'grapheme', value: chars.join('') }) chars.length = 0 chars.push(char) currentState = state.sign } code = code.slice(1) break case state.leftParen: tokens.push({ type: 'paren', value: chars.join('') }) chars.length = 0 chars.push(char) if (isGrapheme(char)) { currentState = state.grapheme } else if (char === '(') { currentState = state.leftParen } else if (char === ')') { currentState = state.rightParen } else if(isSign(char)) { currentState = state.sign } code = code.slice(1) break case state.rightParen: tokens.push({ type: 'paren', value: chars.join('') }) chars.length = 0 chars.push(char) if (isGrapheme(char)) { currentState = state.grapheme } else if (char === '(') { currentState = state.leftParen } else if (char === ')') { currentState = state.rightParen } else if(isSign(char)) { currentState = state.sign } code = code.slice(1) break case state.sign: tokens.push({ type: 'sign', value: chars.join('') }) chars.length = 0 chars.push(char) if (isGrapheme(char)) { currentState = state.grapheme } else if (char === '(') { currentState = state.leftParen } else if (char === ')') { currentState = state.rightParen } else if(isSign(char)) { currentState = state.sign } code = code.slice(1) break } } return tokens }
其中用到的几个工具函数
function isGrapheme(char) { // 判断字母组成的无含义词,包括纯英文字组,数字 return (char >= 'a' && char <= 'z') || return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char !== ' ' && !isNaN(char) && !Number.isNaN(char)) } function isSign(char) { // 判断运算符 return signs.includes(char) }
-
- 添加颜色标记和公式名校验
function marker(tokens) { const parenStack = [] // 栈结构记录左右括号关系,左括号对应对应的右括号添加一致的颜色 for (let i = 0; i < tokens.length; i++) { const t = tokens[i] switch (t.type) { case 'paren': if (t.value === '(') { t.color = getParenColor(i) parenStack.push(t.color) } else { t.color = parenStack[parenStack.length - 1] parenStack.pop() } break; case 'formula': t.color = colors[formula] if (!formulas.includes(t.value)) { // 判断公式名是否符合预置的公式 t.error = `invalidate formula: ${t.value}` } break case 'grapheme': t.color = colors[grapheme] break case 'sign': t.color = colors[sign] break default: break; } } }
上述标记完成后进入下一步,生成html模板并重新渲染高亮显示区 这里括号的颜色需要随机生成,但又不能完全随机,保证已渲染的左括号对应点的右括号被删除后,重新编译渲染后左括号的颜色保持不变。
const curParenColors = [] // 缓存已添加过的颜色 function getParenColor(index) { if (curParenColors[index]) { return curParenColors[index] } let color = '#' for (let i = 0; i < 6; i++) { color += Math.floor(Math.random()*16).toString(16) } curParenColors[index] = color return color }
- 渲染高亮
渲染部分因为本身dom结构不复杂,仅进行整体替换即可
const errors = [] function render(abs) { let template = '' abs.forEach(b => { if (b.error) { errors.push(b) } template += `<span style="color:${b.color};">${b.value}</span>` }) // 渲染高亮模板 renderDom.innerHTML = template errorDom.innerHtml = errors.map(b => b.error).join('、') }