大概说一下:
- React+Hook+TypeScript+single-spa
- scss+css-module
- markdown 渲染使用 marked.js,语法高亮使用 highlight.js
- 主题切换
- PC
- 移动端
滚动位置恢复用 这个库,之前自己写的,已发布 npm
;
配合 useScroll
很简单
public/md/目录名
public/md/
.
├── demo
│ └── demo1.md
├── js
│ ├── amd-cmd.md
│ ├── evt.md
│ ├── js-review.md
│ ├── promise.md
│ └── scroll-load.md
├── mini-program
│ └── movie-db.md
├── node.js
│ ├── cmd-line.md
│ ├── directory-1.md
│ ├── directory-2.md
│ └── zr-deploy.md
├── others
│ ├── create-react-app_single-spa.md
│ ├── vue-cli3_single-spa.md
│ └── web-component.md
├── react
│ ├── React-Hook.md
│ ├── keep-alive-comp.md
│ ├── movie-db-web.md
│ ├── next-js.md
│ ├── react-keep-alive.md
│ └── react-ts-template.md
└── vue
├── calendar.md
├── clock.md
└── uni-app.md
[
{
"tag": "demo",
"name": "demo1.md",
"title": "MD-Note说明",
"create_time": "2019/10/01 00:00:00"
},
]
// src/views/NoteList/index.tsx
useEffect(() => {
restore();
}, []);
const restore = () => {
const scTop = props.scrollRestore!();
const _state = props.stateRestore!();
setNoteList(_state?.noteList || []);
setCurrentTag(_state?.currentTag);
setTimeout(() => {
document.body.scrollTop = scTop || 0;
document.documentElement.scrollTop = scTop || 0;
}, 0);
};
// 离开前保存状态
const toDetailClick = () => {
props.beforeRouteLeave!(scrollTop, {
noteList,
currentTag,
});
};
// 标签
const tags: TagItem[] = useMemo(() => {
if (!fullNoteList) return [];
const _tags: TagItem[] = [];
fullNoteList[0]?.name &&
fullNoteList?.forEach((noteItem) => {
const hasItem: TagItem | undefined = _tags.find(
(item) => item.name === noteItem.tag
);
if (hasItem) {
hasItem.count++;
} else {
_tags.push({ name: noteItem.tag, count: 1 });
}
});
return [{ name: '全部', count: fullNoteList.length }, ..._tags];
}, [fullNoteList]);
const onTagChange = (tag: TagItem | undefined) => {
setCurrentTag(tag);
};
使用 css变量
的方式切换主题
- 在
html
设置data-theme
.vars-base {
--maskBg: rgba(50, 50, 50, 0.6);
--boxShadow: 0px 1px 1px -2px rgba(0, 0, 0, 0.8);
}
.light-base {
.vars-base;
--baseColor: #3e3e3e;
--descColor: #666;
--secondColor: #999;
--grayColor: #aaa;
--borderColor: #e9e9e9;
--bgColor: #fefefe;
--bgColorLight: #f6f6f6;
--bgColorHeavy: #f1f1f1;
--bgColorO6: rgba(255, 255, 255, 0.6);
--bgColorO8: rgba(255, 255, 255, 0.8);
--borderColorO8: rgba(233, 233, 233, 0.5);
--linearBackground-0: linear-gradient(0deg, rgba(255, 255, 255, 0.8), rgb(255, 255, 255));
--linearBackground-90: linear-gradient(90deg, transparent, #fff);
--linearBackground-180: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgb(255, 255, 255));
--bgImage: url(https://s1.ax1x.com/2020/08/20/dJ97PU.th.jpg);
}
.dark-base {
.vars-base;
--baseColor: #ccc;
--descColor: #666;
--secondColor: #999;
--grayColor: #464444;
--borderColor: #2e2e2e;
--bgColor: #232426;
--bgColorLight: #292b2d;
--bgColorHeavy: #1e1f21;
--bgColorO6: #1e1f21;
--bgColorO8: #292b2d;
--borderColorO8: #2e2e2e;
--linearBackground-0: linear-gradient(0deg, rgba(35, 36, 38, 0.8), rgb(35, 36, 38));
--linearBackground-90: linear-gradient(90deg, transparent, #232426);
--linearBackground-180: linear-gradient(180deg, rgba(35, 36, 38, 0.8), rgb(35, 36, 38));
--bgImage: url(https://s1.ax1x.com/2020/08/20/dGXIpR.th.jpg);
}
// 白兰主题
html[data-theme='blue'] {
.light-base;
--statusBarColor: #5098e4;
--primaryColor: rgba(80, 152, 228, 0.8);
--primaryColorLight: rgba(80, 152, 228, 0.6);
--primaryColorHeavy: rgba(80, 152, 228, 1);
--primaryBgColor: rgba(80, 152, 228, 0.05);
};
// 暗夜
html[data-theme='dark'] {
.dark-base;
--statusBarColor: #1e1f21;
--primaryColor: rgba(228, 149, 80, 0.8);
--primaryColorLight: rgba(228, 149, 80, 0.6);
--primaryColorHeavy: rgba(228, 149, 80, 1);
--primaryBgColor: rgba(228, 149, 80, 0.05);
};
// 橘橙
html[data-theme='orange'] {
.light-base;
--statusBarColor: #e49550;
--primaryColor: rgba(228, 149, 80, 0.8);
--primaryColorLight: rgba(228, 149, 80, 0.6);
--primaryColorHeavy: rgba(228, 149, 80, 1);
--primaryBgColor: rgba(228, 149, 80, 0.05);
};
// 小红
html[data-theme='red'] {
.light-base;
--statusBarColor: #e45250;
--primaryColor: rgba(228, 82, 80, 0.8);
--primaryColorLight: rgba(228, 82, 80, 0.6);
--primaryColorHeavy: rgba(228, 82, 80, 1);
--primaryBgColor: rgba(228, 82, 80, 0.05);
};
// 浅绿
html[data-theme='green'] {
.light-base;
--statusBarColor: #009688;
--primaryColor: rgba(0, 150, 136, 0.8);
--primaryColorLight: rgba(0, 150, 136, 0.6);
--primaryColorHeavy: rgba(0, 150, 136, 1);
--primaryBgColor: rgba(0, 150, 136, 0.05);
};
// 魅紫
html[data-theme='purple'] {
.light-base;
--statusBarColor: #c625ef;
--primaryColor: rgba(198, 37, 239, 0.8);
--primaryColorLight: rgba(198, 37, 239, 0.6);
--primaryColorHeavy: rgba(198, 37, 239, 1);
--primaryBgColor: rgba(198, 37, 239, 0.05);
};
.theme {
color: var(--primaryColor);
}
// src/components/ChangeTheme/index.tsx
import React, { useEffect } from 'react';
import useGlobalModel from '@/model/useGlobalModel';
import { ThemeType } from '@/theme/themeType';
import styles from './styles.less';
interface ThemeItem {
text: string;
color: ThemeType;
}
const themesConfig: ThemeItem[] = [
{
text: '白兰',
color: 'blue',
},
{
text: '暗夜',
color: 'dark',
},
{
text: '橘橙',
color: 'orange',
},
{
text: '小红',
color: 'red',
},
{
text: '浅绿',
color: 'green',
},
{
text: '魅紫',
color: 'purple',
},
];
const ChangeTheme = () => {
const { theme, setTheme } = useGlobalModel((modal) => [
modal.theme,
modal.setTheme,
]);
useEffect(() => {
scrollIntoView();
}, []);
const onThemeChange = (color: ThemeType) => {
setTheme(color);
scrollIntoView();
};
const scrollIntoView = () => {
setTimeout(() => {
const themeColor = document.querySelector(`.${styles.theme}`);
themeColor?.scrollIntoView();
}, 0);
};
return (
<span>
{themesConfig.map((item) => (
<span
key={item.color}
className={`${styles.color} ${
item.color === theme ? styles.theme : ''
}`}
onClick={() => onThemeChange(item.color)}
>
{item.text}
</span>
))}
</span>
);
};
export default ChangeTheme;
直接异步请求 public/md/
下面的 markdown
文件
注意需要使用
HashRouter
才能用相对路径获取到public
下的东西
// src/model/useNoteModel.ts
// 请求数据 tag: 标签;name:名称
const fetchNoteByName = async (tag: NoteTag | string, name: string) => {
try {
const res: any = await fileApi(`/${tag}/${name}`);
return { code: 0, data: res, msg: 'ok' };
} catch (err) {
console.error('fetch error: ', err);
}
return { code: -2, data: null, msg: 'error' };
};
// src/api/file.ts
// 获取文件
export function fileApi(uri: string, params: any = {}) {
return axios.get(`./md${uri}`, {
data: { ...params },
});
}
marked
设置自定义渲染
默认:
- 标题 id 会被去掉
英文特殊字符
, - 链接不在新窗口打开
// marked 样式
const markedHighlight = () => {
// 渲染设置
const renderer = new marked.Renderer();
// 设置标题,生成目录跳转需要
renderer.heading = function(text: string, level: number) {
const realId = text.replace('<code>', '`').replace('</code>', '`');
return `<h${level} class="heading-h${level}" id="${realId}" title="${realId}"><span>${text}</span></h${level}>`;
};
// 代码块
renderer.code = function(src: string, tokens: string) {
const codeCopyContent = encodeURI(src);
const iconContent = `<span>复制代码</span>
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="copy" class="svg-inline--fa fa-copy fa-w-14 " role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path fill="currentColor" d="M320 448v40c0 13.255-10.745 24-24 24H24c-13.255 0-24-10.745-24-24V120c0-13.255 10.745-24 24-24h72v296c0 30.879 25.121 56 56 56h168zm0-344V0H152c-13.255 0-24 10.745-24 24v368c0 13.255 10.745 24 24 24h272c13.255 0 24-10.745 24-24V128H344c-13.2 0-24-10.8-24-24zm120.971-31.029L375.029 7.029A24 24 0 0 0 358.059 0H352v96h96v-6.059a24 24 0 0 0-7.029-16.97z"></path>
</svg>`;
return `<pre>
<div class="languange">
<span>${tokens}</span>
<span class="copy-code" data-code="${codeCopyContent}">${iconContent}</span>
</div>
<div class="code-wrapper"><code class="${tokens}">${highlight(
src,
tokens
)}</code></div>
</pre>`;
};
// 设置链接
renderer.link = function(href: string, title: string, text: string) {
const _title = title || href || '';
return `<a href="${href}" class="link" title="${_title}" target="_blank" rel="noopener noreferrer">${text}
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="external-link-alt" class="svg-inline--fa fa-external-link-alt fa-w-16 " role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M432,320H400a16,16,0,0,0-16,16V448H64V128H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V336A16,16,0,0,0,432,320ZM488,0h-128c-21.37,0-32.05,25.91-17,41l35.73,35.73L135,320.37a24,24,0,0,0,0,34L157.67,377a24,24,0,0,0,34,0L435.28,133.32,471,169c15,15,41,4.5,41-17V24A24,24,0,0,0,488,0Z">
</path></svg>
</a>`;
};
// 给图片添加类名,添加点击事件,方便点击查看大图
renderer.image = function(src: string, alt: string) {
return `<img src="${src}" alt="${alt || ''}" class="md-img" />`;
};
marked.setOptions({
renderer,
highlight,
langPrefix: '',
pedantic: false,
gfm: true,
tables: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false,
});
};
- 对二级标题,三级标题,四级标题生成目录
- 点击标题视图切换到响应标题下
- 滚动时,高亮响应目录标题
生成目录
// src/components/MdCatalog/index.tsx
// 生成目录
const generateCate = (
text: string,
splitChar: string = '\n##',
list: CatalogItem[] = []
) => {
// 最多到四级标题h4 ####
if (splitChar === '\n#####') return [];
const cateList: CatalogItem[] = [];
const content = text.slice(text.indexOf('\n'), text.length);
const cateArr = content.split(`${splitChar} `);
cateArr.shift();
cateArr.forEach((cate) => {
const id = cate.substring(0, cate.indexOf('\n')).trim();
const cateItem: CatalogItem = {
id,
header: `catelog-${id}`,
label: id,
child: [],
};
const cateItemChild = generateCate(cate, `${splitChar}#`);
if (cateItemChild.length) cateItem.child = cateItemChild;
cateList.push(cateItem);
});
return list.concat(cateList);
};
// src/views/NoteDetail/index.tsx
// 点击事件代理
const onMdContentClick = () => {
const mdContent = document.querySelector('#md-content') as HTMLElement;
mdContent.onclick = function(e: any) {
onCopyCode(e);
onImgClick(e);
};
// 需要初始化一次,不然要点击两次才能复制
const copyCodeElems = document.querySelectorAll(
'#md-content .copy-code'
) as NodeList;
Array.from(copyCodeElems).forEach((el: HTMLElement) => {
el.onmouseenter = function(e: any) {
onCopyCode(e);
};
});
};
// 复制代码
const onCopyCode = (e: any) => {
const copyCodeEl: HTMLElement | null = e.target?.closest('.copy-code');
if (!copyCodeEl || !copyCodeEl.dataset.code) return;
const text = copyCodeEl.querySelector('span')!;
const realCode = decodeURI(copyCodeEl.dataset.code);
clipboard.current = new ClipboardJS(copyCodeEl, {
action: () => 'copy',
text: () => realCode,
});
clipboard.current.on('success', () => restoreText('复制成功'));
clipboard.current.on('error', () =>
restoreText('<span style="color:red;">复制失败</span>')
);
const restoreText = (innerHTML: string) => {
text.innerHTML = innerHTML;
clipboard.current?.destroy();
setTimeout(() => {
text.innerHTML = '复制代码';
}, 2000);
};
};
// 图片点击新窗口打开
const onImgClick = (e: any) => {
const imgEl: HTMLImageElement | null = e.target?.closest('.md-img');
if (imgEl) {
window.open(imgEl.src);
// updatePicPreview({
// show: true,
// src: img.src,
// alt: img.alt,
// onClose: onClosePicPreview,
// });
}
};
定时器
+自定义Hook useWindowClick
实现滚动可中断(滚动时,点击页面任意处就停止滚动)
import React, { CSSProperties, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAngleDoubleUp } from '@fortawesome/free-solid-svg-icons';
import useWindowClick from '@/utils/useWindowClick';
import styles from './styles.scss';
export interface Scroll2TopProps {
position?: CSSProperties;
}
// 回到顶部
const Scroll2Top: React.FC<Scroll2TopProps> = ({ position }) => {
const scrollTop = useRef(0);
const canScroll = useRef(false); // 允许滚动
// 全局点击
const onWindowClick = () => {
onRemoveClick();
};
const onRemoveClick = () => {
canScroll.current = false;
removeListener();
};
const { addListener, removeListener } = useWindowClick(onWindowClick);
const onScroll2oTop = (e: React.MouseEvent) => {
e.stopPropagation();
scrollTop.current =
document.body.scrollTop || document.documentElement.scrollTop;
canScroll.current = true;
addListener();
scrollHandler();
};
const scrollHandler = () => {
let scTop = document.body.scrollTop || document.documentElement.scrollTop;
if (scTop > 0) {
document.body.scrollTop -= 100;
document.documentElement.scrollTop -= 100;
if (canScroll.current) setTimeout(scrollHandler, 16);
} else {
onRemoveClick();
}
};
return (
<div className="gitter">
<div
style={position}
className={`btn ${styles.scroll2top}`}
onClick={(e: any) => onScroll2oTop(e)}
>
<FontAwesomeIcon icon={faAngleDoubleUp} />
</div>
</div>
);
};
export default Scroll2Top;
获取上一次的值
// src/utils/usePrevState.ts
import { useRef, useEffect, useState } from 'react';
function usePrevState<T>(state: T) {
const countRef = useRef<any>(null);
const [_state, setState] = useState<T>(state);
useEffect(() => {
countRef.current = _state;
setState(state);
}, [state]);
// prevState
return countRef.current;
}
export default usePrevState;
函数防抖
import { useCallback } from 'react';
// 防抖
const useDebounce = (callback: (...param: any) => void, delay: number = 16) => {
let timer: NodeJS.Timeout;
let lastTime: number = 0;
const runCallback = (...args: any) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
callback.apply(null, args);
}, delay);
};
return useCallback(function(...param) {
const thisTime = new Date().getTime();
if (thisTime - lastTime > delay && lastTime !== 0) {
lastTime = 0;
} else {
lastTime = thisTime;
}
runCallback(...param);
}, []);
};
export default useDebounce;
函数节流
import { useCallback } from 'react';
// 节流
const useThrottle = (callback: () => any, delay: number = 16) => {
let lastTime: number = 0;
let canCallback = true;
const restore = (time: number) => {
lastTime = time;
canCallback = false;
};
const runCallback = () => {
callback();
};
return useCallback((args?: any) => {
const thisTime = new Date().getTime();
if (canCallback && thisTime - lastTime > delay) {
restore(thisTime);
runCallback.apply(null, args);
setTimeout(() => {
canCallback = true;
}, delay);
return;
}
}, []);
};
export default useThrottle;
滚动
使用:见 src/components/header/index.tsx
// src/utils/useScroll.ts
import { useEffect, useState } from 'react';
import usePrevState from './usePrevState';
// 监听window滚动
const useScroll = () => {
const [scrollTop, setScrollTop] = useState(0);
const prevScrollTop = usePrevState(scrollTop);
useEffect(() => {
onScroll();
window.addEventListener('scroll', onScroll, false);
return () => {
window.removeEventListener('scroll', onScroll, false);
};
}, []);
const onScroll = () => {
const scTop = document.body.scrollTop || document.documentElement.scrollTop;
setScrollTop(scTop || 0);
};
return {
prevScrollTop,
scrollTop,
};
};
export default useScroll;
点击,可以代替 clickOutside
点击外部使用
使用:见 src/components/Scroll2Top/index.tsx
import { useEffect, useRef } from 'react';
// 添加全局点击事件,底层元素阻止冒泡则不会触发
function useWindowClick(callback: () => void) {
const isReady = useRef(false);
useEffect(() => {
return () => {
window.removeEventListener('click', onWindowClick, false);
};
}, []);
const addListener = () => {
isReady.current = true;
window.addEventListener('click', onWindowClick, false);
};
const removeListener = () => {
isReady.current = false;
window.removeEventListener('click', onWindowClick, false);
};
const onWindowClick = () => {
if (typeof callback !== 'function') {
return console.warn('callback 不是函数!');
}
if (callback && isReady) {
callback();
removeListener();
}
};
return {
addListener,
removeListener,
};
}
export default useWindowClick;
这个是之前看一位大佬的 文章 05,里面分享的另一篇国外的 文章,然后自己根据实际使用改的
项目使用的是 UmiJS 框架,自带的 request,
使用 axios 的话也是差不多的,把 fetchFn 类型改为
fetchFn: () => Promise<AxiosResponse>;
然后,请求函数改为 axios 相应的写法就可以了
说明:
- fetchFn: 请求函数
- deps: 更新依赖,重新执行 fetchFn
- isReady: fetchFn 执行条件
import { useState, useEffect } from 'react';
import { RequestResponse } from 'umi-request';
import $message from './$message';
export interface UseFetchDataProps {
fetchFn: () => Promise<RequestResponse>;
deps?: any[];
isReady?: boolean;
}
export type ResponseType = {
code: number;
data: any;
msg: string;
}
/**
* 自定义 Hook: 获取数据
* @example 使用时最好这样: useFetchData<{}>,方便给 resData 提供类型
* @type <S>:在 返回数据格式 基础上扩展的字段,如总数字段等
* @param fetchFn {*} 使用 request 封装的请求函数
* @param deps {*} 更新依赖,重新执行
* @param isReady {*} 可以获取数据标志,默认直接获取数据
*
* @returns isLoading: 是否正在请求
* @returns resData: 请求返回的数据
* @returns fetchData: 请求函数,供外部调用手动请求数据
*/
export default function useFetchData<S = ResponseType>({
fetchFn,
deps = [],
isReady,
}: UseFetchDataProps) {
let isDestroyed = false;
const [isLoading, setIsLoading] = useState<boolean>(false);
const [resData, setResData] = useState<ResponseType>();
useEffect(() => {
// 默认(undefined)直接获取数据
// 有条件时 isReady === true 再获取
if (isReady === undefined || isReady) {
fetchData();
} else {
setIsLoading(false);
}
return () => {
isDestroyed = true;
};
}, deps);
const fetchData = async () => {
try {
setIsLoading(true);
const res: any = await fetchFn();
if (res?.code !== 0) {
$message.warning(res?.msg || '请求出错!');
setIsLoading(false);
return;
}
if (!isDestroyed) {
setResData(res);
setIsLoading(false);
}
} catch (err) {
console.error(err);
}
};
return {
isLoading,
resData,
fetchData,
};
}