React初学者手搓Chrome的Json Formatter插件

180 阅读3分钟

Github项目链接:github.com/DoubleD0721…

Background

在之前的需求中接触到了React开发,开始学习后感觉可以实现相关功能的初步交付了,觉得自己可以了。之前使用的json format工具一直充斥着广告,实在受不了决定自己手搓一个json formatter的插件,方便自己后面开发的过程中的便捷。

这篇文档就记录这个插件的实现过程,整个插件的代码在 -> Git仓库,后面在Json Formatter的基础上,也扩展了RGBA和Hex的互转、Url的Encode和Decode功能。

React基础介绍

这里主要是介绍一些react的基础语法和hooks,并使用kotlin语法做对比,方便理解(因为作者本身是Android开发)。如果已经对语法和hooks比较熟悉,可以直接跳转到 Solution 目录下。

基础语法

  • 空值合并运算符

?? 是空值合并运算符,当左侧表达式为 null 或 undefined 时,返回右侧表达式的值。

const value1: string | null = null;
const result1 = value1 ?? 'Default Value';
console.log(result1);

const value2: string | null = 'Actual Value';
const result2 = value2 ?? 'Default Value';
console.log(result2);
fun main() {
    val value1: String? = null
    val result1 = value1 ?: "Default Value"
    println(result1) // 输出: Default Value
    val value2: String? = "Actual Value"
    val result2 = value2 ?: "Default Value"
    println(result2) // 输出: Actual Value
}
  • 可选链操作符

?. 是可选链操作符,当对象属性可能为 null 或 undefined 时,可安全地访问对象的属性或方法。

interface Person {
    address?: {
        city?: string;
    };
}
const person: Person = {};
const city = person.address?.city;
console.log(city); // 输出: undefined
class Address(var city: String? = null)
class Person(var address: Address? = null)
fun main() {
    val person = Person()
    val city = person.address?.city
    println(city) // 输出: null
}
  • 类型判断

类型断言用于告诉编译器某个变量的具体类型,有两种语法形式:尖括号语法和 as 语法。

const value: any = "Hello";
const length1 = (value as string).length;
const length2 = (<string>value).length;
console.log(length1); // 输出: 5
console.log(length2); // 输出: 5
fun main() {
    val value: Any = "Hello"
    val str1 = value as String
    val str2 = value as? String
    println(str1.length) // 输出: 5
    println(str2?.length) // 输出: 5
}
  • 解构赋值

解构赋值可以从数组或对象中提取值并赋给变量。

// 数组解构
const numbers = [1, 2, 3];
const [a, b, c] = numbers;
console.log(a, b, c); // 输出: 1 2 3

// 对象解构
const person = { name: "Alice", age: 30 };
const { name, age } = person;
console.log(name, age); // 输出: Alice 30
data class Person(val name: String, val age: Int)
fun main() {
    val person = Person("Alice", 30)
    val (name, age) = person
    println("$name $age") // 输出: Alice 30
    // 数组解构
    val numbers = arrayOf(1, 2, 3)
    val (a, b, c) = numbers
    println("$a $b $c") // 输出: 1 2 3
}
  • 匿名函数
const add = (a: number, b: number) => a + b;
console.log(add(1, 2)); // 输出: 3
fun main() {
    val add = { a: Int, b: Int -> a + b }
    println(add(1, 2)) // 输出: 3
}

常用Hooks函数

useState

用于在函数组件中添加和管理状态。它返回一个数组,第一个元素是当前状态值,第二个元素是用于更新该状态的函数。

const UseStateExample = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button 
        onClick={() => setCount(count + 1)}
      >Increment</button>
    </div>
  );
};

useContext

用于在函数组件中访问 React 上下文(Context)的值,避免通过props层层传递数据。

const ThemeContext = createContext<string>('light');
const UseContextExample: React.FC = () => {
  const { theme } = useContext(ThemeContext);
  return (
    <div>
      <p>Current theme: {theme}</p>
    </div>
  );
};

useEffect

处理函数组件中的副作用操作,例如数据获取、订阅、手动修改 DOM 等。可以在组件挂载、更新、卸载时执行相应的逻辑。类似swift UI的@State

const UseEffectExample = () => {
  const [data, setData] = useState<string | null>(null);
  useEffect(() => {
    // 组件挂载时发起网络请求
    const fetchData = async () => {
      try {
        const response = await fetch('xxx');
        const jsonData = await response.json();
        setData(jsonData.title);
      } catch (error) {
        console.error('Error fetching data:', error);
      }
    };
    fetchData();
    // 组件卸载时执行清理操作
    return () => {
      console.log('Component unmounted');
    };
  }, []);
  return (
    <div>
      {data ? <p>Data: {data}</p> : <p>Loading...</p>}
    </div>
  );
};

useRef

创建一个可变的引用对象,在组件的整个生命周期内保持不变。常用于获取 DOM 元素、存储不需要触发重新渲染的变量。

const UseRefExample = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const focusInput = () => {
    if (inputRef.current) {
      inputRef.current.focus();
    }
  };
  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus Input</button>
    </div>
  );
};

useCallback

返回一个记忆化的回调函数,只有当依赖项发生变化时,才会重新创建该函数。常用于优化子组件的性能,避免不必要的重新渲染。

const ChildComponent: React.FC<{ onClick: () => void }> = ({ onClick }) => {
  return <button onClick={onClick}>Click me</button>;
};

const UseCallbackExample: React.FC = () => {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);
  return (
    <div>
      <p>Count: {count}</p>
      <ChildComponent onClick={handleClick} />
    </div>
  );
};

useMemo

返回一个记忆化的值,只有当依赖项发生变化时,才会重新计算该值。常用于优化性能,避免不必要的计算。

const UseMemoExample = () => {
  const [num1, setNum1] = useState(1);
  const [num2, setNum2] = useState(2);
  // 使用 useMemo 记忆化计算结果
  const sum = useMemo(() => {
    console.log('Calculating sum...');
    return num1 + num2;
  }, [num1, num2]);
  return (
    <div>
      <p>Sum: {sum}</p>
      <button onClick={() => setNum1(num1 + 1)}>Increment Num1</button>
      <button onClick={() => setNum2(num2 + 1)}>Increment Num2</button>
    </div>
  );
};

Solution

下面就是整个Json Formatter的实现。

初始化项目

创建项目

在terminal中使用create-react-app来初始化一个项目

npx create-react-app json-formatter

初始化manifest

打开创建的项目,修改manifest中的信息

{
  "manifest_version": 3,
  "name": "Json Formatter",
  "version": "1.0",
  "description": "A simple Chrome extension for formatting JSON",
  "permissions": ["scripting"],
  "action": {
    "default_width": 800,
    "default_height": 600
  },
  "background": {
    "service_worker": "background.js"
  },
  "web_accessible_resources": [
    {
      "resources": ["index.html"],
      "matches": ["<all_urls>"]
    }
  ]
}

这里由于我希望点击插件按钮后,可以进入到一个新的页面而不是弹出一个弹窗,所以需要在background.js中实现这个操作

chrome.action.onClicked.addListener((tab) => {
  chrome.tabs.create({
    // 指向构建后的 index.html
    url: chrome.runtime.getURL('index.html')
  });
});

运行项目

在terminal中输入npm start来运行项目,成功后会打开对应的前端页面。后面和我们编写Lynx项目一样,修改项目,点击保存,网页会进行热更新,实时看到修改后的样式.

打包和发布

编写完所有的逻辑后,执行npm run build对项目进行打包,打包完成后会发现项目的目录里增加了一个build的文件夹。 6187e5fb-7d98-4439-9037-7d2f3056de65.png 通过以下几个步骤在chrome上加载自己的插件:

  1. 打开 Chrome 浏览器,访问 chrome://extensions
  2. 开启右上角的「开发者模式」
  3. 点击「加载已解压的扩展程序」,选择项目的 build 目录。

实现

创建好项目后,可以通过点击插件按钮打开一个新的网页,对应的网页内容就是App.jsx中的内容,现在开始实现内部的具体功能

初始化

首先需要新建一个组件来作为Json Formatter的功能容器,新建components文件,并在里面新建json_formatter目录并新建一个json_formatter.jsx用来实现能力框架

const JsonFormatter = () => {
  const [inputJson, setInputJson] = useState('');
  
  return (
    <div>
      <h1>Json Formatter</h1>
      <div>
        <textarea
          value={inputJson}
          onChange={(e) => setInputJson(e.target.value)}
          placeholder="Enter JSON here"
          className="json-formatter-textarea"
          wrap="off"
        />
        <button className="clear-button" onClick={clearInput}>Clear</button>
      </div>
  );
};

export default JsonFormatter;

然后在App.jsx中加上我们的json组件

import JsonFormatter from './components/json_formatter';

function App() {
  const [selectedTab, setSelectedTab] = useState('');

  return (
    <div className="App">
      <JsonFormatter />
    </div>
  );
}

export default App;

这样,我们就可以在页面上看到一个Json的输入框,样式可以通过新建scss文件进行调整

功能实现

基础能力

首先我们需要划分我们需要的功能都有哪些,按照市面上常用的json formatter工具,重点的功能有以下几个:

  1. 格式化json
  2. 压缩json
  3. 转义json
  4. 去除转义

每个功能对应着一个button,每个button的点击事件都是对useState定义的inputJson进行修改,下面对这四个功能进行简单的实现。

  • 格式化json
const formatJson = () => {
  try {
    const parsedJson = JSON.parse(inputJson);
    const formatted = JSON.stringify(parsedJson, null, 2);
    setInputJson(formatted);
  } catch (e) {
  }
};
  • 压缩json
const minifyJson = () => {
  try {
    const parsedJson = JSON.parse(inputJson);
    const minifiedJson = JSON.stringify(parsedJson);
    setInputJson(minifiedJson);
  } catch (e) {
  }
};
  • 转义json
const escape = (str) => {
  return str
    .replace(/\\/g, '\\\\')
    .replace(/"/g, '\\"')
    .replace(/\n/g, '\\n')
    .replace(/\r/g, '\\r')
    .replace(/\t/g, '\\t');
}

const escapeJson = () => {
  try {
   const escapedJson = escape(inputJson);
    setInputJson(escapedJson);
  } catch (e) {
  }
};
  • 去除转义
const unescape = (str) => {
  return str
    .replace(/\\\\/g, '\\')
    .replace(/\\"/g, '"')
    .replace(/\\n/g, '\n')
    .replace(/\\r/g, '\r')
    .replace(/\\t/g, '\t');
}

const unescapeJson = () => {
  try {
    const unescapedJson = unescape(inputJson);
    setInputJson(unescapedJson);
    success();
  } catch (e) {
    fail(e.message || 'Unescape JSON format');
  }
}
错误提示

在实现完这些能力后,我们还需要一个可以提示用户错误信息的组件,将error信息展示给用户,可以让用户根据错误调整json格式。

const [error, setError] = useState(null);
const fail = (message) => {
  setError(message);
}
const success = () => {
  setError(null);
}
return <>
  {error && <text className="error-message">{error}</text>}
</>

新定义一个error的state,当点击button后发生错误走入了catch逻辑后,调用fail函数可以为error赋值,在成功后删掉error的信息,这样就可以保证在失败的时候展示一个error信息,在成功后隐藏error信息。

Copy到剪切板

用户在处理好json后,应该需要一个button来copy到剪切板,这样就可以避免用户手动选择全部再点击复制了,首先实现最基础的copy能力。

const copyToClipboard = () => {
  if (inputJson) {
    navigator.clipboard.writeText(inputJson);
  } else {
  }
};

一般在用户点击完copy后不知道自己到底有没有copy成功,这样我们需要一个toast来给用户提示,实现的方法也是通过useState来控制toast的展示,然后使用useEffect来实现过x秒后自动隐藏

const [toast, setToast] = useState(null);

useEffect(() => {
  if (toast) {
    setTimeout(() => {
      setToast(null);
    }, 3000);
  }
}, [toast])

return <>
  {toast && <div className="toast">{toast}</div>}
</>
.toast {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  background-color: #333;
  color: white;
  padding: 10px 20px;
  border-radius: 5px 5px 5px 5px;
  opacity: 0;
  animation: fadeInOut 3s ease-in-out;
  z-index: 9999;
}

@keyframes fadeInOut {
  0% { opacity: 0; transform: translateX(-50%) translateY(100%); }
  10% { opacity: 1; transform: translateX(-50%) translateY(0); }
  90% { opacity: 1; transform: translateX(-50%) translateY(0); }
  100% { opacity: 0; transform: translateX(-50%) translateY(100%); }
}
组装

完成上面的能力实现后,我们可以将各个功能进行组装,完成我们对json format能力的实现。

import { useEffect, useState } from 'react';
import './json_formatter.styles.scss'; 

const escape = (str) => {
  return str
    .replace(/\\/g, '\\\\')
    .replace(/"/g, '\\"')
    .replace(/\n/g, '\\n')
    .replace(/\r/g, '\\r')
    .replace(/\t/g, '\\t');
}

const unescape = (str) => {
  return str
    .replace(/\\\\/g, '\\')
    .replace(/\\"/g, '"')
    .replace(/\\n/g, '\n')
    .replace(/\\r/g, '\r')
    .replace(/\\t/g, '\t');
}

const JsonFormatter = () => {
  const [inputJson, setInputJson] = useState('');
  const [error, setError] = useState(null);
  const [toast, setToast] = useState(null);

  const success = () => {
    setError(null);
  }
  const fail = (message) => {
    setError(message);
  }

  const copyToClipboard = () => {
    if (inputJson) {
      navigator.clipboard.writeText(inputJson);
      setToast('Copied to clipboard');
    } else {
      setToast('No content to copy');
    }
  };

  useEffect(() => {
    if (toast) {
      setTimeout(() => {
        setToast(null);
      }, 3000);
    }
  }, [toast])

  const formatJson = () => {
    try {
      const parsedJson = JSON.parse(inputJson);
      const formatted = JSON.stringify(parsedJson, null, 2);
      setInputJson(formatted);
      success();
    } catch (e) {
      fail(e.message || 'Invalid JSON format');
    }
  };

  const minifyJson = () => {
    try {
      const parsedJson = JSON.parse(inputJson);
      const minifiedJson = JSON.stringify(parsedJson);
      setInputJson(minifiedJson);
      success();
    } catch (e) {
      fail(e.message || 'Minify JSON format');
    }
  };

  const escapeJson = () => {
    try {
      const escapedJson = escape(inputJson);
      setInputJson(escapedJson);
      success();
    } catch (e) {
      fail(e.message || 'Escape JSON format');
    }
  };

  const unescapeJson = () => {
    try {
      const unescapedJson = unescape(inputJson);
      setInputJson(unescapedJson);
      success();
    } catch (e) {
      fail(e.message || 'Unescape JSON format');
    }
  }

  return (
    <div>
      <h1>Json Formatter</h1>
      <div className="editor-container">
        <textarea
          value={inputJson}
          onChange={(e) => setInputJson(e.target.value)}
          placeholder="Enter JSON here"
          className="json-formatter-textarea"
          wrap="off"
        />
      </div>
      {error && <text className="error-message">{error}</text>}
      <div className="button-container">
        <button onClick={copyToClipboard} className="button">Copy to Clipboard</button>
        <button onClick={formatJson} className="button">Format JSON</button>
        <button onClick={minifyJson} className="button">Minify JSON</button>
        <button onClick={escapeJson} className="button">Escape JSON</button>
        <button onClick={unescapeJson} className="button">Unescape JSON</button>
      </div>
      {toast && <div className="toast">{toast}</div>}
    </div>
  );
};

export default JsonFormatter;

最终效果

954e58d4-f822-49e0-a733-7b3f7e6bdf9d.png

进阶功能

现在展示的只是一个简单的用户输入一大串字符串,点击各个button可以进行json的相关操作,但是还有提升用户体验的方式,比如对json的key和value进行不同颜色的区分。相比于现在的全部以黑色展示,以颜色区分用户能更加的直观获取到json中的各个数据。下面将介绍一些针对这个模块的一些优化体验的能力实现

高亮key/value

首先我们先对json的key和value进行不同颜色的区分,首先我们需要定义各种颜色,包括对value的各种类型进行区分,比如string和boolean类型的颜色进行区分。

首先我们先定义各个颜色的style。

.key {
  color: #d73a49;
}
.string {
  color: #032f62;
}
.number {
  color: #005cc5;
}
.boolean {
  color: #6f42c1;
}
.null {
  color: #d23632;
}

接着我们给编辑框引入一个可编辑的状态,只有点击编辑按钮才可以开始编辑,当用户点击format按钮后,就进入不可编辑的状态。

新创建一个isEditing的状态,同时新增一个button来进入可编辑状态,在原来的textarea的基础上扩展成只有可编辑的时候才展示textarea,当不可编辑的时候展示pre标签,从而可以展示高亮的json文本。

import { useEffect, useState } from 'react';
import './json_formatter.styles.scss'; 

const JsonFormatter = () => {

  // 已有代码
  
  const [isEditing, setIsEditing] = useState(true);
  
  // 已有代码

  return (
    <div>
      <h1>Json Formatter</h1>
      <div className="editor-container">
        {isEditing ? (
          <textarea
            value={inputJson}
            onChange={(e) => setInputJson(e.target.value)}
            placeholder="Enter JSON here"
            className="json-formatter-textarea"
            wrap="off"
          />
        ) : (
          <pre 
            className="json-formatter-pre"
            dangerouslySetInnerHTML={{ __html: inputJson }}
          />
        )}
      </div>
      {error && <text className="error-message">{error}</text>}
      <div className="button-container">
        { /* Other buttons */ }
        <button
          onClick={() => {
            if (isEditing) {
              return;
            }
            setIsEditing(true)
          }} 
          className={`button ${isEditing ? 'disabled': ''}`}
        >Start Editing</button>
      </div>
      {toast && <div className="toast">{toast}</div>}
    </div>
  );
};

export default JsonFormatter;

下面开始实现高亮能力,主要是用来匹配各个字符串,然后判断字符串的类型分配对应的主题,然后通过spac标签,将匹配好的主题通过这种方式进行展示。

const highlightJson = (json) => {
  json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => {
    let cls = 'number';
    if (/^"/.test(match)) {
      cls = /:$/.test(match) ? 'key' : 'string';
    } else if (/true|false/.test(match)) {
      cls = 'boolean';
    } else if (/null/.test(match)) {
      cls = 'null';
    }
    return `<span class="${cls}">${match}</span>`;
  });
};

最终效果:

a05127db-1c68-4c6d-a002-20c2b06ac225.png

折叠key/value

如果文件过长,用户可能需要拉好久才能看到自己想要的数据,这样的话我们需要可以折叠key的能力,让用户可以隐藏掉自己不希望看到的数据。

首先,我们需要解析以及format好的json数据,然后通过递归的方式搭建对应的JsonTree,每一个JsonNode都有一个标签用于提示折叠/展开状态,以及内部Node的数量。每个节点都有一个expanded状态,用于记录是否需要被折叠。

import { useState } from 'react';
import './json_formatter.styles.scss';

const JsonTree = ({ data, depth = 0 }) => {
  const [expanded, setExpanded] = useState(true);
  const isObject = typeof data === 'object' && data !== null;
  const isArray = Array.isArray(data);

  if (!isObject) {
    let valueClass = 'json-value';
    if (typeof data === 'number') {
      valueClass = 'number';
    } else if (typeof data === 'string') {
      valueClass = 'string';
    } else if (typeof data === 'boolean') {
      valueClass = 'boolean';
    } else if (data === null) {
      valueClass = 'null';
    }
    return <span className={valueClass}>{JSON.stringify(data)}</span>;
  }

  const toggleExpand = () => {
    setExpanded(!expanded);
  };

  const keys = Object.keys(data);
  if (keys.length === 0) {
    return isArray ? <span className="json-value">[]</span> : <span className="json-value">{{}}</span>;
  }

  return (
    <div className="json-node">
      <div className="json-header" onClick={toggleExpand}>
        {isArray ? '[' : '{'}
        <span className="toggle-icon">{expanded ? '▼' : '▶'}</span>
        {isArray ? `] (${keys.length})` : `} (${keys.length})`}
      </div>
      {expanded && (
        <div className="json-children" style={{ marginLeft: `${depth * 5}px` }}>
          {keys.map((key, index) => (
            <div key={index} className="json-item">
              <span className="key">"{key}"</span>:{" "}
              <JsonTree data={data[key]} depth={depth + 1} />
              {index < keys.length - 1 && <span className="json-comma">,</span>}
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

export default JsonTree;

接着我们需要在现有的button中新增一个button用来展示这种情况,当用户点击后,可以把格式化好的json编程可折叠的样式进行展示。

const [isViewing, setIsViewing] = useState(false);

let parsedData;
try {
  parsedData = JSON.parse(inputJson);
} catch (e) {
  parsedData = null;
}

<div className="editor-container">
  {isEditing ? (
    <textarea
      value={inputJson}
      onChange={(e) => setInputJson(e.target.value)}
      placeholder="Enter JSON here"
      className="json-formatter-textarea"
      wrap="off"
    />
  ) : isViewing && parsedData ? (
    <div className="json-tree">
      <JsonTree data={parsedData} />
    </div>
  ) : (
    <pre 
      className="json-formatter-pre"
      dangerouslySetInnerHTML={{ __html: highlightJson(inputJson) }}
    />
  )}
  <button className="clear-button" onClick={clearInput}>Clear</button>
</div>

最终效果

e4fccdf4-15e4-40a3-a65d-ff3fc62f99ca.png

总结

通过上面的代码,我们实现了一个简单的json format工具,在此基础上,我们可以扩展其他的能力,比如常用到的RGBA和Hex颜色的互转,Url的encode和decode等操作,整个项目的代码在 Git仓库 中,可以参考相关的实现(or 指出我整个实现过程中存在的问题),感谢大家!