跳到主要内容

· 阅读需 5 分钟

客户端配置项的特别

客户端要实现动态加载配置项,也就是文件服务器每次读取到不同的内容,从而去渲染不同的页面结果。这种动态通常用于改动不大的场景,例如页面布局,某些功能的开关。

而浏览器客户端不是 NodeJS 环境,没办法通过 fs 之类的库去读取文件中的内容,没办法 writeFile 等去写入文件,而通常需要在页面打开时请求到文件服务器存储的所有配置文件,然后通过 fetch 去动态读取解析,然后保存起来。

下面是一个配置项,其中配置了 2 个配置项。我们希望部署在本地服务器或者 nginx 之类的服务器上的页面,通过修改 enable 后,页面刷新后会立刻接受到该值,然后 javascript 执行不同的结果。

{
"isFlex": {
"enable": true,
"comment": "是否xxx采用弹性布局"
},
"findImmediatly": {
"enable": false,
"comment": "是否在xxx之后立刻执行find"
}
}

前端怎么请求配置项文件

那么要配置项,首先配置文件需要让人能够易读,所以我们会约定好 json 格式等,然后让这个文件在打包后格式保持原样,在页面加载的时候还能读取这个配置。

以 Vite 为例,要让配置项能够在打包的时候不被修改,而是直接复制到打包文件夹下,我们需要把文件放在根路径 public 文件夹下。重要的是我不能不能将它引入到源码中,也就是不能通过 import 导入这个文件然后通过 parser 解析成我们要的 js 对象。这是 vite 的规定,因为 静态文件如果引入到源码中,它会被打包成 js,最后保留在 js 代码中,这个时候配置项的动态性已经丢失,因为打包文件会把我们最后一次修改的配置项 json,以实实在在的变量值写入到 js 源码中,成了固定的值。

那么我们要访问这个文件,例如configuration.json,在项目中访问中的路径是/configs/configuration.json,这个文件显然是放在public文件夹下的。

那如果 import 去引入 public 下的 json 文件等,会报错。意思是说,我们不能 import 一个 public 下的文件。

Cannot import non-asset file /configs/configuration.json which is inside /public. JS/CSS files inside /public are copied as-is on build and can only be referenced via <script src> or <link href> in html. If you want to get the URL of that file, use /configs/configuration.json?url instead.

因此我们借用 fetch 去请求这个文件。例如下面这个函数,我们传入/configs/configuration.json给第一个参数 path,这样 fetch 的时候会寻找根目录下的configs/configuration.json文件,而在打包后的 dist 文件夹下面确实有 configs 文件夹内,下面有 configuration.json,这样文件处理器会将这个文件发送给 fetch 的请求方——浏览器,从而获得我们想要的文件。

export default async <T>(path: string, errorMsg?: string) => {
const data: T = await fetch(path)
.then((res) => res.json())
.catch(() => {
console.error(errorMsg ? errorMsg : `请检查${path}文件格式是否正确`);
});

return data;
};

这种方式其实就是借用后端服务器,去转接我们的路径。只不过这个服务器是文件服务器,而不是我们写的后端服务器。

· 阅读需 2 分钟

场景

现在有一个需求,vue2, element-ui 中,需要使用数字输入框,保留两位小数,并且要有 % 后缀。

出现的问题

在 vue2 - element-ui 中,el-input组件的type="number"可以支持数字输入,但是首先在样式上会多出来原生 input 的数字上下点击控件,需要隐藏的话,要借用 css 去隐藏多余的内容。第二,这个 input 支持原生的 max,min 但是不支持精度属性,要配合 onchange 等去处理。

el-input-number组件中,支持 max,min 和 precision 精度属性,那么在功能上它是够用的,但是这个组件不支持插槽去修改 prefix 和 suffix,那么只能通过组合el-input-numberdiv的方式去实现,借用 element 内部的 css 类来实现和 element 组件一样的效果。

实现

这里提供了一个 css 类,这个类直接绑定el-input-number组件上,用来隐藏前缀和后缀,并且对边框和宽度进行修正,在显示效果上和el-input保持一致。

// 隐藏el-input-number组件的前缀和后缀
.el-input-number-without-controls {
width: 100%;

.el-input__inner {
padding: 0 15px !important;
}

.el-input-number {
&__decrease,
&__increase {
display: none;
}
}
}

其次就是组合的问题。单纯用 el-input-number和 div 进行拼接,样式上会有问题。所以借用 element ui 内部的el-input-group,el-input-group--appendel-input-group__append类,这三个类是用在el-group相关组件中的。

<div class="el-input-group el-input-group--append">
<el-input-number class="el-input-number-without-controls el-input-group--append" :max="100" :min="0" :precision="2" v-model="xxxx" />
<div class="el-input-group__append">%</div>
</div>

· 阅读需 9 分钟

下面的内容关于封装的树的部分操作的工具类,平铺树转 tree,树转平铺、树的遍历、查找、筛选等。

树的自定义结构

treeCreate 函数接受一个平铺数组,其中平铺数组中的节点数据结构总是可变的,为了满足这个函数能够作为通用函数使用,在函数参数中提供了配置项,其中 id 和 faId 为树节点的 id 和父节点 id,children 为子节点的属性名。其中 ts 类型是比较完善的,你能够提供泛型去约束树节点类型,也可以不提供泛型,让其自动识别子节点类型,从而提供配置项中值的自动判断。

实现

enum TreeNodeTypeKeyEnum {
id = "id",
faId = "faId",
children = "children",
}

/** 默认节点参数数据类型 */
export type TreeNodeItemType = {
id: TreeNodeTypeKeyEnum.id;
faId: TreeNodeTypeKeyEnum.faId;
children: TreeNodeTypeKeyEnum.children;
};

/** 构建树函数配置项 */
type TreeCreateConfig<T extends Record<string, string>> = {
keyMapping: Partial<Record<keyof Pick<TreeNodeItemType, "id" | "faId" | "children">, keyof T>>;
};

/** 检查是否是可索引类型 */
const checkIndexType = (data: any) => {
return ["string", "number", "symbol"].includes(typeof data);
};

/** 树原型类型 */
type TreePrototypeType<T> = {
treeForEach: (callback?: (item: T) => void) => ReturnType<typeof treeDFS<T>>;
treeFind: (callback?: (item: T) => boolean) => ReturnType<typeof treeFind<T>>;
treeFilter: (
callback?: (item: T) => boolean,
config?: {
isSearch?: boolean;
}
) => ReturnType<typeof treeFilter<T>>;
};

/** key为K类型中所有的key,以K[key]作为索引,用索引作为key取T中对应key的类型,该类型则为K[key]的类型 */
type TypeToRecord<T extends Record<string, any>, K extends Record<string, string>> = {
[key in keyof K as K[key]]: T[K[key]];
};

/**
*【树的自定义键配置】
*/
type TreeTypeWithouPrototype<T extends Record<string, any>, K extends Record<string, string>> = K extends {
children: string;
}
? TypeToRecord<T, K>
: TypeToRecord<T, K> & { children: TreeType<T, K> };

/**
* 【树结构类型】
*
* T:树节点的类型
*
* K:自定义键
*
* 如果 K 中没有指定children,则最终返回的类型会包含children;
*
* 如果K中指定了children,最终返回类型将没有children,而是 K 类型中children对应值的类型。
*/
type TreeType<T extends Record<string, any>, K extends Record<string, any>> = TreeTypeWithouPrototype<T, K>[] & TreePrototypeType<TreeTypeWithouPrototype<T, K>>;

/**
* 平铺数组转化成树
* @param arr 平铺数组
* @param keyMapping 使用自定义的key来作为节点数据中的id和faId
* @returns
*/
export function treeCreate<T extends Record<string, any>, K extends TreeCreateConfig<T>["keyMapping"]>(_arr: T[], keyMapping: K): TreeType<T, K> {
const arrData = _arr.slice();

/** id、faId、children的key */
const customInfo = {
idKey: (keyMapping.id ? keyMapping.id : TreeNodeTypeKeyEnum.id) as string,
faIdKey: (keyMapping.faId ? keyMapping.faId : TreeNodeTypeKeyEnum.faId) as string,
childrenKey: (keyMapping.children ? keyMapping.children : TreeNodeTypeKeyEnum.children) as string,
};

/** 获取到数据的id值 */
const getId = (data: T): string | null => {
// 判断是否有keyMapping中的id,如果有
const id: string = (data as any)[customInfo.idKey];

if (!checkIndexType(id)) return null;
return id;
};

/** 获取到数据的id值 */
const getFaId = (data: T): string | null => {
const faId: string = (data as any)[customInfo.faIdKey];

if (!checkIndexType(faId)) return null;
return faId;
};

/** 获取到数据的children值 */
const getChildren = (data: T) => {
const children = (data as any)[customInfo.childrenKey];
if (!(children instanceof Array)) return null;
return children;
};

/** 构建树*/
const buildTree = () => {
const tree = bindTreeFunction([]);
const treeMap = new Map<string, T>();

arrData.forEach((item) => {
const id = getId(item);
const faId = getFaId(item);
const children = bindTreeFunction(getChildren(item) || []);

if (id) {
treeMap.set(id, {
[customInfo.idKey]: id,
[customInfo.faIdKey]: faId,
[customInfo.childrenKey]: children,
} as unknown as T);
}
});

for (let i = 0; i < arrData.length; i++) {
const item = arrData[i];
const faId = getFaId(item);
const id = getId(item);

if (!id || !faId) continue;

const node = treeMap.get(id);
const father = treeMap.get(faId);
// 遍历每一个节点
if (node) {
if (!father) {
tree.push(node);
} else {
const children = getChildren(father);
if (!(children instanceof Array)) {
throw new Error(`'${customInfo.childrenKey}'的值不是一个数组`);
}
children.push(node);
}
}
}

return tree;
};

/** 绑定原型函数 */
const bindTreeFunction = (tree: TreeType<T, K>[]) => {
const childrenKey = customInfo.childrenKey as keyof TreeType<T, K>;

const prototypeFun: TreePrototypeType<TreeType<T, K>> = {
treeForEach: (callback) =>
treeDFS(tree, callback, {
childrenKey,
}),
treeFind: (callback) =>
treeFind(tree, callback, {
childrenKey,
}),
treeFilter: (callback, config) =>
treeFilter(tree, callback, {
...config,
childrenKey,
}),
};

const res = Object.create(Array.prototype, Object.getOwnPropertyDescriptors(prototypeFun));
return Object.setPrototypeOf(tree, res);
};

return buildTree();
}

/**
* 深度优先遍历
* @param tree 树数据
* @param callback 遍历到每个元素时的回调,参数为节点
* @param config.childrenKey
*/
export function treeDFS<T>(tree: T[], callback?: (item: T) => void, config?: { childrenKey?: keyof T }): void {
const childrenKey = config?.childrenKey || "children";

/** 传递节点 获取到children的引用 */

const getChildren = (item: T) => {
const children = (item as any)[childrenKey];
if (!(children instanceof Array)) {
throw new Error("没有children字段,或在配置项参数中指定childrenKey为children的key");
}
return children;
};

const list = tree.slice();
let current: T | undefined;

while ((current = list.shift())) {
callback && callback(current);

const currentChildren = getChildren(current);

if (currentChildren.length !== 0) {
list.unshift(...currentChildren);
}
}
}

/**
*
* @param tree 树数据
* @param callback 回调函数,返回值为boolean,参数接收节点类型
* @param confi.childrenKey
* @returns callback为true时返回的节点,如果没有符合callback的节点则返回null
*/
export function treeFind<T>(
tree: T[],
callback?: (item: T) => boolean,
config?: {
childrenKey?: keyof T;
}
): T | null {
const { childrenKey } = config || {};

/** 传递节点 获取到children的引用 */
const getChildren = (item: T) => {
const children = (item as any)[childrenKey || "children"];
if (!(children instanceof Array)) {
throw new Error("没有children字段,或在配置项参数中指定childrenKey为children的key");
}
return children;
};

const list = tree.slice();
let current: T | undefined;

while ((current = list.shift())) {
const children = getChildren(current);

if (callback && callback(current)) {
// 符合筛选callback条件的返回
return current;
} else {
if (children.length !== 0) {
list.unshift(...children);
}
}
}

return null;
}

/**
* @param tree 树数据
* @param callback 过滤回调函数
* @param config.isSearch
* 为true时,搜索过滤,搜索出节点,保留不符合条件的父节点。
*
* 为false时,子树过滤,不符合callback的节点以及子节点都舍弃。
* @param config.childrenKey 树中节点用来嵌套子节点的children的key
* @returns 节点数组
*/
export function treeFilter<T>(
tree: T[],
callback?: (item: T) => boolean,
config?: {
isSearch?: boolean;
childrenKey?: keyof T;
}
): T[] {
const { childrenKey } = config || {};

/** 传递节点 获取到children的引用 */
const getChildren = (item: T) => {
const children = (item as any)[childrenKey || "children"];
if (!(children instanceof Array)) {
throw new Error();
}
return children;
};

const { isSearch } = config || {};
const filterTarget: T[] = [];

const list = tree.slice();
let current: T | undefined;

// 未传递callback时,返回空数组
if (!callback) return [];

while ((current = list.shift())) {
const children = getChildren(current);

const filteredChildren = treeFilter(children, callback, {
...config,
isSearch: true,
});

// 搜索过滤,只要子节点满足,则所有父级节点都保留
if (isSearch) {
if (filteredChildren.length > 0 || callback(current)) {
filterTarget.push({
...current,
[childrenKey as string]: filteredChildren,
});
}
} else {
// 子树过滤,节点不满足则舍弃所有子节点
if (callback(current)) {
filterTarget.push({
...current,
[childrenKey as string]: filteredChildren,
});
}
}
}

return filterTarget;
}

下面是使用。在 createTree 生成的节点中,会在原型上绑定 treeForeach、treeFind、treeFilter 方法,在原型上调用时会利用 ts 类型简化一些例如 childrenKey 的配置项的传递。

const nodes = [
{
code: "7zwqkfyqshl",
facode: "0",
},
{
code: "zzuuldvpbbc",
facode: "7zwqkfyqshl",
},
];
const targetTree = treeCreate(nodes, {
id: "code",
faId: "facode",
});

console.log("@@", targetTree);

// ------ 树遍历
let count = 0;
treeDFS(
targetTree,
(item) => {
count++;
item;
},
{
childrenKey: "test",
}
);

targetTree.treeForEach((item) => {
count++;
item;
});
console.log("2. 树遍历", count);

// ------ 树查找
console.log("3. 树查找");
treeFind(
targetTree,
(item) => {
return item.code.includes("f");
},
{
childrenKey: "test",
}
);

const findTarget = targetTree.treeFind((item) => {
return item.code.includes("f");
});
console.log(findTarget, findTarget?.code);

// ------ 树过滤
// 子树过滤
targetTree.treeFilter((item) => {
return item.code.includes("f");
});

// 搜索过滤
const filterTarget = treeFilter(
targetTree,
(item) => {
return item.code.includes("f");
},
{
isSearch: true,
childrenKey: "test",
}
);
console.log("4. 树过滤", filterTarget);

· 阅读需 1 分钟

问题

在使用 pnpm 命令的时候,报错下列信息。

 WARN  POST http://localhost:5813/requestPackage error (ECONNREFUSED). Will retry in 1 minute. 99 retries left.

解决

https://github.com/pnpm/pnpm/issues/4177 这是 pnpm 的该解决方案的 issue。

其中解决方法是:

  • macOS 环境下删除~/.pnpm-store/v3/server/server.json
  • windows 环境下删除%LocalAppData%\pnpm\store\v3\server\server.json

· 阅读需 5 分钟

遇到的问题描述

遇到这个问题的项目是一个用create-react-app(webpack)来搭建的 monorepo 项目。 其中的目录结构大概是

|-scripts
|-start.js
|-paths.js
|-webpack.config.js
|-packages
|-package1
|- src
|- package.json
tsconfig.json
package.json
pnpm.workspace.yaml
...

在我们普通 src 结构的项目中,通过npm run start去执行scripts中的start.js,项目会以webpack.config.js去启动项目。

在 package1.json 中的start的 script 配置是这样。很好理解,就是去调用总项目根目录下的start.js文件。

start:"node ../../scripts/start.js"

在 package.json 中的start就是pnpm run start --filter package1

那么问题来了,我在 package1 的 src 下index.tsx来引用另外一个App.tsx,报错为module not fount, ..../App ...,那么当我给.App加上.tsx的后缀之后,就不报错了。

根据这一点,很多适合会想到是配置文件tsconfig.ts的问题,结果在package1中添加tsconfig.js之后,问题真的好了,但是我希望的是整个项目只有一个tsconfig.js,子包里面不用另外的配置,因为我这些项目都是关联起来的。

虽然说添加了tsconfig.js问题就好了,但是真的是它的原因吗?想一想发现,不会,因为 ts 是不会在项目运行时起作用的,也就是说就算报错了项目也能跑起来。那这样,就只能是打包工具的问题了,打包工具没有正确的解析指定后缀名的文件。

分析 webpack,定位问题

resolve: {
modules: ["node_modules", paths.appNodeModules].concat(
modules.additionalModulePaths || [],
),
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes("ts")),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/

其中 resolve.extensions,针对一些配置后缀名进行了一些预处理,例如 babel 等。

extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes("ts")),

由上面这一段配置可以看出,paths.moduleFileExtensions中导出了被处理的一些模块的后缀名,后面 filter 是根据useTypescript这个参数来判断是否要包含带有.ts后缀的,其中也就包含了.ts,.tsx等等。

// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);

然后从上面发现,useTypescript 就是判断一个路径是否存在,也就是paths.appTsConfig是否存在。继续查看,发现这个配置就是 appTsConfig: resolveApp("tsconfig.json"),,也就是查看当前运行路径下是否有tsconfig.json这个配置文件。

解决问题

上面说了,运行子项目的时候,实际上是调用的子项目下的start的 script,那么process.cwd也就是返回的子项目的根路径,自然 webpack 中来判断是否支持 ts 的时候,就会从子项目路径下寻找 tsconfig.json 的配置文件了。

结合我们只希望整个项目只有一个tsconfig.json,我们有了一个方法,就是修改webpack.config.js的配置,让它只去寻找项目根目录的 tsconfig.json。这样就不能使用process.cwd,而是使用__dirname来确定脚本所在位置,然后回退到根目录下。

module.exports = {
...
- appTsConfig: resolveApp("tsconfig.json")
+ appTsConfig: path.resolve(__dirname,"..","tsconfig.json"),
...
}

我们直接把appTsConfig中寻找当前运行项目下tsconfig.json的路径,改成从配置文件的路径下定位根目录下tsconfig.json。这样,无论从哪里运行,只要使用了这个webpack.config.js,就会寻找同一个tsconfig.json

现在重新运行子项目,发现又报错了...

Cannot find the "xxxxx\package1\tsconfig.json" file.

Please check webpack and ForkTsCheckerWebpackPlugin configuration.
Possible errors:
- wrong `context` directory in webpack configuration (if `configFile` is not set or is a relative path in the fork plugin configuration)
- wrong `typescript.configFile` path in the plugin configuration (should be a relative or absolute path)

看来要使用 ts,还是要在每个项目下使用配置文件,但是现在我们不是单独的配置一个配置文件了,而是采用继承的方式来继承根目录下的tsconfig.json

{
"extends": "../../tsconfig.json"
}

然后再次运行,项目成功跑通不报错。

· 阅读需 2 分钟

在模态框中嵌入了 Form 表格元素,由官方的说法是

const [form] = useForm()

useEffect(()=>{
form.resetField()
})

// 组件
<Modal ...>
<Form form={form}>...</Form>
</Modal>

用 useForm 将 form 绑定到 Form 组件上,通过调用form.resetFields来初始化 initialValue。 但是 useEffect 中调用 reset 的时候,值的变化总是会延迟,这是由于组件的更新策略导致的。

modal 只挂载了一次,form 也只挂载了一次,所以值的变化是根据 react 的渲染时机来决定的。那么就有一个强制重载组件的方法,可能不是最好的方法,但是能解决问题。

解决方法

+const [visible,setVisible] = useState(false)
const [form] = useForm()

useEffect(()=>{
form.resetField()
-})
+},[visible])

# 组件
<Modal ...>
{visible && <Modal ...>
<Form form={form}></Form>
</Modal>}

监听 visible,来触发 reset 函数,那么每次 visible 改变的时候 modal 都会被销毁或者挂载,随后 form 进行 reset,能始终保持 form 处于最新初始化状态。

· 阅读需 1 分钟

在本地 build 项目和 preview 没有任何问题,在 netlify 部署就因为警告导致了命令被中断,那么就要用到环境变量来让 CI 不进行输出的编译。

解决方案

原本的 netlify 编译的命令npm run build,修改成CI=false npm run build

· 阅读需 1 分钟
function focus() {
const input = inputItemRef.current;
if (!input) return;

input.innerText = initialValue === null ? "" : initialValue;
const range = window.getSelection();
range?.selectAllChildren(input);
range?.collapseToEnd();
}

这是在 React 中写的一个函数,所以用了 ref 来获取 input。

其中让光标跑到最后的关键是,window.getSelection()的函数,通过 range 来找到我们需要移动光标的元素,然后调用 collapseToEnd 就可以让光标跑到最后。

contentEditable 和 input 并不相同,如果是 input 元素,可以去调用自身元素上面的函数selectionSetSelectionRange来设定。

· 阅读需 14 分钟

在网页中渲染一个表格,基本上是这两种方法,一是采用原生的table 标签,二是通过div 标签+contenteditable 属性来。

1. 原生 table 标签和 div 标签的对比

table 标签

原生 table 标签能够很方便的去渲染出表格,只需要按照 table 标签的规范来填充数据。

如果我们要修改它的样式,要通过 css 来修改每一个部分对应的标签的 css 属性。例如th,td,td标签等等。

div 实现(div 代指其他标签)

俗话说,万物皆可 div。只要我们渲染逻辑写的缜密,我们仍然可以去通过 div 去实现一套视觉友好,甚至能够符合 html5 的无障碍规范等等的表格组件出来。

如何选择

用 table 毕竟是已经封装好的原生的组件,所以难免会遇到一些不好解决的,具有局限性的配置等。就比如说,表格中每一个元素的边框,会产生一些重叠,导致内层的边框比外层的边框粗,但是我们可以通过 table 提供的border-collapse去实现内外边框粗细一致。还有我们想定制这个表格的元素的时候,会发现仍然有很多局限,因为每个元素都已经被规范应该是什么样的位置等,所以想要在元素中嵌入一些自定义的元素,就要花更多的事件去考虑需要怎么嵌入。

上述所说的问题在很多场景下没有大碍,但是在一些组件库中,或者在高度定制的表格项目中,我们用原生的 table 标签并不有利。所以在很多项目例如腾讯表格,没有用原生的 table 表格来实现,而是通过 div 等标签来实现的。

那么用 div 能有什么好处?首先上述原生 table 显露出来的一个问题就是,我们的渲染逻辑都局限在了 table 标签的规范中去了,所有的元素都被规范局限了渲染逻辑。很方便的渲染出一个表格,但问题总是在后期遇到。那么我们何不去封装一套更好的表格渲染逻辑,或者说是一套符合高度自定义的场景,同样暴露出和原生 table 标签所一致的 api 等等,但此基础上还暴露出了更多的自定义的 api,就像所说的嵌入元素等的 api。

2. div 的渲染设计

要实现一个表格,最终要的是渲染出表格的框架,其次就是实现表格的内容编辑的功能,后面就是对单元格的一些操作,以及适应屏幕的逻辑等。

1. 生成驱动视图的数据

表格,首先最容易想到的是用二维数组来实现,二维数组的第一维数据来渲染每一行,将每一行都渲染出来就形成了一个表格。

arr: [[ ,A,B,C],[1,A1,B1,C1],[2,A2,B2,C2]]

ABC
1A1B1C1
2A2B2C2

就像上面这样对应起来,arr 是最原始的渲染数组,其中包括了表头的数据。在 arr[0]中是[,A,B,C..],第一个是undefined,所以在后面的逻辑中渲染出来的是空白。是否一个表格数据应该包含表头数据?我认为最原始数组最好保留所有的数据,在后面的渲染中进行逻辑排除处理是一个比较好的做法。

2. 根据数据来渲染表格

数据已经被分为了数组的每一项,那么我们把每一维当行,每一维中的每一项当列即可。那么通过 2 层二维渲染就可以成功渲染出来。其中需要做的重要处理就是对空数据的处理,如果为空的时候我们不能单纯的不渲染这个元素,否则这个单元格的宽度和高度不会被撑起来。我们需要对它做标记,在 css 的处理上要针对性处理。

3. 编写表格的样式

如果我们单独为每个单元格都编写上下左右的边框,那就会出现边框重叠的问题,内部的宽度比外围的宽度粗,并且表格也出现了不对其的问题。

要解决边框粗细一致,在渲染的时候采取一定的措施,就是按一个方向去渲染表格的边框。比如说,只渲染表格的右边和下边的边框,最后渲染出来的结果就是没有任何的单元格边框被重叠,但是第一列和第一行的左边框或上边框没有被渲染出来。所以这个时候我们针对第一行和第一列,额外对它们设置左边和上边的边框的渲染。这样就达成了一个边框粗细一致的表格。

3.5 边框渲染出现的问题**

通过给每一个 div 单元格设置边框,因为第一行和第一列的边框渲染边框逻辑不一致,所以这些盒子在内容上的尺寸不一致。例如默认情况下每一个单元格的宽度是 100px,高度是 30px,边框宽度为 1px,那么大多数单元格边框渲染后的尺寸变成了 99px 和 29px,而第一行和第一列变成了 98px 和 28px。或许你会说,box-sizing可以让它们的内容尺寸保持一致,但是内容保持一致了盒子总体宽度还一致吗?显然不一致。 那么为了解决这一个问题,我运用到了伪元素来渲染表格边框。

4. 表格样式改良 - 伪元素

伪元素的一个特点就是不会占据文档流位置,不会对盒子产生任何的宽高上的形变等。用伪元素之后,我们的表格就可以简单的渲染成 100px,30px 的尺寸,而不考虑宽度。这时候这些单元格没有任何的边框,接下来要借助伪元素来渲染边框。通过伪元素渲染边框就可以简单的设置 100%宽高来让伪元素铺满 100px 和 30px 的 div,然后同样是按一个方向来渲染之前的表格,但是这一次的结果是渲染完表格之后,所有单元格的尺寸仍然是 100px 和 30px。

5. 实现内容的编辑**

在 div 中要实现内容的编辑,我们就需要借助contenteditable属性,设置了这个属性之后的 div 在被点击之后就会变成一个输入框,输入内容。

这个属性的编辑在生效后,我们要实现让编辑的最新的内容写入到 state 中,然后触发重新渲染。梳理一下完整的编辑逻辑就是:

1. 正常通过 state 最原始的空数组渲染出空白的单元格

2. 单元格被点击,编辑内容

3. 单元格上面的 input 事件被触发,回调函数中实现获取最新输入的内容,然后更新 state

4. state 中对应单元格索引的数据改变,最新渲染单元格中内容改变


看起来渲染逻辑没有问题,但是在第 3 点到第 4 点,也就是重新渲染的过程中,如果我们输入框一直在触发输入,渲染也在被一直重新触发,那么是不是会出现一个问题,就是我们输入的内容因为重新渲染而消失,导致数据丢失等? 而我出现的问题实际上是出现每次输入光标都会跑到最前面

为了解决这样的一个问题,就要让输入和渲染内容的容器被区分开,但是让几个 div 叠加到单元格上,点击单元格实际触发的是另外的一个输入框,但是这样单元格会变得层级比较复杂,可能会更容易出现 bug。我就想能不能用防抖来实现,也就是输入内容的时候并不立刻去重新渲染,而是等待一段时间。事实上,并不能行得通,因为如果在输入阶段停下来引起了防抖函数的执行,那么仍然会重新执行,光标仍然会跑到最前面。

参考了一些单元格的产品,我觉得在这上面做一个妥协也是行得通的,就是在单元格进行失焦的时候再来重新渲染。在后期也可以在数据的保护上更下功夫。

6. 实现单元格选中的高亮边框

实现高亮边框,修改单元格的伪元素的边框不太现实,这样也可能造成更多的元素的属性的修改,对性能可能不太有利。所以最好是单独用一个 div 来实现,而表格的尺寸和位置都通过元素的计算来实现。 比如我点击了一个 row 索引为 10,column 索引为 8 的单元格,那么根据这一个单元格索引结合每一个单元格的尺寸配置数据,来计算出单元格需要偏移多少个像素,宽度高度是多少。选中多个单元格也是如此,无非就是多计算几个单元格的尺寸。

7. 实现单元格的伸缩和虚拟列表适应全屏等

上面已经说明了这个表格是如何通过数据去驱动整个视图的渲染的,那么要做单元格的伸缩等,从 css 的渲染上入手即可。

根据一个配置,计算出这个配置对哪些索引的表格尺寸进行了修改,然后在注入 css 属性的时候根据这个计算进行动态的改变,我觉得这是一个比较开放的功能,没有很多需要特别强调的点,如果要进行优化性能,无非是针对大量数据的场景下表格尺寸计算的算法速度和空间占用等。

虚拟列表这些,也都是那样的方案,针对场景做做调整即可。

· 阅读需 6 分钟

styled-components 是用于 react 的一个 css in js 的库。

下面是一个 styled-components 创建的一个 css in js 的组件。

<Styled borderColor={"red"}>{item}</Styled>

1. Passing props

React - Passing props

Styled-components - Passed props

在 React 中的 passing props 和 styled-components 的 passed props,都是对 props 传递的处理。

对于标准的 html 标签和 JSX 的标签,传递 props 的时候造成的结果是不一样的。

如果是标准的 html 标签,例如一个img标签,传递的widthheight甚至className(className 是 react 符合 html 标准的属性,会被注入成 class)都会被直接传递到 dom 中。也就是说最后在页面上检查元素标签的时候会出现这些 property。就像下面这样

<img width="100" class="xxx" />

但是是一个 React 的 JSX 组件,我们在这个组件上传递 props,这个 props 就是属于这个 react 的组件,并不会注入到任何的 html 标签上,因为这就相当于是一个函数的参数。

2. styled-components 中的参数传递

编写组件

但是在 styled-components 中,会因为组件创建的逻辑产生不同的结果。我们会通过styled.div之类的去创建一个组件,通过styled-components创建出一个 React 的组件,但是有些时候我们会对这个组件进行参数的传递,比如下面这种情况。

//这只是其中的一种传参的写法
const Component =
styled.div <
{ customColor: string } >
(({ customColor }) => {
return css`
height: 100px;
color: ${customColor};
`;
});

组件传参

此时我们要对这个组件及进行传参,传参的方式是和 React 一样的。

const ReactComponent:React.FC<...> = () => {

return (
<Component customColor={"red"}/>
)
}

最终渲染 dom 的结果

通过这样的方式进行传参之后,在页面中检查元素会发现,div 标签上面出现了一个 customcolor="red"(注意 大驼峰被改成了全小写。

<div class="sc-xxxx" customcolor="red"></div>

styled-components 还会进行下面的一个提示的警告。

React does not recognize the customColor prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase customcolor instead. If you accidentally passed it from a parent component, remove it from the DOM element.

我们的预期是最终在页面中渲染结果如下

<div class="sc-xxxx" />

显然实际结果和我们的预期结果之间,差了一个不希望被注入到 dom 标签上面的 property。这要根据 styled-components 的 props 的 passing 逻辑来处理,根据文档中的说法,任何使用在 styled-components 的组件上面的 property 都会被注入到 html 中,如果我们不想被注入,要在属性前面加$来屏蔽掉。所以最终的解决方法是如下

解决方法

const Component =
styled.div <
{ $customColor: string } >
(({ $customColor }) => {
return css`
height: 100px;
color: ${customColor};
`;
});
const ReactComponent:React.FC<...> = () => {
return (
<Component $customColor={"red"}/>
)
}

这样最后渲染 dom 的结果如下

<div class="sc-xxxx" />

3. 不希望被注入到 dom 的 property 被注入,有什么影响

首先,符合规范的 dom 的 properties 都是全小写的,有些会用 杠 来连接。那么至少在 React 的严格模式下,我们不是规范的 property,都会被认为是对组件的传参,所以原生的 React 的组件对于这个问题,很多情况下是没有什么影响的。

但是在 styled-components 中,我们不希望被注入到 html 中的属性被注入上去了,并且本来应该是大驼峰的,最后变成了小驼峰,这样在 dom 元素检查上会造成一定的误解。

而我记录这个问题的原因是,在我有进行单元测试的时候,生成的快照上面的标签上突然生成了这样的一个 property,控制台还抛出了大量的警告,也就是上面提到的那个警告信息。当时我并没有意识到这个passing props概念的存在,我认为只有标准的 property 才会被注入到 html 标签上,其他不符合标准的并不会被注入到 dom 上,而是被当作是组件的传参,但实际并不是这样。

我想了一下,我当时修改了一些组件的传参方式,就造成了这样的结果,我觉得在灵活的 React 组件传参方式上,有的方式会造成这样的结果,而有的不会,这是并不希望出现的,所以最好的是根据官方的文档用$符号来确定性的将 property 排除掉。