Skip to main content

· 9 min read

本文介绍的是 pnpm 管理 Monorepo 项目实践。

什么是 Monorepo

Monorepo 项目简称多包项目,一个包含多个子项目的仓库。

那为什么要放多个项目在一个仓库下呢?

是因为这些项目互相引用,相互依赖,放在一个仓库下方便管理及依赖。

所以管理一个多包项目的关键,需要实现以下 2 点:

  • 能够很方便的管理包与包之间的依赖关系
  • 能够在发布其中一个包时,自动更新依赖了该包的其他包并发布

什么是 lerna

使用最广泛成熟的 Monorepe 项目管理方案就是 lerna + yarn。

lerna 是一个优化使用 git 和 npm 管理多包存储库工作流的工具。

它具有以下功能:

  • 自动解决 packages 之间的依赖关系;
  • 通过 git 检测文件改动,自动发布;
  • 根据 git 提交记录,自动生成 CHANGELOG。

更多详细的 lerna 介绍可以见我的另外一篇博客:最详细的 lerna 中文手册

既然 lerna 这么好用也这么熟悉了,那为什么还要切换到 pnpm 呢?

有以下几个原因:

  • pnpm 内置了管理 monorepe 功能,使用起来比 lerna 简单
  • pnpm 安装比 yarn 高效,也节省电脑内存

什么是 pnpm

pnpm 介绍可以查看 pnpm 官网

pnpm 是新一代的包管理工具,相较于 npm 和 yarn,有以下 2 个优点:

节约磁盘空间并提升安装速度

节约磁盘空间:

  • pnpm 安装依赖时,依赖会被存储在硬盘中,不同项目的同一依赖都会硬链接到硬盘位置,不会额外占用磁盘空间。

  • 同一依赖包的不同版本,也只会将不同版本中有差异的文件添加到仓库中,不会下载整个包占用磁盘空间。

提升安装速度:

  • 安装依赖时,会先去硬盘位置寻找包,如果能找到,则建立硬链接,比起重新下载包或者从缓存中拷贝移动包,速度快了很多

创建非扁平化的 node_modules 文件夹

npm、yarn 为了解决同一依赖被安装多次的问题,将所有包都被提升到模块目录的根目录。

但是当依赖包有多个版本的时候,只会提升一个,其余版本的包依然会被安装多次。

另外扁平化 node_modules 时,项目可以访问到未被添加进当前项目的依赖,这样是有隐患的,因为没有显式依赖,万一有一天别的包不依赖这个包了,代码就不能跑了,因为你依赖这个包,但是现在不会被安装了。

image.png

pnpm 采用磁盘硬链接连接依赖,已经解决了依赖会被安装多次的问题。

为了避免幽灵依赖,pnpm 选择创建非扁平化的 node_modules,项目无法访问到未被添加进当前项目的依赖。

image.png

快速开始

上面已经了解到为什么选择 pnpm 了,那么下面就一起用 pnpm 来管理 Monorepo 吧。

全局安装 pnpm

nvm use 16
npm i pnpm -g

创建 Monorepo 项目

创建目录结构:

mkdir my-project
cd ./my-project
npm init -y
mkdir packages
cd ./packages
mkdir my-project-a
cd ./my-project-a
npm init -y
mkdir my-project-b
cd ./my-project-b
npm init -y

项目结构如下:

image.png

启动 pnpm 的 workspace 功能,根目录新增 pnpm-workspace.yaml,指定工作空间的目录:

packages:
- "packages/**"

当我们配置了指定工作空间的目录后,packages 里的包互相引用时,会自动依赖本地编译的路径,方便实时调试

至此我们就解决了 Monorepre 项目的管理包与包之间的依赖关系的问题。

安装项目内依赖

限制仅允许 pnpm 安装依赖,更新 package.json:

{
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}

安装 eslint 等全局依赖:

pnpm i eslint -w -D

安装子项目内独立的依赖:

cd ./packages/my-project-a
pnpm i rollup -D

发布流程

pnpm 没有提供内置的发布流程解决方案,官方推荐了两个开源的版本控制工具:

changesets 的入手学习成本更低,于是乎选择了 changesets 来管理发布流程。

安装 changesets

pnpm add -Dw @changesets/cli

初始化 changesets

pnpm changeset init

初始化后生成的 .changeset/config.json:

{
"$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json",
"changelog": "@changesets/cli/changelog", // changelog 生成方式
"commit": false, // 不要让 changeset 在 publish 的时候帮我们做 git add
"fixed": [],
"linked": [], // 配置哪些包要共享版本
"access": "restricted", // 公私有安全设定,内网建议 restricted ,开源使用 public
"baseBranch": "master", // 项目主分支
"updateInternalDependencies": "patch", // 确保某包依赖的包发生 upgrade,该包也要发生 version upgrade 的衡量单位(量级)
"ignore": [] // 不需要变动 version 的包
}

管理 changelog

如果是开源库可以安装 @changesets/changelog-github 来管理 changelog。

安装:

pnpm add -Dw @changesets/changelog-github

更新 .changeset/config.json:

{
"changelog": [
"@changesets/changelog-github",
{
"repo": "worktile/slate-angular" // 改为你的 github 仓储
}
]
}

如果不是开源库,则保持 "changelog": "@changesets/cli/changelog"

生成 changesets

npx changeset

选择要发布的包:

image.png

选择发布的类型:

image.png

填写发布备注:

image.png

确认发布:

image.png

生成临时文件:

image.png

更新版本

更新版本前可以先把开发区的改动提交上去。

git add .
git commit -m 'feat: msg'
git push

更新版本:

npx changeset version

image.png

自动生成 CHANGELOG.md 并更新 package.json 中的版本,同时如果子项目间有相互依赖,也会更新依赖版本

image.png

image.png

发布版本

发布至 npm:

npx changeset publish

至此我们就解决了 Monorepre 项目的在发布其中一个包时,自动更新依赖了该包的其他包并发布的问题。

小结

pnpm 是不是比 yarn + lerna 更香?节省磁盘空间,安装依赖更快,内置 Monorepo 功能。

说实话,不得不用 pnpm 的原因,还得是电脑内存不够用,20个项目,40G node_modules 的内存就没了。

所以,赶紧转 pnpm 吧。

项目地址:https://github.com/jiaozitang/web-learn-note/tree/feat/pnpm

希望能对你有所帮助,感谢阅读~

别忘了点个赞鼓励一下我哦,笔芯 ❤️

参考资料

· 13 min read

介绍

本文带你一起使用 Rollup 打包项目,实现以下功能:

  • 自动将 dependencies 依赖声明为 externals
  • 支持处理外部 npm 依赖
  • 支持基于 CommonJS 模块引入
  • 支持 typescript,并导出声明文件
  • 支持 scss,并添加前缀
  • 支持自动清除调试代码
  • 打包输出文件保留原始模块结构
  • 支持按需加载

一、什么是 rollup

rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。

二、为什么是 rollup

为什么是 rollup 而不是 webpack 呢?

rollup的特色是 ES6 模块和代码 Tree-shaking,这些 webpack 同样支持,除此之外 webpack 还支持热模块替换、代码分割、静态资源导入等更多功能。

当开发应用时当然优先选择的是 webpack,但是若你项目只需要打包出一个简单的 bundle 包,并是基于 ES6 模块开发的,可以考虑使用 rollup

rollup 相比 webpack,它更少的功能和更简单的 api,是我们在打包类库时选择它的原因。

三、支持打包的文件格式

rollup 支持的打包文件的格式有 amd, cjs, es\esm, iife, umd。其中,amd 为 AMD 标准,cjs 为 CommonJS 标准,esm\es 为 ES 模块标准,iife 为立即调用函数, umd 同时支持 amd、cjs 和 iife。

四、快速开始

1. 安装

npm install --global rollup

2. 基础打包

新增文件 src/main.js

// src/main.js
import foo from "./foo.js";
export default function () {
console.log(foo);
}

新增文件 src/foo.js

export default "hello world!";

项目根目录下新增文件 rollup.config.js

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "cjs",
},
};

运行命令:

rollup -c

得到产物 bundle.js

"use strict";

var foo = "hello world!";

// src/main.js
function main() {
console.log(foo);
}

module.exports = main;

这时我们使用 Rollup 将 src/main.jssrc/foo.js打包成功,完成了第一个 bundle,。

image.png

3. 引入外部资源

更新 src/main.js,添加外部资源 lodash-es 引入:

// src/main.js
import foo from "./foo.js";

import { sum } from "lodash-es";

export default function () {
console.log(foo);
console.log(sum[(1, 2)]);
}

再次打包 rollup -c,发现有报错 (!) Unresolved dependencies

image.png

这是因为当项目中引入外部资源时,如 npm 包,rollup 不知道如何打破常规去处理这些依赖。

有 2 种方法引入外部资源:

  • 添加插件 @rollup/plugin-node-resolve 将我们编写的源码与依赖的第三方库进行合并;
  • 配置 external 属性,告诉 rollup.js 哪些是外部的类库。

3.1 resolve 插件

@rollup/plugin-node-resolve 插件让 rollup 能够处理外部依赖。

安装:

yarn add @rollup/plugin-node-resolve -D

更新 rollup.config.js

import resolve from "@rollup/plugin-node-resolve";
export default {
plugins: [resolve()],
};

重新打包得到产物,已经包含了 lodash-es

"use strict";

var foo = "hello world!";

/**
* This method returns the first argument it receives.
*
* @static
* @since 0.1.0
* @memberOf _
* @category Util
* @param {*} value Any value.
* @returns {*} Returns `value`.
* @example
*
* var object = { 'a': 1 };
*
* console.log(_.identity(object) === object);
* // => true
*/
function identity(value) {
return value;
}

/**
* The base implementation of `_.sum` and `_.sumBy` without support for
* iteratee shorthands.
*
* @private
* @param {Array} array The array to iterate over.
* @param {Function} iteratee The function invoked per iteration.
* @returns {number} Returns the sum.
*/
function baseSum(array, iteratee) {
var result,
index = -1,
length = array.length;

while (++index < length) {
var current = iteratee(array[index]);
if (current !== undefined) {
result = result === undefined ? current : result + current;
}
}
return result;
}

/**
* Computes the sum of the values in `array`.
*
* @static
* @memberOf _
* @since 3.4.0
* @category Math
* @param {Array} array The array to iterate over.
* @returns {number} Returns the sum.
* @example
*
* _.sum([4, 2, 8, 6]);
* // => 20
*/
function sum(array) {
return array && array.length ? baseSum(array, identity) : 0;
}

// src/main.js

function main() {
console.log(foo);
console.log(sum([1, 2]));
}

module.exports = main;

成功运行:

image.png

3.2 external 属性

有些场景下,虽然我们使用了 resolve 插件,但可能我们仍然想要某些库保持外部引用状态,这时我们就需要使用 external 属性,来告诉 rollup.js 哪些是外部的类库。

更新 rollup.config.js

import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "esm",
name: "test",
},
plugins: [nodeResolve(), commonjs()],
external: ["react"],
};

3.3 external 插件

每个类库都要手动添加至 externals 未免太麻烦,这时候可以用 rollup-plugin-node-externals 插件,自动将外部类库声明为 externals。

安装:

yarn add rollup-plugin-node-externals -D

更新 rollup.config.js

import externals from "rollup-plugin-node-externals";

export default [
{
plugins: [
externals({
devDeps: false, // devDependencies 类型的依赖就不用加到 externals 了。
}),
],
},
];

4. 引入 CommonJs 模块

4.1 CommonJs 插件

rollup.js 编译源码中的模块引用默认只支持 ES6+的模块方式 import/export。然而大量的 npm 模块是基于 CommonJS 模块方式,这就导致了大量 npm 模块不能直接编译使用。

需要添加 @rollup/plugin-commonjs 插件来支持基于 CommonJS 模块方式 npm 包。

安装:

yarn add @rollup/plugin-commonjs -D

更新 rollup.config.js:

import commonjs from "@rollup/plugin-commonjs";

export default {
plugins: [commonjs()],
};

更新 src/foo.js:

module.exports = {
text: "hello world!",
};

重新打包,打包成功:

image.png

5. 引入 Sass 资源

rollup-plugin-postcss 默认集成了对 scss、less、stylus 的支持。

5.1 打包支持 sass 文件

新增 src/foo.scss

body {
background-color: red;
display: flex;
}

更新 src/main.js

// src/main.js
import foo from "./foo.js";
import "./foo.scss";

export default function () {
console.log(foo.text);
}

安装:

yarn add rollup-plugin-postcss -D

更新 rollup.config.js:

import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import postcss from "rollup-plugin-postcss";

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "esm",
name: "test",
},
plugins: [nodeResolve(), commonjs(), postcss()],
external: ["react"],
};

打包产物:

"use strict";

var foo = {
text: "hello world!",
};

function styleInject(css, ref) {
if (ref === void 0) ref = {};
var insertAt = ref.insertAt;

if (!css || typeof document === "undefined") {
return;
}

var head = document.head || document.getElementsByTagName("head")[0];
var style = document.createElement("style");
style.type = "text/css";

if (insertAt === "top") {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
} else {
head.appendChild(style);
}
} else {
head.appendChild(style);
}

if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
}

var css_248z = "body {\n background-color: red;\n}";
styleInject(css_248z);

// src/main.js

function main() {
console.log(foo.text);
}

module.exports = main;

效果如图:

image.png

5.2 css 加前缀

安装:

yarn add autoprefixer -D

更新 packages.json:


"browserslist": [
"defaults",
"not ie < 8",
"last 2 versions",
"> 1%",
"iOS 7",
"last 3 iOS versions"
]

更新 rollup.config.js:

import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import autoprefixer from "autoprefixer";
import postcss from "rollup-plugin-postcss";

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "umd",
name: "test",
},
plugins: [
nodeResolve(),
commonjs(),
postcss({
plugins: [autoprefixer()],
}),
],
external: ["react"],
};

效果如图:

image.png

5.3 css 压缩

安装:

yarn add cssnano -D

更新 rollup.config.js:

import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import autoprefixer from "autoprefixer";
import cssnano from "cssnano";
import postcss from "rollup-plugin-postcss";

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "umd",
name: "test",
},
plugins: [
nodeResolve(),
commonjs(),
postcss({
plugins: [autoprefixer(), cssnano()],
}),
],
external: ["react"],
};

效果如图:

image.png

5.4 抽离单独的 css 文件

更新 rollup.config.js

export default [
{
plugins: [
postcss({
plugins: [autoprefixer(), cssnano()],
extract: "css/index.css",
}),
],
},
];

效果如图:

image.png

6. 引入 Typescript 资源

6.1 typescript 插件

修改 src/foo.js -> src/foo.ts

export default {
text: "hello world!",
};

更新 src/main.js

// src/main.js
import foo from "./foo.ts";
import "./foo.scss";

export default function () {
console.log(foo.text);
}

安装:

yarn add @rollup/plugin-typescript -D

更新 rollup.config.js:

import typescript from "@rollup/plugin-typescript";
export default [
{
plugins: [typescript()];
}
];

成功支持 Ts 文件导出:

image.png

6.2 导出类型声明文件

更新 rollup.config.js:

import typescript from "@rollup/plugin-typescript";
export default [
{
plugins: [
typescript({
outDir: "dist",
declaration: true,
declarationDir: "dist",
})
];
}
];

成功支持类型声明文件导出:

image.png

7. 打包产物清除调试代码

插件 @rollup/plugin-strip 用于从代码中删除 debugger 语句和函数。包括 assert.equal、console.log 等等。

安装:

yarn add @rollup/plugin-strip -D

更新 rollup.config.js:

import strip from "@rollup/plugin-strip";
export default [
{
plugins: [
strip()
];
}
];

8. 打包输出文件保留原始模块结构

上面我们的 output 配置是这样的:

output: {
dir: path.dirname('dist/bundle.js'),
format: 'es',
}

打包产物如下:

image.png

那么怎么才能把 index.js、index2.js 改成 foo/index.js、hello/index.js 呢?

修改 output,更新 rollup.config.js:

output: {
dir: path.dirname('dist/bundle.js'),
format: 'es',
exports: 'named', // 指定导出模式(自动、默认、命名、无)
preserveModules: true, // 保留模块结构
preserveModulesRoot: 'src', // 将保留的模块放在根级别的此路径下
},

这时打包产物就和源码的结构一致了:

image.png

9. 按需加载

rollup 支持输出格式为 es 模块化,就会按模块输出。

所以我们上面的配置已经实现了按需加载了。

五、一个真实的组件库的 rollup 打包配置

项目地址:https://github.com/jiaozitang/react-masonry-component2

该项目支持:

  • 打包输出文件保留原始模块结构
  • 自动将 dependencies 依赖声明为 externals
  • 支持处理外部 npm 依赖
  • 支持基于 CommonJS 模块引入
  • 支持 typescript,并导出声明文件
  • 支持 scss,并添加前缀
  • 支持自动清除调试代码
  • 支持按需加载

1. 安装

npm i rollup -g

yarn add typescript postcss @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-strip @rollup/plugin-typescript rollup-plugin-postcss rollup-plugin-node-externals autoprefixer -D

2. 配置

项目根目录下新增 rollup.config.js

import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import strip from "@rollup/plugin-strip";
import typescript from "@rollup/plugin-typescript";
import autoprefixer from "autoprefixer";
import path from "path";
import externals from "rollup-plugin-node-externals";
import postcss from "rollup-plugin-postcss";

import pkg from "./package.json";

export default [
{
input: "./src/index.ts", // 入口文件
output: [
{
// 出口文件
dir: path.dirname(pkg.module),
format: "es", // es模块导出,支持按需加载
name: pkg.name,
exports: "named", // 指定导出模式(自动、默认、命名、无)
preserveModules: true, // 保留模块结构
preserveModulesRoot: "src", // 将保留的模块放在根级别的此路径下
},
],
plugins: [
// 自动将dependencies依赖声明为 externals
externals({
devDeps: false,
}),
// 处理外部依赖
resolve(),
// 支持基于 CommonJS 模块引入
commonjs(),
// 支持 typescript,并导出声明文件
typescript({
outDir: "es",
declaration: true,
declarationDir: "es",
}),
// 支持 scss,并添加前缀
postcss({
plugins: [autoprefixer()],
}),
// 清除调试代码
strip(),
],
},
];

更新 packages.json:

{
"module": "es/index.js",
"types": "es/index.d.ts",
"files": ["es"]
}

新增 tsconfig.json:

{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
},
"include": [
"src"
],
"exclude": [
"src/**/stories.*",
"src/**/.spec.*",
"src/**/.mdx"
]
}

项目结构:

image.png

打包产物:

image.png

小结

本文介绍了 rollup 的各个功能的使用方法,rollup 自身能力较弱,依靠插件完成完整的组件库打包。

可以直接拷贝文中配置,实现一个按需加载的组件库打包。

项目地址:https://github.com/jiaozitang/react-masonry-component2

希望能对你有所帮助,感谢阅读~

别忘了点个赞鼓励一下我哦,笔芯 ❤️

往期精彩

参考资料

· 11 min read

一、背景

本文介绍 5 种瀑布流场景的实现,大家可以根据自身的需求场景进行选择

5 种场景分别是:

瀑布流特点
纵向+高度排序纯 CSS 多列实现,是最简单的瀑布流写法
纵向+高度排序+根据宽度自适应列数通过 JS 根据屏幕宽度计算列数,在 web 端更加灵活的展示瀑布流
横向纯 CSS 弹性布局实现,是最简单的横向瀑布流写法
横向+高度排序横向+高度排序的瀑布流,需要通过 JS 计算每一列高度,损耗性能,但是可以避免某列特别长的情况,体验更好
横向+高度排序+根据宽度自适应列数需要通过 JS 计算每一列高度,并根据屏幕宽度计算列数,损耗性能,但是可以避免某列特别长的情况,并且可以在 web 端更加灵活的展示瀑布流,体验更好,是 5 种瀑布流中用户体验最好的

我已经将这 5 种场景的实现封装成 npm 包,npm 包地址:https://www.npmjs.com/package/react-masonry-component2,可以直接在 React 项目中安装使用。

二、介绍

瀑布流,是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。

下图就是一个瀑布流布局的示意图:

image.png

三、纵向+高度排序

纵向+高度排序指的是,每列按照纵向排列,往高度最小的列添加内容,如下图所示。

image.png

实现纵向+高度排序瀑布流的方法是 CSS 多列布局

1. 多列布局介绍

多列布局指的是 CSS3 可以将文本内容设计成像报纸一样的多列布局,如下实例:

image.png

CSS3 的多列属性:

  • column-count:指定了需要分割的列数;
  • column-gap:指定了列与列间的间隙;
  • column-rule-style:指定了列与列间的边框样式;
  • column-rule-width:指定了两列的边框厚度;
  • column-rule-color:指定了两列的边框颜色;
  • column-rule:是 column-rule-* 所有属性的简写;
  • column-span:指定元素跨越多少列;
  • column-width:指定了列的宽度。

2. 实现思路

瀑布流实现思路如下:

  • 通过 CSS column-count 分割内容为指定列;
  • 通过 CSS break-inside 保证每个子元素渲染完再换行;

3. 实现代码

.css-column {
column-count: 4; //分为4
}

.css-column div {
break-inside: avoid; // 保证每个子元素渲染完在换行
}

4. 直接使用 npm 包

npm - react-masonry-component2 的使用方法:

import { Masonry } from 'react-masonry-component2'

export const MyComponent = (args) => {
return (
<Masonry direction='column'>
<div></div>
<div></div>
<div></div>
</Masonry>
)
}

在线预览:https://632339a3ed0b247d36b0fa3c-njrsmzdcdj.chromatic.com/?path=/story/%E5%B8%83%E5%B1%80-masonry-%E7%80%91%E5%B8%83%E6%B5%81--%E7%BA%B5%E5%90%91%E5%B8%83%E5%B1%80

四、纵向+高度排序+根据宽度自适应列数

在纵向+高度排序的基础上,按照宽度自适应列数。

image.png

1. 实现思路

  • 监听 resize 方法,根据屏幕宽度得到该宽度下应该展示的列数

2. 实现代码

import { useCallback, useEffect, useMemo, useState } from 'react'

import { DEFAULT_COLUMNS_COUNT } from '../const'

export const useHasMounted = () => {
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
return hasMounted
}

export const useWindowWidth = () => {
const hasMounted = useHasMounted()
const [width, setWidth] = useState(0)

const handleResize = useCallback(() => {
if (!hasMounted) return
setWidth(window.innerWidth)
}, [hasMounted])

useEffect(() => {
if (hasMounted) {
window.addEventListener('resize', handleResize)
handleResize()
return () => window.removeEventListener('resize', handleResize)
}
}, [hasMounted, handleResize])

return width
}

export const useColumnCount = (columnsCountBreakPoints: {
[props: number]: number
}) => {
const windowWidth = useWindowWidth()
const columnCount = useMemo(() => {
const breakPoints = (
Object.keys(columnsCountBreakPoints as any) as unknown as number[]
).sort((a: number, b: number) => a - b)
let count =
breakPoints.length > 0
? columnsCountBreakPoints![breakPoints[0]]
: DEFAULT_COLUMNS_COUNT

breakPoints.forEach((breakPoint) => {
if (breakPoint < windowWidth) {
count = columnsCountBreakPoints![breakPoint]
}
})

return count
}, [windowWidth, columnsCountBreakPoints])

return columnCount
}

动态定义 style columnCount,实现根据屏幕宽度自适应列数:

const { columnsCountBreakPoints } = props
const columnCount = useColumnCount(columnsCountBreakPoints)
return (
<div className={classNames(['masonry-column-wrap'])} style={{ columnCount }}>
{children}
</div>
)

3. 直接使用 npm 包

npm - react-masonry-component2 的使用方法:

import { Masonry } from 'react-masonry-component2'

export const MyComponent = (args) => {
return (
<Masonry
direction='column'
columnsCountBreakPoints={{
1400: 5,
1000: 4,
700: 3,
}}
>
<div></div>
<div></div>
<div></div>
</Masonry>
)
}

在线预览:https://632339a3ed0b247d36b0fa3c-njrsmzdcdj.chromatic.com/?path=/story/%E5%B8%83%E5%B1%80-masonry-%E7%80%91%E5%B8%83%E6%B5%81--%E7%BA%B5%E5%90%91%E5%B8%83%E5%B1%80

五、横向

横向瀑布流指的是,每列按照横向排列,如下图所示。

image.png

实现横向瀑布流的方法是CSS 弹性布局

1. 弹性布局介绍

弹性布局,是一种当页面需要适应不同的屏幕大小以及设备类型时确保元素拥有恰当的行为的布局方式。

引入弹性盒布局模型的目的是提供一种更加有效的方式来对一个容器中的子元素进行排列、对齐和分配空白空间。

CSS3 的弹性布局属性:

  • flex-dicreation:指定了弹性子元素的排列方式;
  • justify-content:指定了弹性布局的主轴对齐方式;
  • align-items:指定了弹性布局的侧轴对齐方式;
  • flex-wrap:指定了弹性子元素的换行方式;
  • align-content:指定弹性布局各行的对齐方式;
  • order:指定弹性子元素的排列顺序;
  • align-self:指定弹性子元素的纵向对齐方式;
  • flex  属性用于指定弹性子元素如何分配空间;
    • auto: 计算值为 1 1 auto
    • initial: 计算值为 0 1 auto
    • none:计算值为 0 0 auto
    • inherit:从父元素继承
    • [ flex-grow ]:定义弹性盒子元素的扩展比率。
    • [ flex-shrink ]:定义弹性盒子元素的收缩比率。
    • [ flex-basis ]:定义弹性盒子元素的默认基准值。

2. 实现思路

瀑布流实现思路如下:

  • CSS 弹性布局对 4 列按横向排列,对每一列内部按纵向排列。

3. 实现代码

瀑布流实现代码如下:

<div className={classNames(['masonry-flex-wrap'])}>
<div className='masonry-flex-wrap-column'>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div className='masonry-flex-wrap-column'>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
.masonry-flex-wrap {
display: flex;
flex-direction: row;
justify-content: center;
align-content: stretch;

&-column {
display: 'flex';
flex-direction: 'column';
justify-content: 'flex-start';
align-content: 'stretch';
flex: 1;
}
}

4. 直接使用 npm 包

npm - react-masonry-component2 的使用方法:

import { Masonry } from 'react-masonry-component2'

export const MyComponent = (args) => {
return (
<Masonry
columnsCountBreakPoints={{
1400: 5,
1000: 4,
700: 3,
}}
>
<div></div>
<div></div>
<div></div>
</Masonry>
)
}

在线预览:https://632339a3ed0b247d36b0fa3c-njrsmzdcdj.chromatic.com/?path=/story/%E5%B8%83%E5%B1%80-masonry-%E7%80%91%E5%B8%83%E6%B5%81--%E6%A8%AA%E5%90%91%E5%B8%83%E5%B1%80

六、横向+高度排序

横向+高度排序指的是,每列按照横向排列,往高度最小的列添加内容,如下图所示。

image.png

高度排序就需要用 JS 逻辑来做了。

1. 实现思路

  • JS 将瀑布流的列表按高度均为分为指定列数,比如瀑布流为 4 列,那么就要把瀑布流列表分成 4 个列表

2. 实现代码

export const getColumnsSortWithHeight = (
children: React.ReactNode,
columnCount: number
) => {
const columns: {
height: number
children: React.ReactNode[]
}[] = Array.from({ length: columnCount }, () => ({
height: 0,
children: [],
}))

React.Children.forEach(children, (child: React.ReactNode, index) => {
if (child && React.isValidElement(child)) {
if (index < columns.length) {
columns[index % columnCount].children.push(child)
columns[index % columnCount].height += child.props.height
return
}

const minHeightColumn = minBy(columns, (a) => a.height) as {
height: number
children: React.ReactNode[]
}
minHeightColumn.children.push(child)
minHeightColumn.height += child.props.height
}
})

return columns
}

3. 直接使用 npm 包

npm - react-masonry-component2 的使用方法:

import { Masonry, MasonryItem } from 'react-masonry-component2'

export const MyComponent = (args) => {
return (
<Masonry
sortWithHeight
columnsCountBreakPoints={{
1400: 5,
1000: 4,
700: 3,
}}
>
<MasonryItem height={200}>
<div></div>
</MasonryItem>
<MasonryItem height={300}>
<div></div>
</MasonryItem>
<MasonryItem height={400}>
<div></div>
</MasonryItem>
</Masonry>
)
}

在线预览:https://632339a3ed0b247d36b0fa3c-njrsmzdcdj.chromatic.com/?path=/story/%E5%B8%83%E5%B1%80-masonry-%E7%80%91%E5%B8%83%E6%B5%81--%E6%A8%AA%E5%90%91%E5%B8%83%E5%B1%80%E9%AB%98%E5%BA%A6%E6%8E%92%E5%BA%8F

七、横向+高度排序+根据宽度自适应列数

根据宽度自适应列数的做法和纵向场景一致,都是监听 resize 方法,根据屏幕宽度得到该宽度下应该展示的列数,这里不做赘述。

image.png

1. 直接使用 npm 包

npm - react-masonry-component2 的使用方法:

import { Masonry } from 'react-masonry-component2'

export const MyComponent = (args) => {
return (
<Masonry
sortWithHeight
direction='column'
columnsCountBreakPoints={{
1400: 5,
1000: 4,
700: 3,
}}
>
<div></div>
<div></div>
<div></div>
</Masonry>
)
}

在线预览:https://632339a3ed0b247d36b0fa3c-njrsmzdcdj.chromatic.com/?path=/story/%E5%B8%83%E5%B1%80-masonry-%E7%80%91%E5%B8%83%E6%B5%81--%E6%A8%AA%E5%90%91%E5%B8%83%E5%B1%80%E9%AB%98%E5%BA%A6%E6%8E%92%E5%BA%8F

小结

本文介绍了 5 种瀑布流场景的实现:

  • 纵向+高度排序
  • 纵向+高度排序+根据宽度自适应列数
  • 横向
  • 横向+高度排序
  • 横向+高度排序+根据宽度自适应列数

感兴趣的同学可以到项目源码查看完整实现代码。

也可以下载 https://www.npmjs.com/package/react-masonry-component2 直接使用。

更多思考

当瀑布流数据特别多时,dom 节点过多,会影响到页面性能,那么就需要为瀑布流添加滚动预加载和节点回收功能来进行优化了,在下个版本中将更新滚动预加载和节点回收功能的实现原理。

· 14 min read

背景

前端技术的不断发展过程中,组件化、模块化已成为主流。

当开发的项目中有一些公共组件可以沉淀的时候,将这些组件抽离出来,开发一个组件库无疑是一个好的选择。

那么怎么去开发一个组件库呢?本文将和你一起从零开发一个 React 组件库。

一、搭建项目

组件库的第一步是搭建项目,选择合适的技术,并制定代码规范。

1. 技术选型

1.1 前端框架

前端框架的选择不用多说,大家都是选择日常开发中使用到的框架,本文使用的是 React。

1.2 组件库工具

组件库工具,市面上比较流行的 2 个组件库工具分别的 dumi 和 Storybook。

dumi,是一款为组件开发场景而生的文档工具,与  father  一起为开发者提供一站式的组件开发体验,father 负责构建,而 dumi 负责组件开发及组件文档生成

Storybook 是一个用于单独构建 UI 组件和页面的前端工具。成千上万的团队将它用于 UI 开发、测试和文档。它是开源和免费的。

dumi 和 Storybook 都是专用于组件开发场景的工具,由于 Storybook 更加支持测试难以到达的状态和边缘案例,因此最终选择 Storybook 来开发组件库。

2. 快速开始

2.1 creat-react-app

使用 creat-react-app 创建一个支持 TypeScript 的 React 项目。

npx create-react-app my-react-component --template typescript

2.2 Storybook

Storybook 教程:https://storybook.js.org/

为 React 项目添加 Storybook 能力。

cd ./my-react-component
npx storybook init

此时通过 yarn storybook,将在本地启动 Storybook 并输出地址。根据您的系统配置,它会自动在新的浏览器选项卡中打开地址,然后您会看到一个欢迎屏幕。

3. 代码规范

3.1 Prettier

Prettier 是一个代码格式化工具,可以让团队的代码风格保持一致。可支持的源码类型包括:JavaScript、JSX、Angular、Vue、Flow、TypeScript、CSS、HTML、JSON、YAML 等等。

安装:

yarn add prettier -D

项目根目录下添加配置文件 .prettierrc

{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"vueIndentScriptAndStyle": false
}

修改 packages.json

"scripts": {
"prettier": "prettier src --write",
}

运行 yarn prettier 将会格式化 src 目录下所有文件的代码样式。

3.2 ESlint

ESLint 用于检测 JS 代码,发现代码质量问题并修复问题,还可以自己根据项目需要进行规则的自定义配置以及检查范围等等。

安装:

yarn add eslint eslint-plugin-react eslint-plugin-simple-import-sort eslint-plugin-unused-imports @typescript-eslint/eslint-plugin @typescript-eslint/parser -D

项目根目录下添加配置文件 .eslintrc.js

module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
],
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: [
"react",
"@typescript-eslint",
"unused-imports",
"simple-import-sort",
],
rules: {
"no-unused-vars": "off", // or "@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "warn",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
},
};

修改 packages.json

"scripts": {
"eslint": "eslint src --fix",
}

运行 yarn eslint 将会检测 src 下所有 js、ts、jsx、tsx 的语法及样式问题并进行修复。

3.3 lint-staged

lint-staged  相当于一个文件过滤器,每次提交时只检查本次提交的暂存区的文件,它不能格式化代码和校验文件,需要自己配置一下,如:.eslintrc.stylelintrc  等,然后在  package.json  中引入。

安装:

yarn add lint-staged -D

项目根目录下添加配置文件 .lintstagedrc:

{
"src/**/*.tsx": ["prettier --write", "eslint --fix"],
"src/**/*.scss": ["prettier --write"],
"src/**/*.mdx": ["prettier --write"],
"src/**/*.md": ["prettier --write"]
}

修改 packages.json

"scripts": {
"ling-staged": "ling-staged",
}

运行 yarn lint-staged 将对 git 暂存区所有文件执行 .lintstagedrc 中配置的命令。

3.4 husky

husky  工具可以定义拦截  git  钩子,对提交的文件和信息做校验和自动修复。

安装:

yarn add husky -D

修改 packages.json

"scripts": {
"prepare": "husky install",
}

初始化 husky 配置文件:

yarn prepare

初始化 husky 配置文件后根目录会生成以下目录:

image.png

.husky 下新增配置文件 pre-commit

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx --no-install lint-staged

git commit 之前,将会自动执行上面 pre-commit 脚本配置的命令。

3.5 commitlint

commitlint 是一个 git commit 信息校验工具。

安装:

yarn add commitlint @commitlint/config-conventional -D

项目根目录下添加配置文件 .commitlintrc.js

module.exports = {
extends: ["@commitlint/config-conventional"],
};

.husky 下新增配置文件 commit-msg

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx --no-install commitlint --edit $1

git commit-msg 钩子函数触发时,将会自动执行 commit-msg 脚本配置的命令,校验 commit msg 是否符合规范。

4. 新增组件

src 目录下新增组件:

image.png

每个组件包含 4 个基础文件:

  • [component-name].tsx
  • [component-name].scss
  • index.ts
  • [component-name].stories.mdx

下文将举例瀑布流组件源码:

完整的瀑布流组件代码地址:https://github.com/jiaozitang/react-masonry-component2/tree/dev/src/Masonry

4.1 masonry.tsx

React 组件。

import React from "react";

import { DEFAULT_COLUMNS_COUNT_POINTS, MasonryDirection } from "./const";
import { useColumnCount } from "./hooks";
import MasonryAbsolute from "./masonry-absolute";
import MasonryColumn from "./masonry-column";
import MasonryFlex from "./masonry-flex";

export interface MasonryProps extends React.HTMLAttributes<HTMLElement> {
/** 排列方向 */
direction?: "row" | "column";
sortWithHeight?: boolean; // 是否需要按高度排序
useAbsolute?: boolean; // 是否开启绝对定位方法实现瀑布流,该模式默认开始按高度排序
columnsCountBreakPoints?: {
// 自适应的配置
[props: number]: number;
};
children?: React.ReactNode;
className?: string;
style?: Record<string, any>;
gutter?: number;
}

const Masonry: React.FC<MasonryProps> = (props) => {
const {
direction = MasonryDirection.row,
columnsCountBreakPoints = DEFAULT_COLUMNS_COUNT_POINTS,
useAbsolute,
} = props;
const columnCount = useColumnCount(columnsCountBreakPoints);

if (useAbsolute) {
return <MasonryAbsolute {...props} columnCount={columnCount} />;
}
if (direction === MasonryDirection.column) {
return <MasonryColumn {...props} columnCount={columnCount} />;
}
if (direction === MasonryDirection.row) {
return <MasonryFlex {...props} columnCount={columnCount} />;
}
return <div></div>;
};

export default Masonry;

4.2 masonry.scss

组件的样式文件。

4.3 index.ts

组件需要导出的内容。

import Masonry from "./masonry";
import { MasonryAbsoluteItem, MasonryItem } from "./masonry-item";

export { MasonryAbsoluteItem, MasonryItem };
export type { MasonryProps } from "./masonry";
export default Masonry;

4.4 masonry.stories.mdx

组件案例,Storybook 特定语法。

Storybook 教程:https://storybook.js.org/

image.png

组件案例在 yarn storybook 后可以在线查看效果:

image.png

image.png

也可以通过 Storybook 官方提供的工具发布成一个在线的文档地址,详细的发布教程在第三章节将会介绍。

二、打包组件库

1. 技术选型

比较热门的打包工具有 Webpack、rollup。

Webpack 对于代码分割和静态资源导入有着“先天优势”,并且支持热模块替换(HMR),而 Rollup 并不支持,所以当项目需要用到以上,则可以考虑选择 Webpack。但是,Rollup 对于代码的 Tree-shaking 和 ES6 模块有着算法优势上的支持,若你项目只需要打包出一个简单的 bundle 包,并是基于 ES6 模块开发的,可以考虑使用 Rollup。

因此组件库打包工具选择 rollup。

更详细的 rollup 使用教程见我的另一篇博客:【实战篇】最详细的 Rollup 打包项目教程

2. 快速开始

2.1 安装

npm i rollup -g

yarn add @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-strip @rollup/plugin-typescript rollup-plugin-postcss rollup-plugin-node-externals autoprefixer -D

2.2 打包配置

项目根目录下新增配置文件 rollup.config.js

import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import strip from "@rollup/plugin-strip";
import typescript from "@rollup/plugin-typescript";
import autoprefixer from "autoprefixer";
import path from "path";
import externals from "rollup-plugin-node-externals";
import postcss from "rollup-plugin-postcss";

import pkg from "./package.json";

export default [
{
input: "./src/index.ts", // 入口文件
output: [
{
// 出口文件
dir: path.dirname(pkg.module),
format: "es", // es模块导出,支持按需加载
name: pkg.name,
exports: "named", // 指定导出模式(自动、默认、命名、无)
preserveModules: true, // 保留模块结构
preserveModulesRoot: "src", // 将保留的模块放在根级别的此路径下
},
],
plugins: [
// 自动将dependencies依赖声明为 externals
externals({
devDeps: false,
}),
// 处理外部依赖
resolve(),
// 支持基于 CommonJS 模块引入
commonjs(),
// 支持 typescript,并导出声明文件
typescript({
outDir: "es",
declaration: true,
declarationDir: "es",
}),
// 支持 scss,并添加前缀
postcss({
plugins: [autoprefixer()],
}),
// 清除调试代码
strip(),
],
},
];

2.3 入口文件

新增文件 src/index.ts

export {
default as Masonry,
MasonryAbsoluteItem,
MasonryItem,
} from "./masonry";
export type { MasonryProps } from "./masonry";

2.4 打包命令

修改 packages.json

"scripts": {
"build": "rimraf es && rollup -c",
}

打包产物如图所示:

image.png

三、发布组件库文档网站

Storybook 文档发布教程地址:https://storybook.js.org/docs/react/sharing/publish-storybook#gatsby-focus-wrapper

  1. 安装 chromatic
yarn add --dev chromatic
  1. 发布 Storybook

注意:确保your-project-token用您自己的项目令牌替换。

npx chromatic --project-token=<your-project-token>

然后就得到了一个线上的组件库文档网站:https://632339a3ed0b247d36b0fa3c-njrsmzdcdj.chromatic.com/?path=/story/%E4%BB%8B%E7%BB%8D--page

四、发布项目

1. 注册 npm

如已注册可跳过该步骤。

注册帮助文档:https://docs.npmjs.com/creating-a-new-npm-user-account

2. 登录 npm

进入项目根目录,并登录:

npm login

如果已经登录过,可以查看登录过的账号是否是期望的账号:

npm whoami

3. 开源证书

项目根目录下新增 LICENCE.md

注意:替换[npm username]为你刚刚登录的 username。

The MIT License (MIT)

Copyright (c) [npm username]

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

4. 更新 npm 包数据

更新 packages.json

注意:

  • 确认 name 未被注册过,如果已被注册过将无法发布成功;
  • module、types 需要和 rollup 配置的输出路径一致。
"name": "xx",
"version": "1.0.3",
"author": {
"name": "xx",
"email": "xx"
},
"description": "xx",
"homepage": "https://github.com/xx",
"keywords": [
"react",
"masonry",
"css",
"flexbox",
"responsive",
"absolute",
"column"
],
"license": "MIT",
"module": "es/index.js",
"types": "es/index.d.ts",
"files": [
"es"
],

5. 发布

更新 packages.json:

"version": "1.0.8",

发布:

npm publish

五、调试项目

项目发布成功后,如果有问题,可以通过 yarn link 进行调试,确认没问题后再发布版本。

link 的本质就是软链接,这样可以让我们快速使用本地正在开发的其它包。

假设组件库仓库为项目 A,使用组件库的仓库为项目 B。

在项目 A 下运行 yarn link,在项目 B 下运行 yarn link A,就可以实时调试项目 A 了。

小结

本文是我个人在实际开发中沉淀 React 组件库的一次小结,不是一个完美的组件库,但是也足够日常开发使用。感兴趣的朋友可以跟着一起敲一遍,发布一个属于自己的组件库。

往期精彩

参考资料

· 21 min read

本文所有参考资料来自《TypeScript 类型体操通关秘籍》,想了解更加全面的类型体操知识可前往学习。

今天给大家分享的主题是一起来做类型体操。

主要分为 4 个部分进行介绍:

  1. 类型体操的背景,通过背景了解为什么要在项目中加入类型体操;
  2. 了解类型体操的主要类型、运算逻辑、和类型套路;
  3. 类型体操实践,解析 Typescript 内置高级类型,手写 ParseQueryString 复杂类型;
  4. 小结,综上分享,沉淀结论。

一、背景

在背景章节介绍的是什么是类型,什么是类型安全,怎么实现类型安全,什么是类型体操?

以了解类型体操的意义。

1. 什么是类型?

了解什么是类型之前,先来介绍两个概念:

  • 不同类型变量占据的内存大小不同

boolean 类型的变量会分配 4 个字节的内存,而 number 类型的变量则会分配 8 个字节的内存,给变量声明了不同的类型就代表了会占据不同的内存空间。

  • 不同类型变量可做的操作不同

number 类型可以做加减乘除等运算,boolean 就不可以,复合类型中不同类型的对象可用的方法不同,比如 Date 和 RegExp,变量的类型不同代表可以对该变量做的操作就不同。

综上,可以得到一个简单的结论就是,类型就是编程语言提供对不同内容的抽象定义

2. 什么是类型安全?

了解了类型的概念后,那么,什么是类型安全呢?

一个简单的定义就是,类型安全就是只做该类型允许的操作。比如对于 boolean 类型,不允许加减乘除运算,只允许赋值 true、false。

当我们能做到类型安全时,可以大量的减少代码中潜在的问题,大量提高代码质量。

3. 怎么实现类型安全?

那么,怎么做到类型安全?

这里介绍两种类型检查机制,分别是动态类型检查和静态类型检查。

3.1 动态类型检查

Javascript 就是典型的动态类型检查,它在编译时,没有类型信息,到运行时才检查,导致很多隐藏 bug。

3.2 静态类型检查

Typescript 作为 Javascript 的超集,采用的是静态类型检查,在编译时就有类型信息,检查类型问题,减少运行时的潜在问题。

image.png

4. 什么是类型体操

上面介绍了类型的一些定义,都是大家熟悉的一些关于类型的背景介绍,这一章节回归到本次分享的主题概念,类型体操。

了解类型体操前,先介绍 3 种类型系统。

4.1 简单类型系统

简单类型系统,它只基于声明的类型做检查,比如一个加法函数,可以加整数也可以加小数,但在简单类型系统中,需要声明 2 个函数来做这件事情。

int add(int a, int b) {
return a + b
}

double add(double a, double b) {
return a + b
}

image.png

4.2 泛型类型系统

泛型类型系统,它支持类型参数,通过给参数传参,可以动态定义类型,让类型更加灵活。

T add<T>(T a, T b) {
return a + b
}

add(1, 2)
add(1.1, 2.2)

但是在一些需要类型参数逻辑运算的场景就不适用了,比如一个返回对象某个属性值的函数类型。

function getPropValue<T>(obj: T, key) {
return obj[key]
}

image.png

4.3 类型编程系统

类型编程系统,它不仅支持类型参数,还能给类型参数做各种逻辑运算,比如上面提到的返回对象某个属性值的函数类型,可以通过 keyof、T[K] 来逻辑运算得到函数类型。

image.png

总结上述,类型体操就是类型编程,对类型参数做各种逻辑运算,以产生新的类型

之所以称之为体操,是因为它的复杂度,右侧是一个解析参数的函数类型,里面用到了很多复杂的逻辑运算,等先介绍了类型编程的运算方法后,再来解析这个类型的实现。

image.png

二、了解类型体操

熟悉完类型体操的概念后,再来继续了解类型体操有哪些类型,支持哪些运算逻辑,有哪些运算套路。

1. 有哪些类型

类型体操的主要类型列举在图中。Typescript 复用了 JS 的基础类型和复合类型,并新增元组(Tuple)、接口(Interface)、枚举(Enum)等类型,这些类型在日常开发过程中类型声明应该都很常用,不做赘述。

image.png

2. 运算逻辑

重点介绍的是类型编程支持的运算逻辑。

TypeScript 支持条件、推导、联合、交叉、对联合类型做映射等 9 种运算逻辑。

image.png

  • 条件:T extends U ? X : Y

条件判断和 js 逻辑相同,都是如果满足条件就返回 a 否则返回 b。

// 条件:extends ? :
// 如果 T 是 2 的子类型,那么类型是 true,否则类型是 false。
type isTwo<T> = T extends 2 ? true : false;
// false
type res = isTwo<1>;
  • 约束:extends

通过约束语法 extends 限制类型。

// 通过 T extends Length 约束了 T 的类型,必须是包含 length 属性,且 length 的类型必须是 number。
interface Length {
length: number
}

function fn1<T extends Length>(arg: T): number{
return arg.length
}
  • 推导:infer

推导则是类似 js 的正则匹配,都满足公式条件时,可以提取公式中的变量,直接返回或者再次加工都可以。

// 推导:infer
// 提取元组类型的第一个元素:
// extends 约束类型参数只能是数组类型,因为不知道数组元素的具体类型,所以用 unknown。
// extends 判断类型参数 T 是不是 [infer F, ...infer R] 的子类型,如果是就返回 F 变量,如果不是就不返回
type First<T extends unknown[]> = T extends [infer F, ...infer R] ? F : never;
// 1
type res2 = First<[1, 2, 3]>;
  • 联合:|

联合代表可以是几个类型之一。

type Union = 1 | 2 | 3
  • 交叉:&

交叉代表对类型做合并。

type ObjType = { a: number } & { c: boolean }
  • 索引查询:keyof T

keyof 用于获取某种类型的所有键,其返回值是联合类型。

// const a: 'name' | 'age' = 'name'
const a: keyof {
name: string,
age: number
} = 'name'
  • 索引访问:T[K]

T[K] 用于访问索引,得到索引对应的值的联合类型。

interface I3 {
name: string,
age: number
}

type T6 = I3[keyof I3] // string | number

  • 索引遍历: in

in 用于遍历联合类型。

const obj = {
name: 'tj',
age: 11
}

type T5 = {
[P in keyof typeof obj]: any
}

/*
{
name: any,
age: any
}
*/
  • 索引重映射: as

as 用于修改映射类型的 key。

// 通过索引查询 keyof,索引访问 t[k],索引遍历 in,索引重映射 as,返回全新的 key、value 构成的新的映射类型
type MapType<T> = {
[
Key in keyof T
as `${Key & string}${Key & string}${Key & string}`
]: [T[Key], T[Key], T[Key]]
}
// {
// aaa: [1, 1, 1];
// bbb: [2, 2, 2];
// }
type res3 = MapType<{ a: 1, b: 2 }>

3. 运算套路

根据上面介绍的 9 种运算逻辑,我总结了 4 个类型套路。

  • 模式匹配做提取;
  • 重新构造做变换;
  • 递归复用做循环;
  • 数组长度做计数。

3.1 模式匹配做提取

第一个类型套路是模式匹配做提取。

模式匹配做提取的意思是通过类型 extends 一个模式类型,把需要提取的部分放到通过 infer 声明的局部变量里。

举个例子,用模式匹配提取函数参数类型。

type GetParameters<Func extends Function> =
Func extends (...args: infer Args) => unknown ? Args : never;

type ParametersResult = GetParameters<(name: string, age: number) => string>

首先用 extends 限制类型参数必须是 Function 类型。

然后用 extends 为 参数类型匹配公式,当满足公式时,提取公式中的变量 Args。

实现函数参数类型的提取。

3.2 重新构造做变换

第二个类型套路是重新构造做变换。

重新构造做变换的意思是想要变化就需要重新构造新的类型,并且可以在构造新类型的过程中对原类型做一些过滤和变换。

比如实现一个字符串类型的重新构造。

type CapitalizeStr<Str extends string> =
Str extends `${infer First}${infer Rest}`
? `${Uppercase<First>}${Rest}` : Str;

type CapitalizeResult = CapitalizeStr<'tang'>

首先限制参数类型必须是字符串类型。

然后用 extends 为参数类型匹配公式,提取公式中的变量 First Rest,并通过 Uppercase 封装。

实现了首字母大写的字符串字面量类型。

image.png

3.3 递归复用做循环

第三个类型套路是递归复用做循环。

Typescript 本身不支持循环,但是可以通过递归完成不确定数量的类型编程,达到循环的效果。

比如通过递归实现数组类型反转。

type ReverseArr<Arr extends unknown[]> =
Arr extends [infer First, ...infer Rest]
? [...ReverseArr<Rest>, First]
: Arr;


type ReverseArrResult = ReverseArr<[1, 2, 3, 4, 5]>

首先限制参数必须是数组类型。

然后用 extends 匹配公式,如果满足条件,则调用自身,否则直接返回。

实现了一个数组反转类型。

3.4 数组长度做计数

第四个类型套路是数组长度做计数。

类型编程本身是不支持做加减乘除运算的,但是可以通过递归构造指定长度的数组,然后取数组长度的方式来完成数值的加减乘除。

比如通过数组长度实现类型编程的加法运算。

type BuildArray<
Length extends number,
Ele = unknown,
Arr extends unknown[] = []
> = Arr['length'] extends Length
? Arr
: BuildArray<Length, Ele, [...Arr, Ele]>;

type Add<Num1 extends number, Num2 extends number> =
[...BuildArray<Num1>, ...BuildArray<Num2>]['length'];


type AddResult = Add<32, 25>

首先通过递归创建一个可以生成任意长度的数组类型

然后创建一个加法类型,通过数组的长度来实现加法运算。

image.png

三、类型体操实践

分享的第三部分是类型体操实践。

前面分享了类型体操的概念及常用的运算逻辑。

下面我们就用这些运算逻辑来解析 Typescript 内置的高级类型。

1. 解析 Typescript 内置高级类型

  • partial 把索引变为可选

通过 in 操作符遍历索引,为所有索引添加 ?前缀实现把索引变为可选的新的映射类型。

type TPartial<T> = {
[P in keyof T]?: T[P];
};

type PartialRes = TPartial<{ name: 'aa', age: 18 }>
  • Required 把索引变为必选

通过 in 操作符遍历索引,为所有索引删除 ?前缀实现把索引变为必选的新的映射类型。

type TRequired<T> = {
[P in keyof T]-?: T[P]
}

type RequiredRes = TRequired<{ name?: 'aa', age?: 18 }>
  • Readonly 把索引变为只读

通过 in 操作符遍历索引,为所有索引添加 readonly 前缀实现把索引变为只读的新的映射类型。

type TReadonly<T> = {
readonly [P in keyof T]: T[P]
}

type ReadonlyRes = TReadonly<{ name?: 'aa', age?: 18 }>
  • Pick 保留过滤索引

首先限制第二个参数必须是对象的 key 值,然后通过 in 操作符遍历第二个参数,生成新的映射类型实现。

type TPick<T, K extends keyof T> = {
[P in K]: T[P]
}

type PickRes = TPick<{ name?: 'aa', age?: 18 }, 'name'>
  • Record 创建映射类型

通过 in 操作符遍历联合类型 K,创建新的映射类型。

type TRecord<K extends keyof any, T> = {
[P in K]: T
}

type RecordRes = TRecord<'aa' | 'bb', string>
  • Exclude 删除联合类型的一部分

通过 extends 操作符,判断参数 1 能否赋值给参数 2,如果可以则返回 never,以此删除联合类型的一部分。

type TExclude<T, U> = T extends U ? never : T

type ExcludeRes = TExclude<'aa' | 'bb', 'aa'>

image.png

  • Extract 保留联合类型的一部分

和 Exclude 逻辑相反,判断参数 1 能否赋值给参数 2,如果不可以则返回 never,以此保留联合类型的一部分。

type TExtract<T, U> = T extends U ? T : never

type ExtractRes = TExtract<'aa' | 'bb', 'aa'>
  • Omit 删除过滤索引

通过高级类型 Pick、Exclude 组合,删除过滤索引。

type TOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

type OmitRes = TOmit<{ name: 'aa', age: 18 }, 'name'>
  • Awaited 用于获取 Promise 的 valueType

通过递归来获取未知层级的 Promise 的 value 类型。

type TAwaited<T> =
T extends null | undefined
? T
: T extends object & { then(onfulfilled: infer F): any }
? F extends ((value: infer V, ...args: any) => any)
? Awaited<V>
: never
: T;


type AwaitedRes = TAwaited<Promise<Promise<Promise<string>>>>

还有非常多高级类型,实现思路和上面介绍的类型套路大多一致,这里不一一赘述。

2. 解析 ParseQueryString 复杂类型

重点解析的是在背景章节介绍类型体操复杂度,举例说明的解析字符串参数的函数类型。

如图示 demo 所示,这个函数是用于将指定字符串格式解析为对象格式。

function parseQueryString1(queryStr) {
if (!queryStr || !queryStr.length) {
return {}
}
const queryObj = {}
const items = queryStr.split('&')
items.forEach((item) => {
const [key, value] = item.split('=')
if (queryObj[key]) {
if (Array.isArray(queryObj[key])) {
queryObj[key].push(value)
} else {
queryObj[key] = [queryObj[key], value]
}
} else {
queryObj[key] = value
}
})
return queryObj
}

比如获取字符串 a=1&b=2 中 a 的值。

常用的类型声明方式如下图所示:

function parseQueryString1(queryStr: string): Record<string, any> {
if (!queryStr || !queryStr.length) {
return {}
}
const queryObj = {}
const items = queryStr.split('&')
items.forEach((item) => {
const [key, value] = item.split('=')
if (queryObj[key]) {
if (Array.isArray(queryObj[key])) {
queryObj[key].push(value)
} else {
queryObj[key] = [queryObj[key], value]
}
} else {
queryObj[key] = value
}
})
return queryObj
}

参数类型为 string,返回类型为 Record<string, any>,这时看到,res1.a 类型为 any,那么有没有办法,准确的知道 a 的类型是字面量类型 1 呢?

下面就通过类型体操的方式,来重写解析字符串参数的函数类型。

type ParseParam<Param extends string> =
Param extends `${infer Key}=${infer Value}`
? {
[K in Key]: Value
} : Record<string, any>;

type MergeParams<
OneParam extends Record<string, any>,
OtherParam extends Record<string, any>
> = {
readonly [Key in keyof OneParam | keyof OtherParam]:
Key extends keyof OneParam
? OneParam[Key]
: Key extends keyof OtherParam
? OtherParam[Key]
: never
}

type ParseQueryString<Str extends string> =
Str extends `${infer Param}&${infer Rest}`
? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
: ParseParam<Str>;

首先限制参数类型是 string 类型,然后为参数匹配公式 a&b,如果满足公式,将 a 解析为 key value 的映射类型,将 b 递归 ParseQueryString 类型,继续解析,直到不再满足 a&b 公式。

最后,就可以得到一个精准的函数返回类型,res.a = 1

image.png

四、小结

综上分享,从 3 个方面介绍了类型体操。

  • 第一点是类型体操背景,了解了什么是类型,什么是类型安全,怎么实现类型安全;

  • 第二点是熟悉类型体操的主要类型、支持的逻辑运算,并总结了 4 个类型套路;

  • 第三点是类型体操实践,解析了 Typescript 内置高级类型的实现,并手写了一些复杂函数类型。

从中我们了解到需要动态生成类型的场景,必然是要用类型编程做一些运算,即使有的场景下可以不用类型编程,但是使用类型编程能够有更精准的类型提示和检查,减少代码中潜在的问题。

参考资料+源码

这里列举了本次分享的参考资料及示例源码,欢迎大家扩展阅读。

希望能对你有所帮助,感谢阅读~

别忘了点个赞鼓励一下我哦,笔芯 ❤️

· 8 min read

背景

两种最广泛被使用的版本管理工具就是 Git 和 svn,而 Git 优越的版本管理能力,及广被使用的 github、gitlab 开源平台,Git 成为了程序员必须掌握了一个工具。

大家都很熟练的使用着 vscode 等一些可视化工具来操作 Git,对 Git 的很多指令都不甚了解。

殊不知,可视化工具的底层就是 Git 的各个指令,当我们熟悉了 Git 指令的各种写法,将更理解在可视化工具中用到的功能的底层指令。

image.png

Git 工作流程

Git 分为 4 个工作区:

  • 工作区:指在本地仓库中的全部代码区域;
  • 暂存区:指在本地仓库中通过 git add 后的代码区域;
  • 本地仓库:指在本地仓库中的 git commit 后的代码区域;
  • 远程仓库:远程仓库指的托管代码的服务器。

image.png

常用指令

git clone

git clone 命令用于将存储库克隆到本地。

git clone [url] // 将存储库克隆到本地

git init

git init 命令用于在目录中创建新的 Git 仓库。

- git init // 创建新的 Git 仓库,在当前路径下生成 .git 目录

git remote

git remote 用于管理跟踪远程仓库。

git remote -v // 查看连接的远程仓库地址
git remote add origin [gitUrl] // 为本地仓库添加远程仓库地址
git push -u origin master // 将本地仓库的master和远程仓库的master进行关联
git remote origin set-url [gitUrl] // 为本地仓库修改远程仓库地址
git remote rm origin // 为本地仓库删除远程仓库连接

git checkout

git checkout 命令用于切换分支。

git checkout [branchName] // 切换分支
git checkout -b [branchName] // 新建分支并切换到该分支

git branch

git branch 命令用于查看、创建、删除分支。

git branch //查看本地分支
git branch -r //查看远程分支
git branch -a //查看本地和远程分支
git branch [branchName] //新建本地分支但不切换
git branch -D [branchName] //删除本地分支
git branch -m [oldBranchName] [newBranchName] //重新命名分支

git tag

git tag 用于创建、删除、查看标签。

git tag [tagName] // 新建标签
git tag // 查看标签列表
git tag -d [tagName] // 删除标签
git push origin [tagName] // 推送标签到远程仓库

git add

git add 命令用于将本地文件添加到暂存区。

git add [file1] [file2] // 添加指定文件至暂存区
git add [dir] // 添加指定目录至暂存区
git add . // 添加当前目录下所有文件至暂存区
git add -A // 添加当前仓库下的所有文件改动至暂存区

git commit

git commit 命令用于将暂存区内容添加到本地仓库中。

git commit -m 'xxx' // 将暂存区文件添加到本地仓库,并记录下备注
git commit -m 'xxx' -n // 将暂存区文件添加到本地仓库,并记录下备注,同时跳过 husky hooks 设置的规则校验
git commit -am 'xxx' // 将文件添加到暂存区,再添加到本地仓库,并记录下备注

git push

git push 命令用于将本地分支推送到远程仓库。

git push [remoteName] [branchName] // 推送分支
git push --set-upstream [remoteName] [branchName] // 推送分支并建立关联关系

git pull

git pull 命令用于从远程仓库拉取代码并合并到本地当前分支。

git pull // 从远程仓库拉取代码合并到本地,等同于 git fetch && git merge
git pull --rebase // 使用rebase的模式进行合并

git fetch

git fetch 命令用于从远程获取代码库。

git fetch // 从所有远程仓库拉取当前分支代码
git fetch [remoteName] // 从指定远程仓库拉取当前分支代码
git fetch --all // 获取所有远程仓库所有分支的更新

git cherry-pick

git cherry-pick 命令用于获取指定的 commit,可以将分支 a 上的 commit 1,复制到分支 b上。

git cherry-pick [commitId] // 获取指定的commit

git merge

git merge 命令用于分支合并,将其他分支的内容合并到当前分支中。

git merge [branchName]

git rebase

git rebase 用于分支变基。

git rebase master // 将当前分支变基到 master 分支上

image.png

git rebase -i 交互模式:

git rebase -i [commitId] // 基于 commitId 进行 rebase,交互式变基,可以重新编辑 commit,比如压缩合并

image.png

git reset

git reset 命令用于回退版本,可以指定退回某一次提交的版本。

git reset HEAD^ // 回退所有内容到上一个版本
git reset HEAD^ [filename] // 回退某文件到上一个版本
git reset [commitId] // 回退所有内容到指定版本

git reset --soft HEAD~1 // 回退本地仓库到上一个版本
git reset --hard HEAD~1 // 回退本地仓库到上一个版本,并删除工作区所有未提交的修改内容

git revert

git revert 指令用于回滚提交,可以回滚某一次提交记录。

git revert [commitId] // 回滚某次提交
git revert [commitId] -m 1 // 回滚某次 merge 的 commit,1 代表保留主分支代码

git stash

git stash 用于暂存文件。

git stash // 暂存文件
git stash save 'aa' // 暂存文件,添加备注
git stash pop // 应用最近一次暂存文件,并删除暂存记录
git stash apply // 应用最近一次暂存,但不删除该暂存记录
git stash apply stash@{第几次暂存的代码,例如0} // 应用某一次暂存,但不删除该暂存记录;
git stash list // 暂存记录
git stash clear // 删除所有暂存记录

git reflog

git reflog 记录了所有的 commit 操作记录,便于错误操作后找回。

git reflog

git rm

git rm 用于从 git 仓库删除指定文件或目录。

git rm [filname]
git rm [dir]

git log

git log 命令用于查看 git commit 记录。

git log // 查看所有 commit 记录
git log --grep 瀑布流 // 搜索 commit msg 有瀑布流关键字的 记录

参考资料

· 10 min read

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 2 天,点击查看活动详情

一、背景

或许在开发多包的过程中,总会遇到多包的发布工作流问题,详细解释就是一个 npm 包,由多个 npm 相互依赖构成,发布 npm 包 1,对其有依赖的 npm 包 2 也要一同发布,那么怎么才能将这个发布工作流简单化呢?

实现在发布 npm 包 1 时,自动检测依赖 npm 包的其他包,并同时发布,更新版本。

答案大家都知晓,那便是使用多包管理工具 lerna。

但是 lerna 官方文档(lerna 中文文档)不齐全,学习成本甚高,本文欲打造一个最详细的 lerna 手册,帮助查阅指令,熟悉 lerna。

二、lerna 介绍

lerna 是一个优化使用 git 和 npm 管理多包存储库工作流的工具。

它具有以下功能:

  • 自动解决 packages 之间的依赖关系;
  • 通过 git 检测文件改动,自动发布;
  • 根据 git 提交记录,自动生成 CHANGELOG。

三、工作模式

lerna 支持 2 种工作模式,分别是默认模式-Locked modeIndependent mode

默认模式-Locked mode

每次发布,所有有改动的包自动更新版本号,所有包的版本一致,版本号维护在 lerna.json 的 version 中。

Independent mode

每次发布时,将提示每个已更改的包,以及其建议的版本号,每个 package 都有自己的版本号。

设置方式:

lerna init --independent

或修改 lerna.json:

version: "independent"

四、常用指令

1. lerna init

初始化一个 lerna 工程或者升级现有 lerna 项目到当前版本的 lerna。

lerna init

执行成功后,目录下将会生成这样的目录结构。

 - packages(目录)
- lerna.json(配置文件)
- package.json(工程描述文件)

2. lerna create

创建一个 package,指定包名,可指定包位置。

lerna create < name > [location]

lerna create package1
lerna create package1.1 packages/package1

3. lerna add

为包添加依赖。

  • --dev devDependencies 替代 dependencies
  • --exact 安装准确版本,就是安装的包版本前面不带^, Eg: "^2.20.0" ➜ "2.20.0"
lerna add lodash packages/module-1

4. lerna bootstrap

将本地包链接在一起并安装其余的包依赖项。

lerna bootstrap

5. lerna list

列出所有的包。

lerna list

6. lerna import

导入本地已经存在的包。

lerna import [npm 包所在路径]

项目包建立软链,类似 npm link。

lerna link

8. lerna clean

删除所有包的 node_modules 目录。

lerna clean

9. lerna changed

列出自上次更改后已更改的本地包。

lerna changed

10. lerna publish

发布包。

lerna publish

11. lerna diff

区分自上次发布以来的所有包或单个包

lerna diff

12. lerna info

打印有关本地环境的调试信息。

lerna info

13. lerna run

在包含该脚本的每个包中运行 npm 脚本。

lerna run dev

在包含该脚本的指定包中运行 npm 脚本。

lerna run dev --scope=packageA

14. lerna version

更新版本。

lerna version

五、lerna + yarn workspaces 实践

1. 安装

npm install lerna -g

2. 创建项目

mkdir lerna-demo

3. 初始化项目

cd ./lerna-demo
lerna init

其中 package.json & lerna.json 如下:

// package.json
{
"name": "root",
"private": true, // 私有的,不会被发布,是管理整个项目,与要发布到npm的解耦
"devDependencies": {
"lerna": "^4.0.0"
}
}

// lerna.json
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}

4. 启用 yarn workspaces

各个库之间存在依赖,如 A 依赖于 B,因此我们通常需要将 B link 到 A 的 node_module 里,一旦仓库很多的话,手动的管理这些 link 操作负担很大,因此需要自动化的 link 操作,按照拓扑排序将各个依赖进行 link。

启用 yarn workspaces 可以把所有的依赖提升到顶层的 node_modules 中,并且在 node_modules 中链接到本地的 package,自动的帮忙解决安装和 link 问题。

修改 package.json:

{
"private": true, // 只有私有项目可以开启
"workspaces": ["packages/*"]
}

修改 lerna.json:

{
"useWorkspaces": true,
"npmClient": "yarn"
}

5. 添加 package

lerna create packageA
lerna create packageB

6. 添加、删除、清除包依赖

yarn workspaces 添加依赖:

# 将packageA作为packageB的依赖进行安装:
$ yarn workspace packageB add packageA
# 给所有的package安装依赖:
$ yarn workspaces add lodash
# 给root 安装依赖:
$ yarn add -W -D typescript

yarn workspaces 删除依赖:

# 删除packageB的依赖packageA:
$ yarn workspace packageB remove packageA
# 给所有的packages删除依赖:
$ yarn workspaces remove lodash
# 给root 删除依赖:
$ yarn remove -W -D typescript

yarn workspaces 安装全部依赖:

yarn install

yarn install 等价于 lerna bootstrap --npm-client yarn --use-workspaces,把所有的依赖提升到顶层的 node_modules 中,并且在 node_modules 中链接到本地的 package,自动的帮忙解决安装和 link 问题。

yarn workspaces 清除 node_modules:

yarn workspaces run clean

7. 项目构建

构建所有包:

区别于普通项目之处在于各个 package 之间存在相互依赖,如 packageB 只有在 packageA 构建完之后才能进行构建,否则就会出错,这实际上要求我们以一种拓扑排序的规则进行构建。

lerna 支持按照拓扑排序规则执行命令, --sort 参数可以控制以拓扑排序规则执行命令。

# 以拓扑排序规则在包含该脚本的每个包中运行 npm run dev 脚本。
lerna run dev --stream --sort
# 以拓扑排序规则在包含该脚本的每个包中运行 npm run build 脚本。
lerna run build --stream --sort

构建指定包:

# 在 packageA 包中运行 npm run dev 脚本。
lerna run dev --stream --scope=packageA
# 在 packageA 包中运行 npm run build 脚本。
lerna run build --stream --scope=packageA

8. 发布

lerna publish

发布指令 lerna publish 内置以下步骤:

  • 条件验证:包含验证测试是否通过,是否存在未提交的代码,是否在主分支上进行版本发布操作等等条件验证;

  • version_bump:发版的时候需要更新版本号,这时候如何更新版本号就是个问题,一般大家都会遵循  semVer 语义;

  • 生成 changelog:为了方便查看每个 package 每个版本解决了哪些功能,我们需要给每个 package 都生成一份 changelog 方便用户查看各个版本的功能变化;

  • 生成 git tag:为了方便后续回滚问题及问题排查通常需要给每个版本创建一个 git tag;

  • git 发布版本:每次发版我们都需要单独生成一个 commit 记录来标记 milestone;

  • 发布 npm 包:发布完 git 后我们还需要将更新的版本发布到 npm 上,以便外部用户使用。

小结

本文从 5 个方面介绍了 lerna:

  • 背景
  • 介绍
  • 工作模式
  • 常用指令
  • lerna + yarn workspaces 实践

相信你已经很熟悉 lerna 的使用了,下方还列举了参考资料,可以自行扩展阅读。

希望能对你有所帮助,感谢阅读~

别忘了点个赞鼓励一下我哦,笔芯 ❤️

参考资料

· 4 min read

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 1 天,点击查看活动详情

背景

现在有一个需求,是在更新 Input 输入框时,搜索数据。

类似的功能图如下:

image.png

通过 react useEffect,简单的 demo 实现如下:

import { Input } from 'antd'
import { useEffect, useState } from 'react'
import './App.css'

function App() {
const [val, setVal] = useState('')

const onSearch = (val) => {
console.log('搜索', val || '全部')
}

// 当 val 发生变化时,请求搜索数据
useEffect(() => {
onSearch(val)
}, [val])

return (
<div className='App'>
<Input value={val} placeholder='请输入' onChange={(e) => setVal(e.target.value)} allowClear />
</div>
)
}

这时可以看到,首次进入页面,会发起 2 次查询全部的搜索数据请求,然后每次输入框更新,都会发起搜索数据的请求。

image.png

为了优化性能,我们可以在搜索数据时,加入防抖的逻辑,只有当输入操作停顿指定时间后,才发起搜索数据的请求。

lodash debounce + useCallback

引入 lodash 的 debounce 方法。

lodash_.debounce(func, [wait=0], [options=])创建一个 debounced(防抖动)函数,该函数会从上一次被调用后,延迟 wait 毫秒后调用 func 方法。

将 onSearch 方法用 lodash.debounce + useCallback 封装后,可以实现防抖效果。

const onSearch = useCallback(
debounce((val) => {
console.log('搜索', val || '全部')
}, 500),
[]
)

image.png

useDebounceFn

将上述 lodash debounce + useCallback 封装为自定义 Hooks useDebounceFn,useDebounceFn 将返回一个有防抖效果的函数。

useDebounceFn(fn1, options) 返回防抖 Hooks。

interface DebounceOptions {
wait?: number
}

const useDebounceFn = (fn: (...args: any) => any, options: DebounceOptions) => {
return useCallback(debounce(fn, options.wait), [])
}

const onSearch = useDebounceFn(
(val) => {
console.log('搜索', val || '全部')
},
{
wait: 500,
}
)

useDebounce

在 useDebounceFn 基础上,实现 useDebounce,返回一个具有防抖效果的 state。

创建一个新 state,setState 用 useDebounceFn 封装,useDebounce(state, options) 返回防抖 state。

function useDebounce<T>(value: T, options: DebounceOptions) {
const [debounced, setDebounced] = useState(value)

const update = useDebounceFn((value) => {
setDebounced(value)
}, options)

useEffect(() => {
update(value)
}, [value])

return debounced
}

将 useEffect 的依赖项改成 useDebounce 返回的 state,同样可以实现搜索防抖:

const debounceVal = useDebounce(val, { wait: 500 })
const onSearch = (val: string) => {
console.log('搜索', val || '全部')
}

// 当 debounceVal 发生变化时,请求搜索数据
useEffect(() => {
onSearch(debounceVal)
}, [debounceVal])

useDebounceEffect

在 useDebounceFn 基础上,实现 useDebounceEffect,返回一个具有防抖效果的 useEffect。

创建一个新 state,setState 用 useDebounceFn 封装,依赖更新时防抖更新 state,新 state 更新时执行副作用,这时副作用就防抖执行了。

useDebounceEffect(effect, deps, options) 返回防抖 useEffect。

function useDebounceEffect(effect: EffectCallback, deps: DependencyList, options: DebounceOptions) {
const [debounced, setDebounced] = useState({})

const update = useDebounceFn(() => {
setDebounced({})
}, options)

useEffect(() => {
update()
}, deps)

useEffect(effect, [debounced])
}

将 useEffect 改成 useDebounceEffect,就可以实现搜索防抖:

useDebounceEffect(
() => {
onSearch(val)
}
[val],
{ wait: 500 }
)

小结

本文实现了 useDebounceFn、useDebounce、useDebounceEffect 3 种防抖 Hooks,这 3 个 Hooks 可以直接下载 ahooks 使用。

参考资料

· 14 min read

本文翻译自 Tapas Adhikary 的原创文章。


日常开发工作中,我们常用到 console.log() 调试 Javascript,使用 console.log() 需要不断的修改源码来调试,非常麻烦。

本文将介绍另一个可以高效调试 Javascript 的工具 -- 浏览器开发者工具(DevTools)。

下图是一个表单模块,当输出不符合期望时,怎么使用 DevTools 去调试,发现并解决问题呢?

下文将为你一一揭晓。

在线调试代码地址:greet-me-debugging.vercel.app

1_app_error.png

一、了解 Sources 面板

DevTools 提供了许多不同的工具来执行调试任务,包括 DOM 检查、分析和网络调用检查。

首先介绍的是 Sources 面板,它可以帮助我们调试 JavaScript。

你可以通过按键 F12 或使用快捷键打开 DevTools:Control+Shift+I(Windows、Linux)或 Command+Option+I(Mac)。

单击 Sources 选项卡以导航到 Sources 面板。

2_know_source.png图

该 Sources 面板具有三个主要部分。

3_know_source_sections.png图

  1. 文件导航区:页面请求的所有文件都在此处列出;
  2. 编辑区:当你从导航窗格中选择一个文件时,该文件的内容将在此处列出。我们也可以从这里编辑代码;
  3. 调试区: 你会发现这里有很多工具可以用来设置断点、检查变量值、观察变化等。

如果你的 DevTools 窗口较宽或未停靠在单独的窗口中,则调试器部分将显示在代码编辑器窗格的右侧。

4_source_wide.png图

二、设置断点

要开始调试,首先要做的是设置断点。

断点是你希望代码执行暂停以便调试它的逻辑点。

DevTools 允许你以多种不同的方式设置断点。

主要包括以下 4 种方式:

  • 在代码行;
  • 在条件语句中;
  • 在 DOM 节点处;
  • 在事件侦听器上。

1. 在代码行设置断点

设置代码行断点:

  • 单击 Sources tab;
  • 从文件导航区浏览源文件;
  • 转到右侧代码编辑器区中的代码行;
  • 单击行号列以在行上设置断点。

5_line_of_code.png图

这里我们在第 6 行设置了一个断点,代码执行将在这里暂停。

2. 设置条件断点

设置条件断点:

  • 单击 Sources tab;
  • 从文件导航区浏览源文件;
  • 转到右侧代码编辑器区中的代码行;
  • 右键单击行号并选择添加条件断点选项。

6_add_conditional_1.png图

代码行下方会出现一个对话框,开始输入条件。

'6_add_conditional_2.png图'

按 Enter 激活断点,你应该会看到一个橙色图标出现在行号列的顶部。

6_add_conditional_3.png图

print() 执行时,只要满足 name === Joe 条件,代码将暂停执行。

提示:当你知道要调查的特定代码区域时,可以使用条件断点进一步检查以找到问题的根本原因。

3. 在事件监听器上设置断点

在事件监听器上设置断点:

  • 单击 Sources tab;
  • 展开 Event Listener Breakpoints;
  • 从事件监听器列表选择 click 事件来设置断点。

8_Event_listener_breakpoint.png图

4. 在 DOM 节点处设置断点

DevTools 在 DOM 检查和调试方面同样强大。

当在 DOM 中添加、删除或更改某些内容时,你可以设置断点来暂停代码执行。

要在 DOM 更改上设置断点:

  • 单击 Elements 选项卡。
  • 选择要设置断点的元素。
  • 右键单击元素以获取上下文菜单。选择 Break on,然后选择 Subtree modifications、Attribute modifications、Node removal 其中一个。

7_DOM_breakpoint.png图

如上图所示,我们在 div 节点的更改上设置了一个断点,条件是 Subtree 修改。

当问候消息被添加到输出 div 时,代码将暂停执行。

三、逐步执行源代码

现在我们知道了设置断点的所有重要方法,接下来让我们看看如何通过断点来解决问题。

调试器区提供了 5 个控件来逐步执行代码。

9_debug_controls.png图

1. 下一步(快捷键 - F9)

此选项使你能够在 JavaScript 代码执行时逐行执行。如果途中有函数调用,单步执行也会进入函数内部,逐行执行,然后退出。

f9_step.gif图

2. 跳过(快捷键 - F10)

有时,你可能确定某些功能工作正常,不想花时间检查它们。此选项允许你在不单步执行功能的情况下执行该功能。

在下面的示例中,我们跳过了 logger() 函数的执行。

f10_step_over.gif图

3. 进入(快捷键 - F11)

单步执行时,你可能会感觉某个函数的行为异常并想要检查它。使用此选项可以更深入地研究函数。

在下面的示例中,我们正在单步执行函数 logger()

F11_step_into.gif图

4. 跳出(快捷键 – Shift + F11)

在单步执行一个函数时,你可能不想继续并退出它。使用此选项可退出函数。

在下面的示例中,我们进入 logger() 函数内部,然后立即退出。

shift_F11_step_out.gif图

5. 跳转(快捷键 - F8)

有时,你可能希望从一个断点跳转到另一个断点,而无需在其间调试任何代码。使用此选项跳转到下一个断点。

F8_run_jump.gif图

6. 禁用和删除断点

要一次禁用所有断点,请单击“停用断点”按钮(在下方圈出。)

disable_bp.png图

请注意,上述方法不会删除断点。它只是在你需要的时间内停用它们。要激活breakpoints,请再次单击相同的按钮。

你可以通过取消选中复选框从“断点”面板中删除一个或多个断点。你可以通过右键单击并选择 Remove all breakpoints 选项来永久删除所有断点。

11_remove_all_bp.png图

四、检查范围、调用堆栈和值

当你逐行调试时,你可以检查变量的范围和值以及函数调用的调用堆栈。

1. 范围

你可以在 scope 选项中查看全局变量及 this 指向。

9_scope.png图

2. 调用堆栈

调用堆栈面板有助于识别函数执行堆栈。

9_call_stack.png图

3. 值

检查值是识别代码中的错误的主要方法。单步执行时,你只需将鼠标悬停在变量上即可检查值。

在下面的示例中,我们选择变量 name 以在代码执行阶段检查其值。

9_see_values.png图

此外,你可以选择代码的一部分作为表达式来检查值。在下面的示例中,我们选择了一个表达式 document.getElementById('m_wish') 来检查值。

9_see_values_2.png图

4. Watch

Watch 选项使你能够添加一个或多个表达式并在执行时观察它们的值。当你想要在代码逻辑之外进行一些计算时,此功能非常有用。

你可以组合代码区域中的任何变量并形成有效的 JavaScript 表达式。在单步执行时,你将能够看到表达式的值。

以下是添加 Watch 所需的步骤:

  • 单击 Watch 部分上方的 + 图标

10_watch_1.png图

  • 添加要 Watch 的表达式。在这个例子中,我们添加了一个希望观察其值的变量。

10_watch_2.png图

另一种监听表达式的方法是在控制台里输入表达式。请参阅下面的示例以了解如何激活它。

10_watch_3.png图

五、使用 Visual Studio Code 调试 JavaScript

你最喜欢的代码编辑器是什么?就个人而言,我喜欢 Visual Studio 代码,因为它很简单。只需几个简单的步骤,我们就可以使用 VS Code 启用类似的调试环境。

1. 用于调试的 VS Code 设置

VS Code 支持安装插件来启用各种特性和功能。

要启用 JavaScript 调试,你需要安装一个名为 Debugger for Chrome 的插件。

你可以在 VS Code 的 Extensions 面板中搜索此扩展并安装它。

图像.png图

  • 安装后,单击左侧的 Run 选项并创建配置以运行/调试 JavaScript 应用程序。

图像.png图

  • 将创建一个名为 launch.json 的文件,其中包含一些设置信息。它可能看起来像这样:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: <https://go.microsoft.com/fwlink/?linkid=830387>
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug the Greet Me app",
"url": "<http://localhost:5500>",
"webRoot": "${workspaceFolder}"
}
]
}

你可能需要更改以下参数:

  1. name:项目名称。
  2. url:项目在本地运行的 URL。
  3. webRoot:默认值为 ${workspaceFolder},即当前文件夹。你可能希望将其更改为 index.html 等文件所在的入口点文件夹。
  • 最后一步是通过单击左上角的小播放图标开始调试。

图像.png图

2. 了解调试器面板

VS Code 提供了类似 DevTools 的工具来调试 JavaScript。你会发现与我们目前在本文中看到的 Google Chrome JavaScript 调试器有很多相似之处。

以下是你应该注意的主要部分:

  1. 启用调试。按播放按钮启用调试选项。
  2. 用于单步执行断点以及暂停或停止调试的控件。这与我们在 Chrome DevTools 中看到的几乎相似,只是某些键盘快捷键可能有所不同。
  3. 在源代码上设置断点。这是相似的。
  4. 范围面板查看变量范围和值。这些在两种情况下都是完全一样的。
  5. 用于创建和监视表达式的监视面板。
  6. 执行函数的调用栈。
  7. 要启用、禁用和删除的断点列表。
  8. 调试控制台读取控制台日志消息。

vs_code_frame.png图

3. 快速演示

这是一个快速演示(1 分钟),展示了 VS Code 调试控件的用法:https://www.youtube.com/watch?v=xKkrKS77PIY

总结

总结一下:

  • 使用工具来调试 JavaScript 代码总是更好。像 Google ChromeDevTools 或 VS Code 调试器扩展这样的工具比仅仅依靠 console.log() 调试效率更高;
  • DevToolsSource Panel 非常强大,能够检查变量值、观察表达式、理解范围、读取调用堆栈等;
  • 有几种设置方式断点,我们应该根据调试情况使用它们;
  • VS Code debugger 扩展程序功能非常强大。

往期精彩

· 6 min read

本文分享一些不常见但很有用的JavaScript 调试技巧,能够有效提高浏览器开发工具的使用效率。

一、元素面板

首先介绍的是 Elements 面板。

元素.png

1. 重新排列元素的位置

可以拖放元素以在位置上上下移动,可用于编辑/调试 HTML 结构。

dom_move.gif

2. 在元素面板中引用节点

可以通过 $0 调试元素面板选中的 DOM 节点。

注意:如果你在你的项目中使用 jQuery,你可以使用$($0)jQuery API 来访问和应用这个元素。

参考元素.gif

3. 用一个 DOM 节点做很多事情,比如截屏?

可以在不退出调试器工具的情况下截取 DOM 节点的屏幕截图。

选择一个节点按下 ctrl-shift-p(Mac 快捷键),输入 screen 搜索截图功能,完成 DOM 节点的屏幕截图。

截图.gif

同时,按下 ctrl-shift-p 后还有很多功能可以使用,可以自行探索。

二、控制台面板

接下来介绍的是 console 面板的使用技巧:

cosnole.png

1. 多行 console

按住shift-enter以继续执行每一行,完成后,按enter键,可以实现多行日志。

多行控制台.gif

2. 控制台日志格式化

除了 console.log('Hi'),还有一些更有用的格式化版本:

  • %s 将变量格式化为字符串;
  • %d 将变量格式化为整数;
  • %f 将变量格式化为浮点数;
  • %o 可用于打印 DOM 元素;
  • %O 用于打印对象表示;
  • %c 用于传递 CSS 来格式化字符串。

在控制台面板中下列代码:

console.log(
'%c I have %d %s',
'color: green; background:black; font-size: 20pt',
3,
'Bikes!'
)

输出如下:

格式控制台.png

3. 存储为全局变量

可以将 JSON 对象的任何部分保存为可在控制台面板中访问的全局变量:

global_var_console.gif

4. 控制台面板中的高级日志记录

4.1 console.dir

console.log(['Apple', 'Orange]);

输出:

高级日志1.png

console.dir(['Apple', 'Orange'])

输出与上面几乎相同,但它明确表示类型为Array

高级日志2.png

4.2 console.table

console.table 会在控制台中打印一个表格。

当您处理复杂的对象时,只需将其打印为 table 即可。

看看它的实际效果:

控制台表.gif

5.保存控制台日志

只需选中图示复选框,即可在导航到其他页面时保留控制台中的日志:

保存日志.gif

6. console.group

有时,保持日志松散会导致调试延迟。

console.group 可以将所有日志组合在一起。

console.group('Testing my calc function');
console.log('adding 1 + 1 is', 1 + 1);
console.log('adding 1 - 1 is', 1 - 1);
console.log('adding 2 * 3 is', 2 * 3);
console.log('adding 10 / 2 is', 10 / 2);
console.groupEnd();
`

输出是一个分组的日志:

分组日志.png

7. console.time

console.time 可以测量执行一段代码需要多长时间。

function test time() {
var users= [
{
firstname: "Tapas",
lastname: "Adhikary",
hobby: "Blogging"
},
{
firstname: "David",
lastname: "Williams",
hobby: "Chess"
},
{
firstname: "Brad",
lastname: "Crets",
hobby: "Swimming"
},
{
firstname: "James",
lastname: "Bond",
hobby: "Spying"
},
{
firstname: "Steve",
lastname: "S",
hobby: "Talking"
}
];

var getName = function (user) {
return user.lastname;
}

// Start the time which will be bound to the string 'loopTime'
console.time("loopTime");

for (let counter = 0; counter < 1000 * 1000 * 1000; counter++) {
getName(users[counter & 4]);
}

// End the time tick for 'loopTime
console.timeEnd("loopTime");
}

从控制台面板或节点环境中运行上述代码后,您将获得如下输出,

loopTime: 2234.032958984375ms

这是计算十亿用户的姓氏所需的总时间(以毫秒为单位)。

8. $_ 获取上一个的执行输出

$_ 可以获取上一个的执行输出,作为输入提供给您的下一个执行逻辑。

last_ref.gif

小结

以上是我整理的一小部分 DevTools 使用技巧。

您可以从 Google 的 Web 开发人员工具 中找到完整使用文档。

往期精彩


本文翻译自 Tapas Adhikary 的原创文章。