` afterwards to install a package and\nsave it as a dependency in the package.json file.\n\nPress ^C at any time to quit.\npackage name: (weapp-utils)\nversion: (1.0.0)\ndescription: some foundmental utils for weapp\nentry point: (lib/index.js)\ntest command:\ngit repository:\nkeywords: weapp,utils\nauthor: tusi666\nlicense: (ISC) MIT\nAbout to write to D:\\robin\\lib\\weapp-utils\\package.json:\n\n{\n \"name\": \"weapp-utils\",\n \"version\": \"1.0.0\",\n \"description\": \"some foundmental utils for weapp\",\n \"main\": \"lib/index.js\",\n \"scripts\": {\n \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n },\n \"keywords\": [\n \"weapp\",\n \"utils\"\n ],\n \"author\": \"tusi666\",\n \"license\": \"MIT\"\n}\n```\n\n其中`main`字段是入口文件\n\n# 写好README\n\n一个完备的`README`文件是必要的,以便别人了解你的包是做什么用途。\n\n# 确认registry\n\n一般我们开发时会修改`npm registry`为`https://registry.npm.taobao.org`。\n\n但是发布`npm`包时,我们需要将其改回来,不然是会报错的。\n\n```shell\nnpm config set registry http://registry.npmjs.org/\n```\n\n# npm注册账号\n\n打开`npm`[官网](https://www.npmjs.com/),开始注册账号。\n\n**ps:**记得要验证邮箱哦!\n\n# 添加npm账户\n\n使用`npm adduser`添加账户,别名`npm login`\n\n```\nD:\\robin\\lib\\weapp-utils>npm adduser\nUsername: tusi666\nPassword:\nEmail: (this IS public) cumtrobin@163.com\nLogged in as tusi666 on https://registry.npm.taobao.org/.\n```\n\n# 添加github仓库\n\n在`package.json`添加配置项,不加也没事,看自己需求。\n\n```json\n\"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/xxx/zqh_test2.git\"\n}\n```\n\n# 发布\n\n```shell\nnpm publish\n```\n\n如果发布时报这样的错,\n\n```shell\nThe operation was rejected by your operating system.\nnpm ERR! It\'s possible that the file was already in use (by a text editor or antivirus),\nnpm ERR! or that you lack permissions to access it.\n```\n\n建议还是检查下`registry`,或者`npm adduser`是不是成功了。\n\n发布成功,会有这样的提示,\n\n```shell\nnpm notice\nnpm notice package: weapp-utils@1.0.0\nnpm notice === Tarball Contents ===\nnpm notice 397B package.json\nnpm notice 1.1kB LICENSE\nnpm notice 2.7kB README.md\nnpm notice 12.9kB lib/index.js\nnpm notice === Tarball Details ===\nnpm notice name: weapp-utils\nnpm notice version: 1.0.0\nnpm notice package size: 5.1 kB\nnpm notice unpacked size: 17.1 kB\nnpm notice shasum: a7f2f428d9334dd1dd749d2a492dbc4df7195d0d\nnpm notice integrity: sha512-Cp8jPhOMq73y6[...]bfofe7X+4cLeg==\nnpm notice total files: 4\nnpm notice\n+ weapp-utils@1.0.0\n```\n\n上`npm`搜索`weapp-utils`,发现有了!\n\n![npm查询到所发布的包](https://qncdn.wbjiang.cn/npm%E6%9F%A5%E8%AF%A2%E5%88%B0%E5%8F%91%E5%B8%83%E7%9A%84%E5%8C%85.png)\n\n# 调用\n\n发布成功了,也要验证下,是否可正常使用。\n\n```javascript\nimport { merge } from \"weapp-utils\"\n\nlet mergedOptions = merge(DEFAULT_OPTIONS, options)\n```', '2019-09-21 15:19:42', '2024-09-02 17:10:24', 1, 48, 0, '本文简单地记录了发布一个简单npm包的过程,以便后续参考使用。', 'https://qncdn.wbjiang.cn/npm%E9%80%9A%E7%94%A8.jpg', 0, 0);
-INSERT INTO `article` VALUES (188, '块级元素和行内元素', '最近给自己定了一个小目标,一周温习一个基础知识点,并输出一篇手记。看自己是否能坚持下去。^_^\n\n# 块级元素\n\n块级元素占据独立的空间,有以下特点:\n\n- 独占一行或多行\n- 宽度,高度,内外边距可以设置,且有效\n- 宽度默认是父容器的100%\n- 可以作为其他块级元素和行内元素的父容器(文本类块级元素不建议作为其他块级元素的容器,如`p, h1~h6`)\n\n![块级元素效果](https://qncdn.wbjiang.cn/%E5%9D%97%E7%BA%A7%E5%85%83%E7%B4%A0%E6%95%88%E6%9E%9C.png)\n\n常见的块级元素有:`div, h1~h6, hgroup, p, table, form, ul, ol, hr, header, main, footer, aside, article, section, video, audio, canvas, pre, option` \n\n# 行内元素\n\n行内元素不占据独立空间,依靠自身内容撑开宽高,与同属一个父容器的其他行内元素在同一行上依次排列,根据`white-space`属性值来决定是否换行。它们具备以下特征:\n\n- 不独占一行,但内容过长时会根据`white-space`控制换行。\n\n- 宽度,高度的设置是无效的。内外边距只能设置左右方向(设置`padding-top`, `padding-bottom`, `margin-top`, `margin-bottom`是无效的)。但是有一点要注意,`padding-top`和`padding-bottom`对自身有表现效果,但是不影响周围元素的布局,看图说话:\n\n ![行内元素上下边距效果](https://qncdn.wbjiang.cn/%E8%A1%8C%E5%86%85%E5%85%83%E7%B4%A0%E4%B8%8A%E4%B8%8B%E5%86%85%E8%BE%B9%E8%B7%9D%E6%95%88%E6%9E%9C.png)\n\n- 宽度由自身内容决定。\n\n- 行内元素不建议作为块级元素的容器(`a`标签例外)\n\n常见的行内元素有:`span, i, code, strong, a, br, sub, sup, label ` \n\n对于不确定的元素,可以设置`width`来测试下,如果`width`不生效,说明是行内元素啦。\n\n# 行内块级元素\n\n行内块级元素也不会独占一行,但是可设置宽高,内外边距等。\n\n常见的行内块级元素有:`input, button, img, select, textarea `\n\n# CSS显示转换\n\n## display: block;\n\n让元素表现为块级元素\n\n## display: inline;\n\n让元素表现为行内元素\n\n## display: inline-block;\n\n让元素表现为行内块级元素\n\n------', '2019-09-26 12:44:51', '2024-09-04 00:52:12', 1, 25, 0, '最近给自己定了一个小目标,一周温习一个基础知识点,并输出一篇手记。看自己是否能坚持下去。^_^', 'https://qncdn.wbjiang.cn/tag_480x270.png', 0, 0);
-INSERT INTO `article` VALUES (189, '耐人寻味的CSS属性white-space', '《耐人寻味的CSS属性white-space》,本文说的`white-space`是一个控制换行和空白处理的`CSS`属性。我曾经被这个属性烦死,一直没记住,今天决定还是写下来好好琢磨下。\n\n# 属性值\n\n## normal\n\n**默认值**,正常换行,空白和换行符会被浏览器忽略。啥意思呢?\n\n- 正常换行的意思是,单词间会正常换行,如果下一个单词太长,不足以在当前行剩余部分完整展示,则会在下一行显示。哪些情况算一个单词呢?\n\n - 一个中文字\n\n - 一个英文单词\n\n ```\n // 这是两个单词\n Tusi Blog\n // 这只算一个单词\n TusiBlog\n ```\n\n - 连续的数字或符号也只算一个单词\n\n ```\n // 这只算一个单词,如果超长也不会换行,会挤出横向滚动条\n 10000000000000000000000+2000000000000000000*200000000000000\n ```\n\n- 空白和换行符会被浏览器忽略。就是你输入连续的空格,只会表现出一个空格的效果;如果敲了回车,也不会换行。\n\n```html\n\n00000000 00000000000000000>
\n\n00000000 00000000000000000\n```\n\n## pre\n\n- 行为方式类似`HTML`中的`pre`标签。`pre`标签一般用来包裹源代码。\n- 不会自动换行(想想,你写代码时,不回车会换行吗?),除非在文本中遇到换行符(敲了回车)或使用了`br`标签。\n- 空白会被浏览器保留。意思就是连续的空格会被保留,不会合并成一个。\n\n![pre效果图](https://qncdn.wbjiang.cn/pre%E6%95%88%E6%9E%9C.png)\n\n## nowrap\n\n- 不换行,内容再多也不换行。\n\n- 忽略换行符,也就是说回车也不会换行,直到遇到`br`标签为止。\n\n## pre-wrap\n\n- 正常换行\n- 连续的空白符会被保留\n- 换行符(回车)也是有效的\n\n## pre-line\n\n- 正常换行\n- 连续空白符会被合并成一个\n- 换行符(回车)也有效\n\n## inherit\n\n继承父元素的`white-space`属性值\n\n# 总结\n\n可以从几个方面来对比下这几种属性值的差异。\n\n| / | 是否正常换行 | 是否合并连续空白符 | 换行符是否有效 |\n| -------- | ------------ | ------------------ | -------------- |\n| normal | 是 | 是 | 否 |\n| pre | 否 | 否 | 是 |\n| nowrap | 否 | 是 | 否 |\n| pre-wrap | 是 | 否 | 是 |\n| pre-line | 是 | 是 | 是 |\n\n妈呀,还是挺难记的,多多复习!', '2019-10-13 11:18:18', '2024-08-08 03:57:45', 1, 37, 0, '《耐人寻味的CSS属性white-space》,本文说的white-space是一个控制换行和空白处理的CSS属性。我曾经被这个属性烦死,一直没记住,今天决定还是写下来好好琢磨下。', 'https://qncdn.wbjiang.cn/css.jpg', 0, 0);
-INSERT INTO `article` VALUES (190, '如何判断IE OCX插件正常安装?', '项目中用到了一个第三方的`ie ocx`控件,而经常遇到客户和测试小伙伴反馈相关功能无法正常使用,也没有友好提示。考虑到这个问题,必须要有一个`ie ocx`控件的检查机制。\n\n# 检查原理\n\n创建`ActiveXObject`对象去检查`ocx`控件\n\n```javascript\nlet newObj = new ActiveXObject(servername, typename[, location]) \n```\n\n# 参数问题\n\n看起来很简单的,但是用起来我懵逼了,应用程序对象名称`servername`这个参数怎么填呢?\n\n插件供应商只提供了控件安装包,示例程序,`clsid`\n\n```html\n \n```\n\n于是我想应该可以从`clsid`入手研究。\n\n## 什么是clsid\n\n> class identifier(类标识符)也称为CLASSID或CLSID,是与某一个类对象相联系的唯一标记(UUID)。一个准备创建多个对象的类对象应将其CLSID注册到系统注册数据库的任务表中,以使客户能够定位并装载与该对象有关的可执行代码。\n\n以上摘自百度百科,可以看到`clsid`跟`uuid`是类似的原理,用来进行插件的唯一标识。\n\n## 根据clsid怎么查到servername\n\n在`MDN`上搜索`ActiveXObject`词条,可以看到这么一句:\n\n> 您可以在`HKEY_CLASSES_ROOT`注册注册表项中识别主机PC上的`servername.typename的`值。\n\n哦,可以看到是从注册表中去查的。于是我运行`regedit`打开注册表查看,虽然知道是在`HKEY_CLASSES_ROOT`目录下,但是这也太多了吧,怎么找得到?\n\n![注册表HKEY_CLASSES_ROOT](https://qncdn.wbjiang.cn/%E6%B3%A8%E5%86%8C%E8%A1%A8HKEY_CLASSES_ROOT.png)\n\n当然还是要靠搜索功能,于是我根据`clsid`的值`27E1A157-6A29-48AE-86C2-14591D90B4D4`进行查找\n\n![搜索clsid](https://qncdn.wbjiang.cn/%E6%90%9C%E7%B4%A2clsid.png)\n\n搜索时间有点长,但是最终还是查到了,位置如下:\n\n`计算机\\HKEY_CLASSES_ROOT\\SDS_CMSCtrl.SDS_CMSCtrlCtrl.1`\n\n![ocx插件在注册表的位置](https://qncdn.wbjiang.cn/ocx%E6%8F%92%E4%BB%B6%E5%9C%A8%E6%B3%A8%E5%86%8C%E8%A1%A8%E7%9A%84%E4%BD%8D%E7%BD%AE.png)\n\n于是我猜想,`servername`应该就是`SDS_CMSCtrl.SDS_CMSCtrlCtrl.1`。经测试,果不其然。检查代码如下:\n\n```javascript\ntry {\n const ocx = new ActiveXObject(\'SDS_CMSCtrl.SDS_CMSCtrlCtrl.1\')\n console.log(ocx)\n} catch (error) {\n this.$alert(\'您还未安装视频插件!\', \'提示\')\n}\n```\n\n这样一来,如果用户没有安装插件,马上能够得到提示,perfect!\n\n![ocx未安装的友好提示](https://qncdn.wbjiang.cn/ocx%E6%9C%AA%E5%AE%89%E8%A3%85%E7%9A%84%E5%8F%8B%E5%A5%BD%E6%8F%90%E7%A4%BA.png)\n\n------', '2019-10-16 13:46:59', '2024-08-05 03:18:27', 1, 18, 0, '项目中用到了一个第三方的ie ocx控件,而经常遇到客户和测试小伙伴反馈相关功能无法正常使用,也没有友好提示。考虑到这个问题,必须要有一个ie ocx控件的检查机制。', 'https://qncdn.wbjiang.cn/ie_600x400.png', 0, 0);
-INSERT INTO `article` VALUES (191, '耐人寻味的CSS属性font-family', '`font-family`是一个网站用户体验的第一入口,非常有必要花功夫来研究一下。我们首先需要了解衬线字体和无衬线字体,接着了解中英文的常用字体及其适用性。\n\n# 衬线字体\n\n衬线(`serif`)的笔画有粗有细的变化,在每一笔画上都自有风格,笔画末端会有修饰,强调艺术感,适合用于博客,旅游,文化,艺术类网站。\n\n# 无衬线字体\n\n无衬线(`sans-serif`)的字体工整方正,给人正式的感觉,适合政务类,企业类网站使用。\n\n# 中文字体\n\n## Windows\n\n- `simsun`,宋体,也是`windows`下大部分浏览器的默认字体,`font-size`较大时清晰度不佳。\n- `Microsoft Yahei`,无衬线字体,微软雅黑,是微软委托中国方正设计的一款中文字体。\n\n## Mac OS\n\n- `STHeiti`,华文黑体,`OS X 10.6`之前的简体中文系统界面默认字体,也是目前`Chrome`游览器下的默认字体。\n- `STXihei`,华文细黑,比`STHeiti`文字更细。\n- `Heiti SC`,黑体-简,从 `OS X 10.6` 开始,黑体-简代替华文黑体用作简体中文系统界面默认字体,显示效果不错,但是喇叭口设计遭人诟病。\n- `Hiragino Sans GB`,冬青黑体,清新的专业印刷字体,小字号时足够清晰,拥有很多人的追捧。\n- `PingFang SC`,苹方,在`Mac OS X EL Capitan`上,苹果为中国用户打造,去掉了为人诟病的喇叭口。\n\n## Linux\n\n- `WenQuanYi Micro Hei`,文泉驿微米黑,`Linux`最佳简体中文字体。\n\n# 英文字体\n\n## Windows\n\n- `Arial`,无衬线西文字体,显示效果一般。\n- `Tahoma`,无衬线字体,显示效果比`Arial`要好。\n- `Verdana`,无衬线字体,优点在于它在小字上仍结构清晰端整、阅读辨识容易。\n\n## Mac OS\n\n- `Times New Roman`,衬线字体,`Mac`平台`Safari`下默认的字体。\n- `Helvetica`、`Helvetica Neue`,被广泛使用。\n- `San Francisco`,与苹方一样,`mac os`最新的西文字体。\n\n# font-family设置原则\n\n- 西文优先:西文字体中大多不包含中文,西文优先,中文紧随其后,这样就不会影响到中文字体的选择。\n- 从新到旧:优先体验最好的字体,向下兼容。\n- 兼容多种操作系统:考虑`windows, mac os, android, linux`等系统。\n- 补充字体族:最后根据衬线`serif`或无衬线`sans-serif`来补充字体族,当所有设置的字体都找不到时,让操作系统有选择字体的方向。\n\n# font-family推荐\n\n```css\nfont-family: \"Helvetica Neue\", Helvetica, \"PingFang SC\", Tahoma, \"Hiragino Sans GB\", \"Heiti SC\", Arial, \"Microsoft YaHei\", \"WenQuanYi Micro Hei\", sans-serif;\n```', '2019-10-23 15:03:49', '2024-08-05 13:30:04', 1, 27, 0, 'font-family是一个网站用户体验的第一入口,非常有必要花功夫来研究一下。我们首先需要了解衬线字体和无衬线字体,接着了解中英文的常用字体及其适用性。', 'https://qncdn.wbjiang.cn/welcome_600x400.png', 0, 0);
-INSERT INTO `article` VALUES (192, 'gradle环境搭建', '最近我在尝试了解跨平台技术的发展,首先则是想到了`cordova`。环境配置过程中有依赖`gradle`,下面简单记录了在`windos10`系统下搭建`gradle`环境的过程。\n\n# 什么是gradle\n\n> Gradle是一个基于Apache Ant和Apache Maven概念的项目自动化构建开源工具。 \n\n# 检查java环境\n\n首先要检查是否正常安装了`java`环境。\n\n```\nC:\\>java -version\njava version \"1.8.0_201\"\nJava(TM) SE Runtime Environment (build 1.8.0_201-b09)\nJava HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)\n```\n\n如果没安装,可以参考[jdk下载与安装简明教程](http://hexo.wbjiang.cn/jdk下载和安装简明教程.html)。\n\n# 下载gradle二进制安装包\n\n[v5.6.3版本下载链接]( https://gradle.org/next-steps/?version=5.6.3&format=bin )\n\n# 安装和环境变量配置\n\n首先在`C`盘新建一个` Gradle `目录,然后将安装包解压到该目录下,最终得到的目录是这样的。\n\n```\nC:\\Gradle\\gradle-5.6.3\n```\n\n然后我们需要给**环境变量-系统变量-Path**新增一项\n\n```\nC:\\Gradle\\gradle-5.6.3\\bin\n```\n\n完成后,即可命令行检查下`gradle`是否安装配置正常。\n\n```\nC:\\>gradle -v\n\n------------------------------------------------------------\nGradle 5.6.3\n------------------------------------------------------------\n\nBuild time: 2019-10-18 00:28:36 UTC\nRevision: bd168bbf5d152c479186a897f2cea494b7875d13\n\nKotlin: 1.3.41\nGroovy: 2.5.4\nAnt: Apache Ant(TM) version 1.9.14 compiled on March 12 2019\nJVM: 1.8.0_201 (Oracle Corporation 25.201-b09)\nOS: Windows 10 10.0 amd64\n```\n\n------', '2019-10-28 14:55:13', '2024-08-08 01:11:08', 1, 28, 0, '最近我在尝试了解跨平台技术的发展,首先则是想到了cordova。环境配置过程中有依赖gradle,下面简单记录了在windos10系统下搭建gradle环境的过程。', 'https://qncdn.wbjiang.cn/gradle.png', 0, 0);
-INSERT INTO `article` VALUES (193, 'cordova开发环境搭建', '最近我在尝试了解跨平台技术的发展,首先则是想到了`cordova`。本文简单记录下`cordova`环境搭建的过程。\n\n# 安装cordova\n\n首先是要`npm`全局安装`cordova`\n\n```shell\nnpm install -g cordova\n```\n\n# 创建应用\n\n安装的`cordova`类似于`create-react-app`这种脚手架,可以通过命令行直接创建应用\n\n```shell\ncordova create myapp\n```\n\n# 添加平台支持\n\n`cordova`可以支持`ios`, `android`, `web`三端。\n\n```shell\ncordova platform add ios\ncordova platform add android\ncordova platform add browser\n```\n\n![cordova platforms](https://qncdn.wbjiang.cn/cordova%E5%B9%B3%E5%8F%B0%E6%94%AF%E6%8C%81.png)\n\n进入`android`目录下,可以看到很多`.java`文件,而`ios`目录下是很多的`object-c`文件,`browser`目录下则是熟悉的`web`工程。\n\n并且可以看到,每个平台下都有一个`cordova`目录,我初步猜想,这应该是负责和不同平台通讯交互的`cordova`核心。\n\n# 运行App\n\n## Web\n\n`web`端是最直观最简单的,直接运行如下命令即可。\n\n```shell\ncordova run browser\n```\n\n## Android\n\n对于`Android`和`IOS`,我们则需要先检查相关环境是否安装正常。\n\n```shell\n$ cordova requirements\n\nRequirements check results for android:\nJava JDK: installed 1.8.0\nAndroid SDK: not installed\nFailed to find \'ANDROID_HOME\' environment variable. Try setting it manually.\nDetected \'adb\' command at C:\\Windows\\system32 but no \'platform-tools\' directory found near.\nTry reinstall Android SDK or update your PATH to include valid path to SDK\\platform-tools directory.\nAndroid target: not installed\nandroid: Command failed with exit code ENOENT Error output:\n\'android\' �����ڲ����ⲿ���Ҳ���ǿ����еij���\n���������ļ���\nGradle: not installed\nCould not find gradle wrapper within Android SDK. Could not find Android SDK directory.\nMight need to install Android SDK or set up \'ANDROID_HOME\' env variable.\n\nRequirements check results for browser:\n\nRequirements check results for ios:\nApple macOS: not installed\nCordova tooling for iOS requires Apple macOS\nSome of requirements check failed\n```\n\n可以看到,我的电脑环境并不满足`android`和`ios`平台的要求。\n\n首先我们来满足下`android`平台的环境要求。\n\n### JDK\n\n首先是`JDK`,可以通过`java`和`javac`命令来检查下。\n\n```shell\nC:\\>java -version\njava version \"1.8.0_201\"\nJava(TM) SE Runtime Environment (build 1.8.0_201-b09)\nJava HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)\n```\n\n如果没安装,可以参考[jdk下载与安装简明教程](http://hexo.wbjiang.cn/jdk下载和安装简明教程.html)。\n\n### Gradle\n\n> Gradle是一个基于Apache Ant和Apache Maven概念的项目自动化构建开源工具。 \n\n具体安装过程可以参考[gradle环境搭建](http://hexo.wbjiang.cn/gradle环境搭建.html)。\n\n### Android SDK\n\n首先我们安装[Android Studio](https://developer.android.google.cn/studio)。根据安装指引,我们会安装好`Android SDK`。\n\n在此安装过程中,遇到了一个报错:\n\n```\nAndroid SDK Tools, SDK Patch Applier v4 and 5 more SDK components were not installed\n```\n\n感谢这位[大佬提供的解决方案](https://blog.csdn.net/qq_36784975/article/details/89096195),迅速解决了问题,这里顺便记下`SDK`的安装目录。\n\n```\nC:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\n```\n\n接着我们需要在**环境变量-系统变量-Path**中新增两项:\n\n```\nC:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\\platform-tools\nC:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\\tools\n\n```\n\n并且新增一项**系统变量ANDROID_HOME**,变量值为:\n\n```\nC:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\n\n```\n\n试运行命令`cordova run android`,出现了如下警告\n\n```\n$ cordova run android\nChecking Java JDK and Android SDK versions\nANDROID_SDK_ROOT=undefined (recommended setting)\nANDROID_HOME=C:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk (DEPRECATED)\nStarting a Gradle Daemon (subsequent builds will be faster)\n\n```\n\n于是我又新增了一项**系统变量ANDROID_SDK_ROOT**,变量值与**ANDROID_HOME**一样。\n\n重新跑`cordova run android`命令,首先看到警告如下:\n\n```\n> Configure project :app\nChecking the license for package Android SDK Platform 28 in C:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\\licenses\nWarning: License for package Android SDK Platform 28 not accepted.\n\n```\n\n上网一查,原来是没有同意相关协议。我们来到`C:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\\tools\\bin`目录下运行命令行,输入`sdkmanager --licenses`,然后就会弹出一堆协议,没办法,无脑输入`y`同意吧。\n\n再次运行`cordova run android`,发现了新的报错信息:\n\n```\nNo target specified and no devices found, deploying to emulator\nNo emulator images (avds) found.\n1. Download desired System Image by running: \"C:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\\tools\\android.bat\" sdk\n2. Create an AVD by running: \"C:\\Users\\Jiang.Wenbin\\AppData\\Local\\Android\\Sdk\\tools\\android.bat\" avd\nHINT: For a faster emulator, use an Intel System Image and install the HAXM device driver\n\n```\n\n可以看到,是没有找到设备的原因。需要将手机连接到`PC`,并且打开开发者选项,允许`USB`调试。再次尝试,已经可以看到界面了。\n\n![cordova app界面](https://qncdn.wbjiang.cn/cordova%20app%E7%95%8C%E9%9D%A2.jpg)\n\n### Plugins\n\n我们来试试调用一些原生`API`,比如调用原生`Dialog`, 调用相机等。我们先试下`Dialog`。\n\n#### Dialog\n\n首先需要插件:\n\n```\ncordova plugin add cordova-plugin-dialogs\n\n```\n\n接着我们在`deviceready`事件之后调用`Dialog`\n\n```javascript\ndocument.addEventListener(\'deviceready\', onDeviceReady, false);\n\nfunction onDeviceReady() {\n navigator.notification.alert(\n \'欢迎欢迎!\', // message\n alertDismissed, // callback\n \'试下Dialog\', // title\n \'好的\' // buttonName\n );\n}\n\nfunction alertDismissed() {\n // 点击按钮后的回调\n}\n\n```\n\n调试后发现弹出的中文乱码了,需要在`index.html`加个`meta`\n\n```html\n \n\n```\n\n![cordova_dialog](https://qncdn.wbjiang.cn/cordova_dialog.png)\n\n#### Camera\n\n接着我们试下调用相机,首先也是安装插件:\n\n```\ncordova plugin add cordova-plugin-camera\n\n```\n\n尝试调用相机拍照,并将得到的照片通过`img`元素显示出来:\n\n```javascript\n// Application Constructor\ninitialize: function() {\n const _this = this;\n document.addEventListener(\'deviceready\', this.onDeviceReady.bind(this), false);\n // 点击按钮打开相机\n document.querySelector(\'#btnOpenCamera\').addEventListener(\'click\', function() {\n _this.openCamera()\n })\n},\nopenCamera: function() {\n const cameraOptions = {\n // 默认输出格式为base64\n destinationType: Camera.DestinationType.DATA_URL,\n // 输出png格式\n encodingType: Camera.EncodingType.PNG\n };\n\n navigator.camera.getPicture(cameraSuccess, cameraError, cameraOptions);\n\n // 相机拍照成功\n function cameraSuccess(base64Str) {\n console.log(base64Str)\n // 给图片元素赋值src\n document.querySelector(\'#captureImg\').src = prefixBase64PNG(base64Str)\n }\n\n function cameraError(err) {\n console.error(err)\n }\n\n function prefixBase64PNG(base64Str) {\n return \'data:image/png;base64,\' + base64Str;\n }\n}\n\n```\n\n效果如下:\n\n![cordova_camera](https://qncdn.wbjiang.cn/cordova_camera.jpg)\n\n## IOS\n\n还没钱买`IOS`设备,尴尬。。。\n\n------', '2019-11-01 14:16:18', '2024-08-02 07:39:06', 1, 31, 0, '最近我在尝试了解跨平台技术的发展,首先则是想到了cordova。本文简单记录下cordova环境搭建的过程。', 'https://qncdn.wbjiang.cn/cordova%E6%9E%B6%E6%9E%84.png', 0, 0);
-INSERT INTO `article` VALUES (194, 'ionic初体验', '搞了一波`cordova`后,算是对`Hybrid`有了一点点微小的认知。为了快速开发,`ionic`无疑是更好的选择,它底层的打包和通信机制基于`cordova`实现,在上层实现了自己的`UI`组件,可以结合`Angular`或`React`使用,并且宣称将在未来支持`Vue`。\n\n# 环境准备\n\n如果已经安装了`cordova`,则单独安装`ionic`即可,否则需要一并安装。\n\n```\nnpm install -g ionic cordova\n```\n\n# 创建项目\n\n通过`start`命令来新建一个`ionic`项目。\n\n```\nionic start my-app\n```\n\n并且可以支持传入模板,以及项目类型,具体参考[ionic start](https://ionicframework.com/docs/cli/commands/start)。\n\n我们在这里创建一个基于`angular`的`tabs`导航的`app`。\n\n```\nionic start myapp tabs --type=ionic-angular\n```\n\n当然也可以直接从一个更完善的模板开始。\n\n```\nionic start myapp super --type=ionic-angular\n```\n\n这几种方式可以都试试看。\n\n# 运行项目\n\n## 在浏览器运行web版\n\n在尝试`npm start`调用`ionic-app-scripts serve`启动项目时,发现报错找不到`@ionic/app-scripts`模块,尝试重新安装该模块,`node-gyp`模块又报了这个错:\n\n```\nError: Can\'t find Python executable \"python\", you can set the PYTHON env variable.\n```\n\n查询[node-gyp]( https://www.npmjs.com/package/node-gyp/v/3.8.0 )后,官方提供了两种解决方案\n\n![解决找不到python模块的问题](https://qncdn.wbjiang.cn/%E8%A7%A3%E5%86%B3%E6%89%BE%E4%B8%8D%E5%88%B0python%E6%A8%A1%E5%9D%97%E7%9A%84%E9%97%AE%E9%A2%98.png)\n\n我采用了第一种方案:\n\n```\nnpm install --global --production windows-build-tools\n```\n\n**ps:** 必须以系统管理员方式运行命令行。\n\n接着重新安装一遍`@ionic/app-scripts`,然后重新运行项目,冇问题啦。\n\n```\nnpm uninstall @ionic/app-scripts\nnpm install --save-dev @ionic/app-scripts\nnpm start\n```\n\n![ionic界面](https://qncdn.wbjiang.cn/ionic%E7%95%8C%E9%9D%A2.png)\n\n## 支持android和ios\n\n```she\nionic cordova platform add ios\nionic cordova platform add android\n```\n\n## android调试\n\n首先检查下设备连接是否正常\n\n```shell\nD:\\robin\\frontend\\hybrid\\ionic\\ionic-blog> adb devices\nList of devices attached\n5fdba1e7 device\n```\n\n使用`ionic cli`提供的命令运行`app`\n\n```shell\n// -l是--livereload的简写\nionic cordova run android -l\n```\n\n此时注意在手机上同意**“继续安装”**,否则是不会成功的。安装成功则可以看到成功的提示。\n\n```\n> cordova.cmd build android --device\n[app-scripts] [16:05:33] lint finished in 3.95 s\n> native-run.cmd android --app platforms\\android\\app\\build\\outputs\\apk\\debug\\app-debug.apk --device --forward 8100:8100 --forward 35729:35729 --forward 53703:53703\n[native-run] Selected hardware device 5fdba1e7\n[native-run] Forwarded device port 35729 to host port 35729\n[native-run] Forwarded device port 8100 to host port 8100\n[native-run] Forwarded device port 53703 to host port 53703\n[native-run] Installing platforms\\android\\app\\build\\outputs\\apk\\debug\\app-debug.apk...\n[native-run] Starting application activity io.ionic.starter/io.ionic.starter.MainActivity...\n[native-run] Run Successful\n```\n\n![ionic界面](https://qncdn.wbjiang.cn/ionic%E7%95%8C%E9%9D%A2.jpg)\n\n此时还可以在`Chrome`浏览器上输入`chrome://inspect`进行调试。\n\n![chrome inspect](https://qncdn.wbjiang.cn/ionic_chrome_inspect.png)\n\n手机上的操作会同步到`Chrome`浏览器上。![ionic远程调试动图](https://qncdn.wbjiang.cn/ionic%E8%BF%9C%E7%A8%8B%E8%B0%83%E8%AF%95%E5%8A%A8%E5%9B%BE.gif)\n\n并且还支持断点调试。\n\n![ionic断点调试](https://qncdn.wbjiang.cn/ionic%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95.png)\n\n## ios调试\n\n`ios`就先不试了,没设备。。。\n\n------', '2019-11-05 18:41:07', '2024-08-09 12:37:13', 1, 43, 0, '体验一把ionic', 'https://qncdn.wbjiang.cn/ionic.svg', 0, 0);
-INSERT INTO `article` VALUES (196, '用初中数学知识撸一个canvas环形进度条', '周末好,今天给大家带来一款接地气的环形进度条组件`vue-awesome-progress`。近日被设计小姐姐要求实现这么一个环形进度条效果,大体由四部分组成,分别是底色圆环,进度弧,环内文字,进度圆点。设计稿截图如下:\n\n![环形进度条设计稿](https://qncdn.wbjiang.cn/%E7%8E%AF%E7%8A%B6%E8%BF%9B%E5%BA%A6%E6%9D%A1%E8%AE%BE%E8%AE%A1%E7%A8%BF.png)\n\n我的第一反应还是找现成的组件,市面上很多组件都实现了前3点,独独没找到能画进度圆点的组件,不然稍加定制也能复用。既然没有现成的组件,只有自己用`vue + canvas`撸一个了。\n\n![我也很无奈啊](https://qncdn.wbjiang.cn/%E6%88%91%E4%B9%9F%E5%BE%88%E6%97%A0%E5%A5%88%E5%95%8A.jpg)\n\n# 效果图\n\n先放个效果图,然后再说下具体实现过程,各位看官且听我慢慢道来。\n\n![环形进度条效果图](https://qncdn.wbjiang.cn/%E7%8E%AF%E5%BD%A2%E8%BF%9B%E5%BA%A6%E6%9D%A1%E6%95%88%E6%9E%9C%E5%9B%BE.gif)\n\n# 安装与使用\n\n[源码地址]( https://github.com/cumt-robin/vue-awesome-progress ),欢迎`star`和提`issue`。\n\n## 安装\n\n```shell\nnpm install --save vue-awesome-progress\n```\n\n## 使用\n\n### 全局注册\n\n```javascript\nimport Vue from \'vue\'\nimport VueAwesomeProgress from \"vue-awesome-progress\"\nVue.use(VueAwesomeProgress)\n```\n\n### 局部使用\n\n```\nimport VueAwesomeProgress from \"vue-awesome-progress\"\n\nexport default {\n components: {\n VueAwesomeProgress\n },\n // 其他代码\n}\n```\n\n### script标签引入组件\n\n同时也支持直接使用`script`标签引入哦,满足有这部分需求的朋友。\n\n```html\n\n\n\n \n \n\n\n
\n \n\n\n```\n\n# 静态展示\n\n任何事都不是一蹴而就的,我们首先来实现一个静态的效果,然后再实现动画效果,甚至是复杂的控制逻辑。\n\n## 确定画布大小\n\n第一步是确定画布大小。从设计稿我们可以直观地看到,整个环形进度条的最外围是由进度圆点确定的,而进度圆点的圆心在圆环圆周上。\n\n![环形进度条模型](https://qncdn.wbjiang.cn/%E7%8E%AF%E5%BD%A2%E8%BF%9B%E5%BA%A6%E6%9D%A1%E6%A8%A1%E5%9E%8B.png)\n\n因此我们得出伪代码如下:\n\n```javascript\n// canvasSize: canvas宽度/高度\n// outerRadius: 外围半径\n// pointRadius: 圆点半径\n// pointRadius: 圆环半径\ncanvasSize = 2 * outerRadius = 2 * (pointRadius + circleRadius)\n```\n\n据此我们可以定义如下组件属性:\n\n```javascript\nprops: {\n circleRadius: {\n type: Number,\n default: 40\n },\n pointRadius: {\n type: Number,\n default: 6\n }\n},\ncomputed: {\n // 外围半径\n outerRadius() {\n return this.circleRadius + this.pointRadius\n },\n // canvas宽/高\n canvasSize() {\n return 2 * this.outerRadius + \'px\'\n }\n}\n```\n\n那么`canvas`大小也可以先进行绑定了\n\n```html\n\n \n \n```\n\n## 获取绘图上下文\n\n` getContext(\'2d\') `方法返回一个用于在`canvas`上绘图的环境,支持一系列`2d`绘图`API`。 \n\n```javascript\nmounted() {\n // 在$nextTick初始化画布,不然dom还未渲染好\n this.$nextTick(() => {\n this.initCanvas()\n })\n},\nmethods: {\n initCanvas() {\n var canvas = this.$refs.canvasDemo;\n var ctx = canvas.getContext(\'2d\');\n }\n}\n```\n\n## 画底色圆环\n\n完成了上述步骤后,我们就可以着手画各个元素了。我们先画圆环,这时我们还要定义两个属性,分别是圆环线宽`circleWidth`和圆环颜色`circleColor`。\n\n```javascript\ncircleWidth: {\n type: Number,\n default: 2\n},\ncircleColor: {\n type: String,\n default: \'#3B77E3\'\n}\n```\n\n`canvas`提供的画圆弧的方法是`ctx.arc()`,需要提供圆心坐标,半径,起止弧度,是否逆时针等参数。\n\n```javascript\nctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);\n```\n\n 我们知道,`Web`网页中的坐标系是这样的,从绝对定位的设置上其实就能看出来(`top`,`left`设置正负值会发生什么变化),而且原点`(0, 0)`是在盒子(比如说`canvas`)的左上角哦。 \n\n![web坐标系](https://qncdn.wbjiang.cn/web%E5%9D%90%E6%A0%87%E7%B3%BB.jpeg)\n\n对于角度而言,`0°`是`x`轴正向,默认是顺时针方向旋转。\n\n圆环的圆心就是`canvas`的中心,所以`x `, `y` 取`outerRadius`的值就可以了。\n\n```javascript\nctx.strokeStyle = this.circleColor;\nctx.lineWidth = this.circleWidth;\nctx.beginPath();\nctx.arc(this.outerRadius, this.outerRadius, this.circleRadius, 0, this.deg2Arc(360));\nctx.stroke();\n```\n\n注意`arc`传的是弧度参数,而不是我们常理解的`360°`这种概念,因此我们需要将我们理解的`360°`转为弧度。\n\n```javascript\n// deg转弧度\ndeg2Arc(deg) {\n return deg / 180 * Math.PI\n}\n```\n\n![画圆环](https://qncdn.wbjiang.cn/%E7%94%BB%E5%9C%86%E7%8E%AF.png)\n\n## 画文字\n\n调用`fillText`绘制文字,利用`canvas.clientWidth / 2`和`canvas.clientWidth / 2`取得中点坐标,结合控制文字对齐的两个属性`textAlign`和`textBaseline`,我们可以将文字绘制在画布中央。文字的值由`label`属性接收,字体大小由`fontSize`属性接收,颜色则取的`fontColor`。\n\n```javascript\nif (this.label) {\n ctx.font = `${this.fontSize}px Arial,\"Microsoft YaHei\"`\n ctx.fillStyle = this.fontColor;\n ctx.textAlign = \'center\'\n ctx.textBaseline = \'middle\'\n ctx.fillText(this.label, canvas.clientWidth / 2, canvas.clientWidth / 2);\n}\n```\n\n![画文字](https://qncdn.wbjiang.cn/%E7%94%BB%E6%96%87%E5%AD%97.png)\n\n## 画进度弧\n\n支持普通颜色和渐变色,`withGradient`默认为`true`,代表使用渐变色绘制进度弧,渐变方向我默认给的从上到下。如果希望使用普通颜色,`withGradient`传`false`即可,并可以通过`lineColor`自定义颜色。\n\n```javascript\nif (this.withGradient) {\n this.gradient = ctx.createLinearGradient(this.circleRadius, 0, this.circleRadius, this.circleRadius * 2);\n this.lineColorStops.forEach(item => {\n this.gradient.addColorStop(item.percent, item.color);\n });\n}\n```\n\n其中`lineColorStops`是渐变色的颜色偏移断点,由父组件传入,可传入任意个颜色断点,格式如下:\n\n```javascript\ncolorStops2: [\n { percent: 0, color: \'#FF9933\' },\n { percent: 1, color: \'#FF4949\' }\n]\n```\n\n画一条从上到下的进度弧,即`270°`到`90°`\n\n```javascript\nctx.strokeStyle = this.withGradient ? this.gradient : this.lineColor;\nctx.lineWidth = this.lineWidth;\nctx.beginPath();\nctx.arc(this.outerRadius, this.outerRadius, this.circleRadius, this.deg2Arc(270), this.deg2Arc(90));\nctx.stroke();\n```\n\n![画进度弧](https://qncdn.wbjiang.cn/%E7%94%BB%E8%BF%9B%E5%BA%A6%E5%BC%A7.png)\n\n其中`lineWidth`是弧线的宽度,由父组件传入\n\n```javascript\nlineWidth: {\n type: Number,\n default: 8\n}\n```\n\n## 画进度圆点\n\n最后我们需要把进度圆点补上,我们先写死一个角度`90°`,显而易见,圆点坐标为`(this.outerRadius, this.outerRadius + this.circleRadius)`\n\n![90度圆点坐标](https://qncdn.wbjiang.cn/90%E5%BA%A6%E5%9C%86%E7%82%B9%E5%9D%90%E6%A0%87.png)\n\n画圆点的代码如下:\n\n```javascript\nctx.fillStyle = this.pointColor;\nctx.beginPath();\nctx.arc(this.outerRadius, this.outerRadius + this.circleRadius, this.pointRadius, 0, this.deg2Arc(360));\nctx.fill();\n```\n\n其中`pointRadius`是圆点的半径,由父组件传入:\n\n```javascript\npointRadius: {\n type: Number,\n default: 6\n}\n```\n\n![90度画圆点](https://qncdn.wbjiang.cn/90%E5%BA%A6%E7%94%BB%E5%9C%86%E7%82%B9.png)\n\n## 角度自定义\n\n当然,进度条的角度是灵活定义的,包括开始角度,结束角度,都应该由调用者随意给出。因此我们再定义一个属性`angleRange`,用于接收起止角度。\n\n```javascript\nangleRange: {\n type: Array,\n default: function() {\n return [270, 90]\n }\n}\n```\n\n有了这个属性,我们就可以随意地画进度弧和圆点了,哈哈哈哈。\n\n![等等,你忘了这个场景](https://qncdn.wbjiang.cn/%E7%AD%89%E7%AD%89%E4%BD%A0%E5%BF%98%E4%BA%86%E8%BF%99%E4%B8%AA%E5%9C%BA%E6%99%AF.jpg)\n\n老哥,这种圆点坐标怎么求?\n\n![特殊角度怎么求圆点圆心坐标](https://qncdn.wbjiang.cn/%E7%89%B9%E6%AE%8A%E8%A7%92%E5%BA%A6%E6%80%8E%E4%B9%88%E6%B1%82%E5%9D%90%E6%A0%87.png)\n\n噗......看来高兴过早了,最重要的是根据不同角度求得圆点的圆心坐标,这让我顿时犯了难。\n\n![你要冷静](https://qncdn.wbjiang.cn/%E6%96%8C%E5%93%A5%E4%BD%A0%E8%A6%81%E5%86%B7%E9%9D%99.gif)\n\n经过冷静思考,我脑子里闪过了一个利用正余弦公式求坐标的思路,但前提是坐标系原点如果在圆环外接矩形的左上角才好算。仔细想想,冇问题啦,我先给坐标系平移一下,最后求出来结果,再补个平移差值不就行了嘛。\n\n![平移坐标系后求圆点坐标](https://qncdn.wbjiang.cn/%E5%B9%B3%E7%A7%BB%E5%9D%90%E6%A0%87%E7%B3%BB%E5%90%8E%E6%B1%82%E5%9C%86%E7%82%B9%E5%9D%90%E6%A0%87.png)\n\n画图工具不是很熟练,这里图没画好,线歪了,请忽略细节。\n\n好的,我们先给坐标系向右下方平移`pointRadius`,最后求得结果再加上`pointRadius`就好了。伪代码如下:\n\n```javascript\n// realx:真实的x坐标\n// realy:真实的y坐标\n// resultx:平移后求取的x坐标\n// resultx:平移后求取的y坐标\n// pointRadius 圆点半径\nrealx = resultx + pointRadius\nrealy = resulty + pointRadius\n```\n\n求解坐标的思路大概如下,分四个范围判断,得出求解公式,应该还可以化简,不过我数学太菜了,先这样吧。\n\n```javascript\ngetPositionsByDeg(deg) {\n let x = 0;\n let y = 0;\n if (deg >= 0 && deg <= 90) {\n // 0~90度\n x = this.circleRadius * (1 + Math.cos(this.deg2Arc(deg)))\n y = this.circleRadius * (1 + Math.sin(this.deg2Arc(deg)))\n } else if (deg > 90 && deg <= 180) {\n // 90~180度\n x = this.circleRadius * (1 - Math.cos(this.deg2Arc(180 - deg)))\n y = this.circleRadius * (1 + Math.sin(this.deg2Arc(180 - deg)))\n } else if (deg > 180 && deg <= 270) {\n // 180~270度\n x = this.circleRadius * (1 - Math.sin(this.deg2Arc(270 - deg)))\n y = this.circleRadius * (1 - Math.cos(this.deg2Arc(270 - deg)))\n } else {\n // 270~360度\n x = this.circleRadius * (1 + Math.cos(this.deg2Arc(360 - deg)))\n y = this.circleRadius * (1 - Math.sin(this.deg2Arc(360 - deg)))\n }\n return { x, y }\n}\n```\n\n最后再补上偏移值即可。\n\n```javascript\nconst pointPosition = this.getPositionsByDeg(nextDeg);\nctx.arc(pointPosition.x + this.pointRadius, pointPosition.y + this.pointRadius, this.pointRadius, 0, this.deg2Arc(360));\n```\n\n![ 任意角度画弧线和圆点 ](https://qncdn.wbjiang.cn/%E4%BB%BB%E6%84%8F%E8%A7%92%E5%BA%A6%E7%94%BB%E5%BC%A7%E7%BA%BF%E5%92%8C%E5%9C%86%E7%82%B9.png)\n\n这样,一个基本的`canvas`环形进度条就成型了。\n\n# 动画展示\n\n静态的东西逼格自然是不够的,因此我们需要再搞点动画效果装装逼。\n\n## 基础动画\n\n我们先简单实现一个线性的动画效果。基本思路是把开始角度和结束角度的差值分为`N`段,利用`window.requestAnimationFrame`依次执行动画。\n\n比如从`30°`到`90°`,我给它分为6段,每次画`10°`。要注意`canvas`画这种动画过程一般是要重复地清空画布并重绘的,所以第一次我画的弧线范围就是`30°~40°`,第二次我画的弧线范围就是`30°~50°`,以此类推......\n\n基本的代码结构如下,具体代码请参考[vue-awesome-progress]( https://github.com/cumt-robin/vue-awesome-progress ) `v1.1.0`版本,如果顺手帮忙点个`star`也是极好的。\n\n```javascript\nanimateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step) {\n window.requestAnimationFrame(() => {\n // 清空画布\n ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);\n // 求下一个目标角度\n nextDeg = this.getTargetDeg(nextDeg || startDeg, endDeg, step);\n // 画圆环\n // 画文字\n // 画进度弧线\n // 画进度圆点\n if (nextDeg !== endDeg) {\n // 满足条件继续调用动画,否则结束动画\n this.animateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step)\n }\n }\n}\n```\n\n![线性动画](https://qncdn.wbjiang.cn/%E7%8E%AF%E5%BD%A2%E8%BF%9B%E5%BA%A6%E6%9D%A1%E6%99%AE%E9%80%9A%E5%8A%A8%E7%94%BB.gif)\n\n## 缓动效果\n\n线性动画显得有点单调,可操作性不大,因此我考虑引入贝塞尔缓动函数`easing`,并且支持传入动画执行时间周期`duration`,增强了可定制性,使用体验更好。这里不列出实现代码了,请前往[vue-awesome-progress]( https://github.com/cumt-robin/vue-awesome-progress )查看。\n\n```html\n \n\n\n\n// 省略部分...\n\n \n\n\n```\n\n![环形进度条缓动效果](https://qncdn.wbjiang.cn/%E7%8E%AF%E5%BD%A2%E8%BF%9B%E5%BA%A6%E6%9D%A1%E7%BC%93%E5%8A%A8%E6%95%88%E6%9E%9C.gif)\n\n可以看到,当传入不同的动画周期`duration`和缓动参数`easing`时,动画效果各异,完全取决于使用者自己。\n\n# 其他效果\n\n当然根据组件支持的属性,我们也可以定制出其他效果,比如不显示文字,不显示圆点,弧线线宽与圆环线宽一样,不使用渐变色,不需要动画,等等。我们后续也会考虑支持更多能力,比如控制进度,数字动态增长等!具体使用方法,请参考[vue-awesome-progress]( https://github.com/cumt-robin/vue-awesome-progress )。\n\n![其他效果案例](https://qncdn.wbjiang.cn/%E7%8E%AF%E5%BD%A2%E8%BF%9B%E5%BA%A6%E6%9D%A1%E5%85%B6%E4%BB%96%E6%95%88%E6%9E%9C%E6%A1%88%E4%BE%8B.gif)\n\n# 更新日志\n\n**2020年04月10日更新**\n\n支持进度控制,只需要修改组件的属性值`percentage`即可。\n![进度控制](https://s1.ax1x.com/2020/04/10/GIvQCF.gif)\n\n------\n**2019年11月10日更新**\n\n由于我从业务场景出发做了这个组件,没有考虑到大部分场景都是传百分比控制进度的,因此在`v1.4.0`版本做了如下修正:\n\n1. 废弃`angle-range`,改用`percentage`控制进度,同时提供`start-deg`属性控制起始角度;\n\n2. `with-gradient`改为`use-gradient`\n\n3. 通过`show-text`控制是否显示进度文字\n\n4. 支持通过`format`函数自定义显示文字的规则\n\n\n![v1.4.0版本效果](https://qncdn.wbjiang.cn/v1.4.0%E6%95%88%E6%9E%9C%E5%9B%BE.gif)\n\n# 结语\n\n写完这个组件有让我感觉到,程序员最终不是输给了代码和技术的快速迭代,而是输给了自己的逻辑思维能力和数学功底。就[vue-awesome-progress]( https://github.com/cumt-robin/vue-awesome-progress )这个组件而言,根据这个思路,我们也能迅速开发出适用于`React`,`Angular`以及其他框架生态下的组件。工作三年有余,接触了不少框架和技术,经历了`MVVM`,`Hybrid`,`小程序`,`跨平台`,`大前端`,` serverless `的大火,也时常感慨“学不动了”,在这个快速演进的代码世界里常常感到失落。好在自己还没有丢掉分析问题的能力,而不仅仅是调用各种`API`和插件,这可能是程序员最宝贵的财富吧。前路坎坷,我辈当不忘初心,愿你出走半生,归来仍是少年!\n\n------', '2019-11-09 12:25:01', '2024-08-11 00:51:54', 1, 82, 0, '周末好,今天给大家带来一款接地气的环形进度条组件vue-awesome-progress。', 'https://qncdn.wbjiang.cn/vue-awesome-progress.png', 0, 0);
-INSERT INTO `article` VALUES (197, '因为new Date(),我给IE跪了', '处理日期格式是日常工作中的常事,我们经常会对日期字符串和日期对象之间进行转换。今天在`IE`浏览器就踩了这么一个日期转换的坑。\n\n# new Date()的坑\n\n后端返回的日期字符串格式为:`yyyy-MM-dd HH:mm:ss`,看到这个格式,大部分人都会觉得这应该是标准格式吧,我也是这么认为的,觉得没有任何兼容问题。转换语句如下:\n\n```javascript\nvar str2DateObj = new Date(\'2019-11-04 10:10:10\')\nconsole.log(str2DateObj)\n// 输出:VM796:2 Mon Nov 04 2019 10:10:10 GMT+0800 (中国标准时间)\n```\n\n但是`IE`就是这么`diao`,我就不支持这个格式。\n\n```javascript\nvar str2DateObj = new Date(\'2019-11-04 10:10:10\')\nconsole.log(str2DateObj)\n[date] Invalid Date[date] Invalid Date\n```\n\n# 解决方案\n\n## 自行解析\n\n将得到的日期字符串进行拆分解析,分别得到年月日时分秒,然后再`new Date`\n\n```javascript\n// 注意,月是从0开始的\nnew Date(2019, 10, 4, 10, 10, 10)\n```\n\n## 借助外力\n\n正好项目也用了`moment`这个日期时间库,那就交给它处理吧。\n\n```javascript\n// no problem\nmoment(\'2019-11-04 10:10:10\')\n```', '2019-11-15 09:50:00', '2024-08-16 14:16:29', 1, 40, 0, '处理日期格式是日常工作中的常事,我们经常会对日期字符串和日期对象之间进行转换。今天在IE浏览器就踩了这么一个日期转换的坑。', 'https://qncdn.wbjiang.cn/IE%E4%BD%A0%E5%88%AB%E8%B7%91.jpg', 0, 0);
-INSERT INTO `article` VALUES (198, '解决办公IP变化后git无法推送远程仓库的问题', '最近公司乔迁新址,在提交代码时遇到了无法`git push`的问题。报错如下:\n\n```\nThe RSA host key for github.com has changed,\nand the key for the corresponding IP address 42.243.156.48\nis unknown. This could either mean that\nDNS SPOOFING is happening or the IP address for the host\nand its host key have changed at the same time.\n```\n\n经检查,`ssh`密钥对是没有问题的,问题出在了`known_hosts`文件,办公`ip`变化了,而`known_hosts`中保留的是原来的`ip`,导致不识别当前`ip`而验证失败。\n\n解决方法也很简单,首先找到`.ssh`目录,我的是\n\n```\nC:\\Users\\Jiang.Wenbin\\.ssh\n```\n\n我们删除掉`known_hosts`文件,然后打开`git bash`,视个人情况选择性输入如下命令:\n\n```shell\n// 连接github\nssh -T git@github.com\n// 连接gitee\nssh -T git@gitee.com\n// 连接coding.net\nssh -T git@git.coding.net\n```\n\n在弹出询问后输入`yes`即可。\n\n这里在连接`github`时比较特殊,遇到了一个报错\n\n```\ngit@github.com: Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password).\n```\n\n其实是我开启了网络代理或者`fanqiang`工具引起的,关闭后正常了。\n\n再次`git push`代码就没问题了。', '2019-11-17 15:54:24', '2024-08-14 05:40:08', 1, 53, 0, '最近公司乔迁新址,在提交代码时遇到了无法git push的问题。', 'https://qncdn.wbjiang.cn/abstract_city_600x400.png', 0, 0);
-INSERT INTO `article` VALUES (199, 'Git多个远程仓库不同步时的补救办法', '`git`本地仓库是可以与多个远程仓库关联的,如果想知道怎么配置,请参考[Git如何使用多个托管平台管理代码](http://hexo.wbjiang.cn/干货!Git-如何使用多个托管平台管理代码.html) 。\n\n当`git remote`关联了多个远程仓库时,总会遇到一些问题。今天就遇到了两个远程仓库不一致导致无法`push`的情况。\n\n\n# 远程仓库间出现差异\n\n大概情况是这样的,我是一个本地仓库关联了`github`和`gitee`两个远程仓库。\n\n```shell\ngit remote add all git@github.com:cumt-robin/BlogFrontEnd.git\ngit remote set-url --add all git@gitee.com:tusi/BlogFrontEnd.git\n```\n\n由于不小心在远程仓库`gitee`上手动修改了`README.md`文件,导致两个远程仓库出现了差异。所以当我在本地完成了一部分功能,准备提交到远程仓库时,出现了报错。\n\n```shell\n$ git push all --all\nEverything up-to-date\nTo gitee.com:tusi/BlogFrontEnd.git\n ! [rejected] master -> master (fetch first)\nerror: failed to push some refs to \'git@gitee.com:tusi/BlogFrontEnd.git\'\nhint: Updates were rejected because the remote contains work that you do\nhint: not have locally. This is usually caused by another repository pushing\nhint: to the same ref. You may want to first integrate the remote changes\nhint: (e.g., \'git pull ...\') before pushing again.\nhint: See the \'Note about fast-forwards\' in \'git push --help\' for details.\n```\n\n# 解决方案\n\n由于是`gitee`的仓库多修改了一点东西,因此在本地再加一个`remote`,单独关联`gitee`。\n\n```shell\n$ git remote add gitee git@gitee.com:tusi/BlogFrontEnd.git\n```\n\n将`gitee`的代码拉到本地`master`。\n\n```shell\n$ git pull gitee master\nremote: Enumerating objects: 1, done.\nremote: Counting objects: 100% (1/1), done.\nremote: Total 1 (delta 0), reused 0 (delta 0)\nUnpacking objects: 100% (1/1), done.\nFrom gitee.com:tusi/BlogFrontEnd\n * branch master -> FETCH_HEAD\n * [new branch] master -> gitee/master\nAlready up to date!\nMerge made by the \'recursive\' strategy.\n```\n\n再将本地`master`推送到远程`all`。\n\n```shell\n$ git push all --all\nEnumerating objects: 2, done.\nCounting objects: 100% (2/2), done.\nDelta compression using up to 6 threads\nCompressing objects: 100% (2/2), done.\nWriting objects: 100% (2/2), 499 bytes | 499.00 KiB/s, done.\nTotal 2 (delta 0), reused 0 (delta 0)\nTo github.com:cumt-robin/BlogFrontEnd.git\n 1557ece..8391333 master -> master\nEnumerating objects: 2, done.\nCounting objects: 100% (2/2), done.\nDelta compression using up to 6 threads\nCompressing objects: 100% (2/2), done.\nWriting objects: 100% (2/2), 917 bytes | 917.00 KiB/s, done.\nTotal 2 (delta 0), reused 0 (delta 0)\nremote: Powered By Gitee.com\nTo gitee.com:tusi/BlogFrontEnd.git\n 8912ff5..8391333 master -> master\n```\n\n问题得以解决!\n\n------', '2019-11-19 18:40:23', '2024-07-25 08:06:35', 1, 34, 0, '当git remote关联了多个远程仓库时,总会遇到一些问题。今天就遇到了两个远程仓库不一致导致无法push的情况。', 'https://qncdn.wbjiang.cn/git%E5%A4%9A%E4%BB%93%E5%BA%93%E5%B7%AE%E5%BC%82.png', 0, 0);
-INSERT INTO `article` VALUES (200, '前端API层架构,也许你做得还不够', '上午好,今天为大家分享下个人对于前端`API`层架构的一点经验和看法。架构设计是一条永远走不完的路,没有最好,只有更好。这个道理适用于软件设计的各个场景,前端`API`层的设计也不例外,如果您觉得在调用接口时还存在诸多槽点,那就说明您的接口层架构还待优化。今天我以`vue + axios`为例,为大家梳理下我的一些经历和设想。\n\n# 石器时代,痛苦\n\n直接调用`axios`,真的痛苦,每个调用的地方都要进行响应状态的判断,冗余代码超级多。\n\n```javascript\nimport axios from \"axios\"\n\naxios.get(\'/usercenter/user/page?pageNo=1&pageSize=10\').then(res => {\n const data = res.data\n // 判断请求状态,success字段为true代表成功,视前后端约束而定\n if (data.success) {\n // 结果成功后的业务代码\n } else {\n // 结果失败后的业务代码\n }\n})\n```\n\n看起来确实很难受,每调用一次接口,就有这么多重复的工作!\n\n# 青铜器时代,中规中矩\n\n为了解决直接调用`axios`的痛点,我们一般会利用`Promise`对`axios`二次封装,对接口响应状态进行集中判断,对外暴露`get`, `post`, `put`, `delete`等`http`方法。\n\n## axios二次封装\n\n```javascript\nimport axios from \"axios\"\nimport router from \"@/router\"\nimport { BASE_URL } from \"@/router/base-url\"\nimport { errorMsg } from \"@/utils/msg\";\nimport { stringify } from \"@/utils/helper\";\n// 创建axios实例\nconst v3api = axios.create({\n baseURL: process.env.BASE_API,\n timeout: 10000\n});\n// axios实例默认配置\nv3api.defaults.headers.common[\'Content-Type\'] = \'application/x-www-form-urlencoded\';\nv3api.defaults.transformRequest = data => {\n return stringify(data)\n}\n// 返回状态拦截,进行状态的集中判断\nv3api.interceptors.response.use(\n response => {\n const res = response.data;\n if (res.success) {\n return Promise.resolve(res)\n } else {\n // 内部错误码处理\n if (res.code === 1401) {\n errorMsg(res.message || \'登录已过期,请重新登录!\')\n router.replace({ path: `${BASE_URL}/login` })\n } else {\n // 默认的错误提示\n errorMsg(res.message || \'网络异常,请稍后重试!\')\n }\n return Promise.reject(res);\n }\n },\n error => {\n if (/timeout\\sof\\s\\d+ms\\sexceeded/.test(error.message)) {\n // 超时\n errorMsg(\'网络出了点问题,请稍后重试!\')\n }\n if (error.response) {\n // http状态码判断\n switch (error.response.status) {\n // http status handler\n case 404:\n errorMsg(\'请求的资源不存在!\')\n break\n case 500:\n errorMsg(\'内部错误,请稍后重试!\')\n break\n case 503:\n errorMsg(\'服务器正在维护,请稍等!\')\n break\n }\n }\n return Promise.reject(error.response)\n }\n)\n\n// 处理get请求\nconst get = (url, params, config = {}) => v3api.get(url, { ...config, params })\n// 处理delete请求,为了防止和关键词delete冲突,方法名定义为deletes\nconst deletes = (url, params, config = {}) => v3api.delete(url, { ...config, params })\n// 处理post请求\nconst post = (url, params, config = {}) => v3api.post(url, params, config)\n// 处理put请求\nconst put = (url, params, config = {}) => v3api.put(url, params, config)\nexport default {\n get,\n deletes,\n post,\n put\n}\n```\n\n## 调用者不再判断请求状态\n\n```javascript\nimport api from \"@/api\";\n\nmethods: {\n getUserPageData() {\n api.get(\'/usercenter/user/page?pageNo=1&pageSize=10\').then(res => {\n // 状态已经集中判断了,这里直接写成功的逻辑\n // 业务代码......\n const result = res.result;\n }).catch(res => {\n // 失败的情况写在catch中\n })\n }\n}\n```\n\n## async/await改造\n\n使用语义化的异步函数\n\n```javascript\nmethods: {\n async getUserPageData() {\n try {\n const res = await api.get(\'/usercenter/user/page?pageNo=1&pageSize=10\') \n // 业务代码......\n const { result } = res;\n } catch(error) {\n // 失败的情况写在catch中\n }\n }\n}\n```\n\n## 存在的问题\n\n- 语义化程度有限,调用接口还是需要查询接口`url`\n- 前端`api`层难以维护,如后端接口发生改动,前端每处都需要大改。\n- 如果`UI`组件的数据模型与后端接口要求的数据结构存在差异,每处调用接口前都需要进行数据处理,抹平差异,比如`[1,2,3]`转`1,2,3`这种(当然,这只是最简单的一个例子)。这样如果数据处理不慎,调用者出错几率太高!\n- 难以满足特殊化场景,举个例子,一个查询的场景,后端要求,如果输入了搜索关键词`keyword`,必须调用`/user/search`接口,如果没有输入关键词,只能调用`/user/page`接口。如果每个调用者都要判断是不是输入了关键词,再决定调用哪个接口,你觉得出错几率有多大,用起来烦不烦?\n- 产品说,这些场景需要优化,默认按创建时间降序排序。我擦,又一个个改一遍?\n- ......\n\n那么怎么解决这些问题呢?请耐心接着看......\n\n# 铁器时代,it\'s cool\n\n我想到的方案是在底层封装和调用者之间再增加一层`API`适配层(适配层,取量身定制之意),在适配层做统一处理,包括参数处理,请求头处理,特殊化处理等,提炼出更语义化的方法,让调用者“傻瓜式”调用,不再为了查找接口`url`和处理数据结构这些重复的工作而烦恼,把`ViewModel`层绑定的数据模型直接丢给适配层统一处理。\n\n## 对齐微服务架构\n\n 首先,为了对齐后端微服务架构,在前端将`API`调用分为三个模块。 \n\n```\n├─api\n index.js axios底层封装\n ├─base 负责调用基础服务,basecenter\n ├─iot 负责调用物联网服务,iotcenter\n └─user 负责调用用户相关服务,usercenter\n```\n\n 每个模块下都定义了统一的微服务命名空间,例如`/src/api/user/index.js`: \n\n```javascript\nexport const namespace = \'usercenter\';\n```\n\n## 特性模块\n\n每个功能特性都有独立的`js`模块,以角色管理相关接口为例,模块是`/src/api/user/role.js` \n\n```javascript\nimport api from \'../index\'\nimport { paramsFilter } from \"@/utils/helper\";\nimport { namespace } from \"./index\"\nconst feature = \'role\'\n\n// 添加角色\nexport const addRole = params => api.post(`/${namespace}/${feature}/add`, paramsFilter(params));\n// 删除角色\nexport const deleteRole = id => api.deletes(`/${namespace}/${feature}/delete`, { id });\n// 更新角色\nexport const updateRole = params => api.put(`/${namespace}/${feature}/update`, paramsFilter(params));\n// 条件查询角色\nexport const findRoles = params => api.get(`/${namespace}/${feature}/find`, paramsFilter(params));\n// 查询所有角色,不传参调用find接口代表查询所有角色\nexport const getAllRoles = () => findRoles();\n// 获取角色详情\nexport const getRoleDetail = id => api.get(`/${namespace}/${feature}/detail`, { id });\n// 分页查询角色\nexport const getRolePage = params => api.get(`/${namespace}/${feature}/page`, paramsFilter(params));\n// 搜索角色\nexport const searchRole = params => params.keyword ? api.get(`/${namespace}/${feature}/search`, paramsFilter(params)) : getRolePage(params);\n```\n\n- 每一条接口都根据`RESTful`风格,调用增(`api.post`)删(`api.deletes`)改(`api.put`)查(`api.get`)的底层方法,对外输出语义化方法。\n- 调用的`url`由三部分组成,格式:`/微服务命名空间/特性命名空间/方法`\n- 接口适配层函数命名规范:\n\n- - 新增:`addXXX`\n - 删除:`deleteXXX`\n - 更新:`updateXXX`\n - 根据ID查询记录:`getXXXDetail`\n - 条件查询一条记录:`findOneXXX`\n - 条件查询:`findXXXs`\n - 查询所有记录:`getAllXXXs`\n - 分页查询:`getXXXPage`\n - 搜索:`searchXXX`\n - 其余个性化接口根据语义进行命名\n\n## 解决问题\n\n- 语义化程度更高,配合`vscode`的代码提示功能,用起来不要太爽!\n\n- 迅速响应接口改动,适配层统一处理\n\n- 集中进行数据处理(对于公用的数据处理,我们用`paramsFilter`解决,对于特殊的情况,再另行处理),调用者安心做业务即可\n\n- 满足特殊场景,佛系应对后端和产品朋友\n\n - 针对上节提到的关键字查询场景,我们在适配层通过在入参中判断是否有`keyword`字段,决定调用`search`还是`page`接口。对外我们只需暴露`searchRole`方法,调用者只需要调用`searchRole`方法即可,无需做其他考虑。\n\n ```javascript\n export const searchRole = params => params.keyword ? api.get(`/${namespace}/${feature}/search`, paramsFilter(params)) : getRolePage(params);\n ```\n\n - 针对产品突然加的排序需求,我们可以在适配层去做默认入参的处理。\n\n 首先,我们新建一个专门管理默认参数的`js`,如`src/api/default-options.js`\n\n ```javascript\n // 默认按创建时间降序的参数对象\n export const SORT_BY_CREATETIME_OPTIONS = {\n sortField: \'createTime\',\n // desc代表降序,asc是升序\n sortType: \'desc\'\n }\n ```\n\n 接着,我们在接口适配层做集中化处理\n\n ```javascript\n import api from \'../index\'\n import { SORT_BY_CREATETIME_OPTIONS } from \"../default-options\"\n import { paramsFilter } from \"@/utils/helper\";\n import { namespace } from \"./index\"\n const feature = \'role\'\n \n export const getRolePage = params => api.get(`/${namespace}/${feature}/page`, paramsFilter({ ...SORT_BY_CREATETIME_OPTIONS, ...params }));\n ```\n\n `SORT_BY_CREATETIME_OPTIONS`放在前面,是为了满足如果出现其他排序需求,调用者传入的排序字段能覆盖掉默认参数。\n\n## mock先行\n\n一个完善的`API`层设计,肯定是离不开`mock`的。在后端提供接口之前,前端必须通过模拟数据并行开发,否则进度无法保证。那么如何设计一个跟真实接口契合度高的`mock`系统呢?我这里简单做下分享。\n\n- 首先,创建`mock`专用的`axios`实例\n\n我们在`src`目录下新建`mock`目录,并在`src/mock/index.js`简单封装一个`axios`实例\n\n```javascript\n// 仅限模拟数据使用\nimport axios from \"axios\"\nconst mock = axios.create({\n baseURL: \'\'\n});\n// 返回状态拦截\nmock.interceptors.response.use(\n response => {\n return Promise.resolve(response.data)\n },\n error => {\n return Promise.reject(error.response)\n }\n)\n\nexport default mock\n```\n\n- `mock`同样也要分模块,以`usercenter`微服务下的角色管理`mock`接口为例\n\n```\n├─mock\n index.js mock底层axios封装\n ├─user 负责调用基础服务,usercenter\n ├─role\n ├─index.js\n```\n\n我们在`src/mock/user/role/index.js`中简单模拟一个获取所有角色的接口`getAllRoles`\n\n```javascript\nimport mock from \"@/mock\";\n\nexport const getAllRoles = () => mock.get(\'/static/mock/user/role/getAllRoles.json\')\n```\n\n可以看到,我们是在`mock`接口中获取了`static/mock`目录下的`json`数据。因此我们需要根据接口文档或者约定好的数据结构准备好`getAllRoles.json`数据\n\n```\n{\n \"success\": true,\n \"result\": {\n \"pageNo\": 1,\n \"pageSize\": 10,\n \"total\": 2,\n \"list\": [\n {\n \"id\": 1,\n \"createTime\": \"2019-11-19 12:53:05\",\n \"updateTime\": \"2019-12-03 09:53:41\",\n \"name\": \"管理员\",\n \"code\": \"管理员\",\n \"description\": \"一个拥有部分权限的管理员角色\",\n \"sort\": 1,\n \"menuIds\": \"789,2,55,983,54\",\n \"menuNames\": \"数据字典, 后台, 账户信息, 修改密码, 账户中心\"\n },\n {\n \"id\": 2,\n \"createTime\": \"2019-11-27 17:18:54\",\n \"updateTime\": \"2019-12-01 19:14:30\",\n \"name\": \"前台测试\",\n \"code\": \"前台测试\",\n \"description\": \"一个拥有部分权限的前台测试角色\",\n \"sort\": 2,\n \"menuIds\": \"15,4,1\",\n \"menuNames\": \"油耗统计, 车联网, 物联网监管系统\"\n }\n ]\n },\n \"message\": \"请求成功\",\n \"code\": 0\n}\n```\n\n- 我们来看看`mock`是怎么做的\n\n先看下真实接口的调用方式\n\n```javascript\nimport { getAllRoles } from \"@/api/user/role\";\n\ncreated() {\n this.getAllRolesData()\n},\nmethods: {\n async getAllRolesData() {\n const res = await getAllRoles()\n console.log(res)\n }\n}\n```\n\n那么`mock`时怎么做呢?非常简单,只要将`mock`中提供的方法替代掉`api`提供的方法即可。\n\n```javascript\n// import { getAllRoles } from \"@/api/user/role\";\nimport { getAllRoles } from \"@/mock/user/role\";\n```\n\n可以看到,这种`mock`方式与调用真实接口的契合度还是挺高的,正式调试接口时,只需将注释的代码调整即可,过渡非常平滑!\n\n- 注意,在生产环境下,为了防止打包时将`static/mock`目录下的内容`copy`到`dist`目录下,我们需要配置下`CopyWebpackPlugin`,以`vue-cli@2`为例,我们修改`webpack.base.conf.js`即可。\n\n```javascript\nconst devMode = process.env.NODE_ENV === \'development\';\n\nnew CopyWebpackPlugin([\n {\n from: path.resolve(__dirname, \'../static\'),\n to: devMode ? config.dev.assetsSubDirectory : config.build.assetsSubDirectory,\n ignore: devMode ? \'\' : \'mock/**/*\'\n }\n])\n```\n\n# 蒸汽时代,真香\n\n下一步的设想,使用类型安全的`typescript`,让前端`API`层真正做到面向接口文档编程,规范入参,出参,可选参数,等等,提高可维护性,在编码阶段就大大降低出错几率。虽然还在重构阶段,但是我想说,重拾`typescript`是真香,突然怀念使用`Angular`的那两年了,期待`vue3.0`能将`typescript`结合得更加完美......\n\n# 电气时代,更多畅想\n\n未来还有无限可能,面对日渐复杂和多样化的业务场景,我们会提炼出更好的架构和设计模式。目前有一个不成熟的设想,是否能在接口设计上做到更规范化,后端输出接口文档的同时,提炼出`API json`之类的数据结构?前端拿到`API json`,通过`nodejs`文件编程的能力,自动化生成前端接口层代码,解放双手。 \n\n# 结语\n\n当然,以上只是我的一点点经验和设想,是在我能力范围内能想到的东西,希望能帮助到一些有需要的同学。如果大佬们有更好的经验,可以指点一二。\n\n------\n[首发链接](https://juejin.im/post/5de7169451882512454b18d8)\n\n------\n\n往期精彩:\n\n- [用初中数学知识撸一个canvas环形进度条]( https://juejin.im/post/5dc626125188253aec025a60 )', '2019-12-04 10:18:33', '2024-08-11 21:27:24', 1, 91, 0, '上午好,今天为大家分享下个人对于前端API层架构的一点经验和看法。架构设计是一条永远走不完的路,没有最好,只有更好。这个道理适用于软件设计的各个场景,前端API层的设计也不例外。', 'https://qncdn.wbjiang.cn/%E5%89%8D%E7%AB%AFAPI%E5%B1%82%E6%9E%B6%E6%9E%84.png', 0, 0);
-INSERT INTO `article` VALUES (201, '从一道面试题简单谈谈发布订阅和观察者模式', '今天的话题是`javascript`中常被提及的「发布订阅模式和观察者模式」,提到这,我不由得想起了一次面试。记得在去年的一次求职面试过程中,面试官问我,“你在项目中是怎么处理非父子组件之间的通信的?”。我答道,“有用到`vuex`,有的场景也会用`EventEmitter2`”。面试官继续问,“那你能手写代码,实现一个简单的`EventEmitter`吗?”\n\n# 手写EventEmitter\n\n我犹豫了一会儿,想到使用`EventEmitter2`时,主要是用`emit`发事件,用`on`监听事件,还有`off`销毁事件监听者,`removeAllListeners`销毁指定事件的所有监听者,还有`once`之类的方法。考虑到时间关系,我想着就先实现发事件,监听事件,移除监听者这几个功能。当时可能有点紧张,不过有惊无险,在面试官给了一点提示后,顺利地写出来了!现在把这部分代码也记下来。\n\n```javascript\nclass EventEmitter {\n constructor() {\n // 维护事件及监听者\n this.listeners = {}\n }\n /**\n * 注册事件监听者\n * @param {String} type 事件类型\n * @param {Function} cb 回调函数\n */\n on(type, cb) {\n if (!this.listeners[type]) {\n this.listeners[type] = []\n }\n this.listeners[type].push(cb)\n }\n /**\n * 发布事件\n * @param {String} type 事件类型\n * @param {...any} args 参数列表,把emit传递的参数赋给回调函数\n */\n emit(type, ...args) {\n if (this.listeners[type]) {\n this.listeners[type].forEach(cb => {\n cb(...args)\n })\n }\n }\n /**\n * 移除某个事件的一个监听者\n * @param {String} type 事件类型\n * @param {Function} cb 回调函数\n */\n off(type, cb) {\n if (this.listeners[type]) {\n const targetIndex = this.listeners[type].findIndex(item => item === cb)\n if (targetIndex !== -1) {\n this.listeners[type].splice(targetIndex, 1)\n }\n if (this.listeners[type].length === 0) {\n delete this.listeners[type]\n }\n }\n }\n /**\n * 移除某个事件的所有监听者\n * @param {String} type 事件类型\n */\n offAll(type) {\n if (this.listeners[type]) {\n delete this.listeners[type]\n }\n }\n}\n// 创建事件管理器实例\nconst ee = new EventEmitter()\n// 注册一个chifan事件监听者\nee.on(\'chifan\', function() { console.log(\'吃饭了,我们走!\') })\n// 发布事件chifan\nee.emit(\'chifan\')\n// 也可以emit传递参数\nee.on(\'chifan\', function(address, food) { console.log(`吃饭了,我们去${address}吃${food}!`) })\nee.emit(\'chifan\', \'三食堂\', \'铁板饭\') // 此时会打印两条信息,因为前面注册了两个chifan事件的监听者\n\n// 测试移除事件监听\nconst toBeRemovedListener = function() { console.log(\'我是一个可以被移除的监听者\') }\nee.on(\'testoff\', toBeRemovedListener)\nee.emit(\'testoff\')\nee.off(\'testoff\', toBeRemovedListener)\nee.emit(\'testoff\') // 此时事件监听已经被移除,不会再有console.log打印出来了\n\n// 测试移除chifan的所有事件监听\nee.offAll(\'chifan\')\nconsole.log(ee) // 此时可以看到ee.listeners已经变成空对象了,再emit发送chifan事件也不会有反应了\n```\n\n有了这个自己写的简单版本的`EventEmitter`,我们就不用依赖第三方库啦。对了,`vue`也可以帮我们做这样的事情。\n\n```javascript\nconst ee = new Vue();\nee.$on(\'chifan\', function(address, food) { console.log(`吃饭了,我们去${address}吃${food}!`) })\nee.$emit(\'chifan\', \'三食堂\', \'铁板饭\')\n```\n\n所以我们可以单独`new`一个`Vue`的实例,作为事件管理器导出给外部使用。想测试的朋友可以直接打开`vue`官网,在控制台试试,也可以在自己的`vue`项目中实践下哦。\n\n# 发布订阅模式\n\n其实仔细看看,`EventEmitter`就是一个典型的发布订阅模式,实现了事件调度中心。发布订阅模式中,包含发布者,事件调度中心,订阅者三个角色。我们刚刚实现的`EventEmitter`的一个实例`ee`就是一个事件调度中心,发布者和订阅者是松散耦合的,互不关心对方是否存在,他们关注的是事件本身。发布者借用事件调度中心提供的`emit`方法发布事件,而订阅者则通过`on`进行订阅。\n\n如果还不是很清楚的话,我们把代码换下单词,是不是变得容易理解一点呢?\n\n```javascript\nclass PubSub {\n constructor() {\n // 维护事件及订阅行为\n this.events = {}\n }\n /**\n * 注册事件订阅行为\n * @param {String} type 事件类型\n * @param {Function} cb 回调函数\n */\n subscribe(type, cb) {\n if (!this.events[type]) {\n this.events[type] = []\n }\n this.events[type].push(cb)\n }\n /**\n * 发布事件\n * @param {String} type 事件类型\n * @param {...any} args 参数列表\n */\n publish(type, ...args) {\n if (this.events[type]) {\n this.events[type].forEach(cb => {\n cb(...args)\n })\n }\n }\n /**\n * 移除某个事件的一个订阅行为\n * @param {String} type 事件类型\n * @param {Function} cb 回调函数\n */\n unsubscribe(type, cb) {\n if (this.events[type]) {\n const targetIndex = this.events[type].findIndex(item => item === cb)\n if (targetIndex !== -1) {\n this.events[type].splice(targetIndex, 1)\n }\n if (this.events[type].length === 0) {\n delete this.events[type]\n }\n }\n }\n /**\n * 移除某个事件的所有订阅行为\n * @param {String} type 事件类型\n */\n unsubscribeAll(type) {\n if (this.events[type]) {\n delete this.events[type]\n }\n }\n}\n```\n\n## 画图分析\n\n最后,我们画个图加深下理解:\n\n![发布订阅模式图解](https://qncdn.wbjiang.cn/%E5%8F%91%E5%B8%83%E8%AE%A2%E9%98%85.png)\n\n## 特点\n\n- 发布订阅模式中,对于发布者`Publisher`和订阅者`Subscriber`没有特殊的约束,他们好似是匿名活动,借助事件调度中心提供的接口发布和订阅事件,互不了解对方是谁。\n- 松散耦合,灵活度高,常用作事件总线\n- 易理解,可类比于`DOM`事件中的`dispatchEvent`和`addEventListener`。\n\n## 缺点\n\n- 当事件类型越来越多时,难以维护,需要考虑事件命名的规范,也要防范数据流混乱。\n\n# 观察者模式\n\n观察者模式与发布订阅模式相比,耦合度更高,通常用来实现一些响应式的效果。在观察者模式中,只有两个主体,分别是目标对象`Subject`,观察者`Observer`。\n\n- 观察者需`Observer`要实现`update`方法,供目标对象调用。`update`方法中可以执行自定义的业务代码。\n- 目标对象`Subject`也通常被叫做被观察者或主题,它的职能很单一,可以理解为,它只管理一种事件。`Subject`需要维护自身的观察者数组`observerList`,当自身发生变化时,通过调用自身的`notify`方法,依次通知每一个观察者执行`update`方法。\n\n按照这种定义,我们可以实现一个简单版本的观察者模式。\n\n```javascript\n// 观察者\nclass Observer {\n /**\n * 构造器\n * @param {Function} cb 回调函数,收到目标对象通知时执行\n */\n constructor(cb){\n if (typeof cb === \'function\') {\n this.cb = cb\n } else {\n throw new Error(\'Observer构造器必须传入函数类型!\')\n }\n }\n /**\n * 被目标对象通知时执行\n */\n update() {\n this.cb()\n }\n}\n\n// 目标对象\nclass Subject {\n constructor() {\n // 维护观察者列表\n this.observerList = []\n }\n /**\n * 添加一个观察者\n * @param {Observer} observer Observer实例\n */\n addObserver(observer) {\n this.observerList.push(observer)\n }\n /**\n * 通知所有的观察者\n */\n notify() {\n this.observerList.forEach(observer => {\n observer.update()\n })\n }\n}\n\nconst observerCallback = function() {\n console.log(\'我被通知了\')\n}\nconst observer = new Observer(observerCallback)\n\nconst subject = new Subject();\nsubject.addObserver(observer);\nsubject.notify();\n```\n\n## 画图分析\n\n最后也整张图理解下观察者模式:\n\n![观察者模式](https://qncdn.wbjiang.cn/%E8%A7%82%E5%AF%9F%E8%80%85%E6%A8%A1%E5%BC%8F%E5%9B%BE%E8%A7%A3.png)\n\n## 特点\n\n- 角色很明确,没有事件调度中心作为中间者,目标对象`Subject`和观察者`Observer`都要实现约定的成员方法。\n- 双方联系更紧密,目标对象的主动性很强,自己收集和维护观察者,并在状态变化时主动通知观察者更新。\n\n## 缺点\n\n我还没体会到,这里不做评价\n\n# 结语\n\n关于这个话题,网上文章挺多的,观点上可能也有诸多分歧。重复造轮子,纯属帮助自己加深理解。\n\n本人水平有限,以上仅是个人观点,如有错误之处,还请斧正!如果能帮到您理解发布订阅模式和观察者模式,非常荣幸!\n\n如果有兴趣看看我这糟糕的代码,请点击[github](https://github.com/cumt-robin/just-demos),祝大家生活愉快!\n\n------', '2019-12-12 10:47:36', '2024-08-03 20:28:50', 1, 105, 0, '今天的话题是javascript中常被提及的「发布订阅模式和观察者模式」,提到这,我不由得想起了一次面试。', 'https://qncdn.wbjiang.cn/%E5%8F%91%E5%B8%83%E8%AE%A2%E9%98%85&%E8%A7%82%E5%AF%9F%E8%80%85.png', 0, 0);
-INSERT INTO `article` VALUES (202, '入门babel,我们需要了解些什么', '说实话,我从工作开始就一直在接触`babel`,然而对于`babel`并没有一个清晰的认识,只知道`babel`是用于编译`javascript`,让开发者能使用超前的`ES6+`语法进行开发。自己配置`babel`的时候,总是遇到很多困惑,下面我就以`babel@7`为例,重新简单认识下`babel`。\n\n# 什么是babel\n\n> Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。\n\n`babel`的配置文件一般是根目录下的`.babelrc`,`babel@7`目前已经支持`babel.config.js`,不妨用`babel.config.js`试试。\n\n# 泰拳警告\n\n![泰拳警告](https://qncdn.wbjiang.cn/%E6%B3%B0%E6%8B%B3%E8%AD%A6%E5%91%8A.jpg)\n\n`babel`提供的基础能力是语法转换,或者叫语法糖转换。比如把箭头函数转为普通的`function`,而对于`ES6`新引入的全局对象是默认不做处理的,如`Promise`, `Map`, `Set`, `Reflect`, `Proxy`等。对于这些全局对象和新的`API`,需要用垫片`polyfill`处理,`core-js`有提供这些内容。\n\n所以`babel`做的事情主要是:\n\n1. 根据你的配置做语法糖解析,转换\n2. 根据你的配置塞入垫片`polyfill`\n\n如果不搞清楚这点,`babel`的文档看起来会很吃力!\n\n# 必须掌握的概念\n\n## plugins\n\n`babel`默认不做任何处理,需要借助插件来完成语法的解析,转换,输出。\n\n插件分为语法插件`Syntax Plugins`和转换插件`Transform Plugins`。\n\n### 语法插件\n\n语法插件仅允许`babel`解析语法,不做转换操作。我们主要关注的是转换插件。\n\n### 转换插件\n\n转换插件,顾名思义,负责的是语法转换。\n\n> 转换插件将启用相应的语法插件,如果启用了某个语法的转换插件,则不必再另行指定相应的语法插件了。\n\n语法转换插件有很多,从`ES3`到`ES2018`,甚至是一些实验性的语法和相关框架生态下的语法,都有相关的插件支持。\n\n语法转换插件主要做的事情有:\n\n利用`@babel/parser`进行词法分析和语法分析,转换为`AST` **-->** 利用`babel-traverse`进行`AST`转换(涉及添加,更新及移除节点等操作) **-->** 利用`babel-generator`生成目标环境`js`代码\n\n### 插件简写\n\n`babel@7`之前的缩写形式是这样的:\n\n```javascript\n// 完整写法\nplugins: [\n \"babel-plugin-transform-runtime\"\n]\n// 简写形式\nplugins: [\n \"transform-runtime\"\n]\n```\n\n而在`babel@7`之后,由于`plugins`都归到了`@babel`目录下,所以简写形式也有所改变:\n\n```java\n// babel@7插件完整写法\nplugins: [\n \"@babel/plugin-transform-runtime\"\n]\n// 简写形式,需要保留目录\nplugins: [\n \"@babel/transform-runtime\"\n]\n```\n\n### 插件开发\n\n我们自己也可以开发插件,官网上的一个非常简单的小例子:\n\n```javascript\nexport default function() {\n return {\n visitor: {\n Identifier(path) {\n const name = path.node.name;\n // reverse the name: JavaScript -> tpircSavaJ\n path.node.name = name\n .split(\"\")\n .reverse()\n .join(\"\");\n },\n },\n };\n}\n```\n\n## presets\n\n`preset`,意为“预设”,其实是一组`plugin`的集合。我的理解是,根据这项配置,`babel`会为你预设(或称为“内置”)好一些`ECMA`标准,草案,或提案下的语法或`API`,甚至是你自己写的一些语法规则。当然,这都是基于`plugin`实现的。\n\n### 官方presets\n\n- [@babel/preset-env](https://babeljs.io/docs/en/babel-preset-env)\n- [@babel/preset-flow](https://babeljs.io/docs/en/babel-preset-flow)\n- [@babel/preset-react](https://babeljs.io/docs/en/babel-preset-react)\n- [@babel/preset-typescript](https://babeljs.io/docs/en/babel-preset-typescript)\n\n### @babel/preset-env\n\n`@babel/preset-env`提供了一种智能的预设,根据配置的`options`来决定支持哪些能力。\n\n我们看看关键的`options`有哪些。\n\n- targets\n\n描述你的项目要支持的目标环境。写法源于开源项目[browserslist](https://github.com/browserslist/browserslist)。这项配置应该根据你需要兼容的浏览器而设置,不必与其他人一模一样。示例如下:\n\n```javascript\n\"targets\": {\n \"browsers\": [\"> 1%\", \"last 2 versions\", \"not ie <= 9\"]\n}\n```\n\n- loose\n\n可以直译为“松散模式”,默认为`false`,即为`normal`模式。简单地说,就是`normal`模式转换出来的代码更贴合`ES6`风格,更严谨;而`loose`模式更像我们平时的写法。以`class`写法举例:\n\n我们先写个简单的`class`:\n\n```javascript\nclass TestBabelLoose {\n constractor(name) {\n this.name = name\n }\n getName() {\n return this.name\n }\n}\n\nnew TestBabelLoose(\'Tusi\')\n```\n\n使用`normal`模式编译得到结果如下:\n\n```javascript\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nvar TestBabelLoose =\n/*#__PURE__*/\nfunction () {\n function TestBabelLoose() {\n _classCallCheck(this, TestBabelLoose);\n }\n\n _createClass(TestBabelLoose, [{\n key: \"constractor\",\n value: function constractor(name) {\n this.name = name;\n }\n }, {\n key: \"getName\",\n value: function getName() {\n return this.name;\n }\n }]);\n\n return TestBabelLoose;\n}();\n\nnew TestBabelLoose(\'Tusi\');\n```\n\n而使用`loose`模式编译得到结果是这样的,是不是更符合我们用`prototype`实现类的写法?\n\n```javascript\n\"use strict\";\n\nvar TestBabelLoose =\n/*#__PURE__*/\nfunction () {\n function TestBabelLoose() {}\n\n var _proto = TestBabelLoose.prototype;\n\n _proto.constractor = function constractor(name) {\n this.name = name;\n };\n\n _proto.getName = function getName() {\n return this.name;\n };\n\n return TestBabelLoose;\n}();\n\nnew TestBabelLoose(\'Tusi\');\n```\n\n个人推荐配置`loose: false`,当然也要结合项目实际去考量哪种模式更合适。\n\n- modules\n\n可选值有:`\"amd\" | \"umd\" | \"systemjs\" | \"commonjs\" | \"cjs\" | \"auto\" | false`,默认是`auto`\n\n该配置将决定是否把`ES6`模块语法转换为其他模块类型。注意,`cjs`是`commonjs`的别名。\n\n其实我一直有个疑惑,为什么我看到的开源组件中,基本都是设置的`modules: false`?后面终于明白了,原来这样做的目的是把转换模块类型的处理权交给了`webpack`,由`webpack`去处理这项任务。所以,如果你也使用`webpack`,那么设置`modules: false`就没错啦。\n\n- useBuiltIns\n\n可选值有:`\"entry\" | \"usage\" | false`,默认是`false`\n\n该配置将决定`@babel/preset-env`如何去处理`polyfill`\n\n`\"entry\"`\n\n如果`useBuiltIns`设置为`\"entry\"`,我们需要安装`@babel/polyfill`,并且在入口文件引入`@babel/polyfill`,最终会被转换为`core-js`模块和`regenerator-runtime/runtime`。对了,`@babel/polyfill`也不会处理`stage <=3`的提案。\n\n我们用一段包含了`Promise`的代码来做下测试:\n\n```javascript\nimport \"@babel/polyfill\";\n\nclass TestBabelLoose {\n constractor(name) {\n this.name = name\n }\n getName() {\n return this.name\n }\n testPromise() {\n return new Promise(resolve => {\n resolve()\n })\n }\n}\nnew TestBabelLoose(\'Tusi\')\n```\n\n但是编译后,貌似引入了很多`polyfill`啊,一共149个,怎么不是按需引入呢?嗯...你需要往下看了。\n\n```javascript\nimport \"core-js/modules/es6.array.map\";\nimport \"core-js/modules/es6.map\";\nimport \"core-js/modules/es6.promise\";\nimport \"core-js/modules/es7.promise.finally\";\nimport \"regenerator-runtime/runtime\";\n// 此处省略了144个包。。。\n```\n\n`\"usage\"`\n\n如果`useBuiltIns`设置为`\"usage\"`,我们无需安装`@babel/polyfill`,`babel`会根据你实际用到的语法特性导入相应的`polyfill`,有点按需加载的意思。\n\n```javascript\n// 上个例子中,如果改用useBuiltIns: \'usage\',最终转换的结果,只有四个模块\nimport \"core-js/modules/es6.object.define-property\";\nimport \"core-js/modules/es6.promise\";\nimport \"core-js/modules/es6.object.to-string\";\nimport \"core-js/modules/es6.function.name\";\n```\n\n配置`\"usage\"`时,常搭配`corejs`选项来指定`core-js`主版本号\n\n```javascript\nuseBuiltIns: \"usage\",\ncorejs: 3\n```\n\n`false`\n\n如果`useBuiltIns`设置为`false`,`babel`不会自动为每个文件加上`polyfill`,也不会把`import \"@babel/polyfill\"`转为一个个独立的`core-js`模块。\n\n- `@babel/preset-env`还有一些配置,自己慢慢去折腾吧......\n\n### stage-x\n\n`stage-x`描述的是`ECMA`标准相关的内容。根据`TC39`(`ECMA`39号技术专家委员会)的提案划分界限,`stage-x`大致分为以下几个阶段:\n\n- stage-0:`strawman`,还只是一种设想,只能由`TC39`成员或者`TC39`贡献者提出。\n- stage-1:`proposal`,提案阶段,比较正式的提议,只能由`TC39`成员发起,这个提案要解决的问题须有正式的书面描述,一般会提出一些案例,以及`API`,语法,算法的雏形。\n- stage-2:`draft`,草案,有了初始规范,必须对功能的语法和语义进行正式描述,包括一些实验性的实现,也可以提出一些待办事项。\n- stage-3:`condidate`,候选,该提议基本已经实现,需要等待实践验证,用户反馈及验收测试通过。\n- stage-4:`finished`,已完成,必须通过`Test262`验收测试,下一步就是纳入到`ECMA`标准中。比如一些`ES2016`,`ES2017`的语法就是通过这个阶段被合入`ECMA`标准中了。\n\n有兴趣了解的可以关注[ecma262](https://github.com/tc39/ecma262)。\n\n> 需要注意的是,babel@7已经移除了stage-x的preset,stage-4部分的功能已经被@babel/preset-env集成了,而如果你需要stage <= 3部分的功能,则需要自行通过plugins组装。\n\n```\nAs of v7.0.0-beta.55, we\'ve removed Babel\'s Stage presets.\nPlease consider reading our blog post on this decision at\nhttps://babeljs.io/blog/2018/07/27/removing-babels-stage-presets\nfor more details. TL;DR is that it\'s more beneficial in the long run to explicitly add which proposals to use.\nIf you want the same configuration as before:\n{\n \"plugins\": [\n // Stage 2\n [\"@babel/plugin-proposal-decorators\", { \"legacy\": true }],\n \"@babel/plugin-proposal-function-sent\",\n \"@babel/plugin-proposal-export-namespace-from\",\n \"@babel/plugin-proposal-numeric-separator\",\n \"@babel/plugin-proposal-throw-expressions\",\n // Stage 3\n \"@babel/plugin-syntax-dynamic-import\",\n \"@babel/plugin-syntax-import-meta\",\n [\"@babel/plugin-proposal-class-properties\", { \"loose\": false }],\n \"@babel/plugin-proposal-json-strings\"\n ]\n}\n```\n\n### 自己写preset\n\n如需创建一个自己的`preset`,只需导出一份配置即可,主要是通过写`plugins`来实现`preset`。此外,我们也可以在自己的`preset`中包含第三方的`preset`。\n\n```javascript\nmodule.exports = function() {\n return {\n // 增加presets项去包含别人的preset\n presets: [\n require(\"@babel/preset-env\")\n ],\n // 用插件来包装成自己的preset\n plugins: [\n \"pluginA\",\n \"pluginB\",\n \"pluginC\"\n ]\n };\n}\n```\n\n# @babel/runtime\n\n`babel`运行时,很重要的一个东西,它一定程度上决定了你产出的包的大小!一般适合于组件库开发,而不是应用级的产品开发。\n\n## 说明\n\n这里有两个东西要注意,一个是`@babel/runtime`,它包含了大量的语法转换包,会根据情况被按需引入。另一个是`@babel/plugin-transform-runtime`,它是插件,负责在`babel`转换代码时分析词法语法,分析出你真正用到的`ES6+`语法,然后在`transformed code`中引入对应的`@babel/runtime`中的包,实现按需引入。\n\n举个例子,我用到了展开运算符`...`,那么经过`@babel/plugin-transform-runtime`处理后的结果是这样的:\n\n```javascript\n/* 0 */\n/***/ (function(module, exports, __webpack_require__) {\n\nvar arrayWithoutHoles = __webpack_require__(2);\n\nvar iterableToArray = __webpack_require__(3);\n\nvar nonIterableSpread = __webpack_require__(4);\n\nfunction _toConsumableArray(arr) {\n return arrayWithoutHoles(arr) || iterableToArray(arr) || nonIterableSpread();\n}\n\nmodule.exports = _toConsumableArray;\n \n// EXTERNAL MODULE: ../node_modules/@babel/runtime/helpers/toConsumableArray.js\nvar toConsumableArray = __webpack_require__(0);\nvar toConsumableArray_default = /*#__PURE__*/__webpack_require__.n(toConsumableArray);\n```\n\n## 安装和简单配置\n\n`@babel/runtime`是需要按需引入到生产环境中的,而`@babel/plugin-transform-runtime`是`babel`辅助插件。因此安装方式如下:\n\n```\nnpm i --save @babel/runtime\nnpm i --save-dev @babel/plugin-transform-runtime\n```\n\n配置时也挺简单:\n\n```javascript\nconst buildConfig = {\n presets: [\n // ......\n ],\n plugins: [\n \"@babel/plugin-transform-runtime\"\n ],\n // ......\n}\n```\n\n## @babel/runtime和useBuiltIns: \'usage\'有什么区别?\n\n两者看起来都实现了按需加载的能力,但是实际上作用是不一样的。`@babel/runtime`处理的是语法支持,把新的语法糖转为目标环境支持的语法;而`useBuiltIns: \'usage\'`处理的是垫片`polyfill`,为旧的环境提供新的全局对象,如`Promise`等,提供新的原型方法支持,如`Array.prototype.includes`等。如果你开发的是组件库,一般不建议处理`polyfill`的,应该由调用者去做这些支持,防止重复的`polyfill`。\n\n- 开发组件时,如果仅使用`@babel/plugin-transform-runtime`\n\n![@babel/runtime打包分析](https://qncdn.wbjiang.cn/babel-runtime.png)\n\n- 加上`useBuiltIns: \'usage\'`,多了很多不必要的包。\n\n![@babel/runtime + useBuiltIns: \'usage\'打包分析](https://qncdn.wbjiang.cn/babel-runtime%E4%BB%A5%E5%8F%8AuseBuiltIns.png)\n\n# babel@7要注意的地方\n\n最后简单地提一下使用`babel@7`要注意的地方,当然更详细的内容还是要看[babel官方](https://babeljs.io/docs/en/v7-migration)。\n\n- `babel@7`相关的包命名都改了,基本是`@babel/plugin-xxx`, `@babel/preset-xxx`这种形式。这是开发插件体系时一个比较标准的命名和目录组织规范。\n- 建议用`babel.config.js`代替`.babelrc`,这在你要支持不同环境时特别有用。\n- `babel@7`已经移除了`stage-x`的`presets`,也不鼓励再使用`@babel/polyfill`。\n- 不要再使用`babel-preset-es2015`, `babel-preset-es2016`等`preset`了,应该用`@babel/preset-env`代替。\n- ......\n\n# 结语\n\n本人只是对`babel`有个粗略的认识,所以这是一篇`babel`入门的简单介绍,并没有提到深入的内容,可能也存在错误之处。自己翻来覆去也看过好几遍`babel`的文档了,一直觉得收获不大,也没理解到什么东西,在与`webpack`配合使用的过程中,还是有很多疑惑没搞懂的。其实错在自己不该在复杂的项目中直接去实践。在最近重新学习`webpack`和`babel`的过程中,我觉得,对于不是很懂的东西,我们不妨从写一个`hello world`开始,因为不是每个人都是理解能力超群的天才......\n\n-----', '2019-12-17 09:43:47', '2024-08-03 12:21:07', 1, 44, 0, '说实话,我从工作开始就一直在接触babel,然而对于babel并没有一个清晰的认识,只知道babel是用于编译javascript,让开发者能使用超前的ES6+语法进行开发。自己配置babel的时候,总是遇到很多困惑,下面我就以babel@7为例,重新简单认识下babel。', 'https://qncdn.wbjiang.cn/babel.png', 0, 0);
-INSERT INTO `article` VALUES (203, '自动化部署的一小步,前端搬砖的一大步', '在`nodejs`日渐普及的大背景下,**前端工程化**的发展可谓日新月异。构建打包这种日常任务脚本化已经是常态了,`webpack`和`gulp`已经家喻户晓自然不必多说,而**持续集成/持续交付/持续部署**也越来越得到各个前端`Team`的重视,业界也有了很多成熟的概念或者方案,如`Hudson`, `Jenkins`, `Travis CI `, `Circle CI`, `DevOps`, `git hook`。然而对于小白来讲,如果直接上手这些内容,很容易混淆概念,陷入迷茫。如果为了用`Jenkins`而用`Jenkins`,那不是我的做事风格,我必须搞清楚这项技术能给我带来什么。所以我干脆回归问题本质,从最简单的**工作流**入手,先**解决手动部署的效率问题**。\n\n> 前面说这么多废话纯属凑字数,对了,本文讲的内容比较简单,不适合工作流已经很完善的同学\n\n# 自动构建\n\n**构建不是本文的重点**,也不是一篇短文能够讲清楚的,这里就一笔带过了。\n\n## 构建工具\n\n使用主流的构建工具如`webpack`, `gulp`, `rollup`等。\n\n## 构建目标\n\n通过脚本化的形式组织`代码检查`,`编译`,`压缩`,`混淆`,`资源处理`,`devServer`等工作流事务。\n\n# 手动部署\n\n## 踩过的坑\n\n本人曾经也尝试过两种手动部署的方法。\n\n- 搬砖模式,将构建完毕的文件夹通过`xftp`传输到服务器`/usr/share/nginx/html`目录下。\n- 将构建完毕的文件夹用`git`分支管理起来,推送到远程仓库,然后在`linux`服务器上拉取这部分代码。\n\n第一种方法显然已经属于刀耕火种模式了,不过我竟然用了很久。唉,没办法,业务缠身的我只能挤出时间来优化工作流。\n\n第二种方法我自己私下也用过,后来一想,好像可以用[git hook](https://www.git-scm.com/book/zh/v2/自定义-Git-Git-钩子)来改造优化下,也是实现自动部署的好方法。有兴趣的同学可以试试`git hook`。\n\n# 自动部署\n\n## 写脚本\n\n先写个自动构建部署的脚本,主要是包含了切`git`分支,拉取最新代码,构建打包,传输文件到服务器这些步骤。\n\n> scp 命令用于 Linux 之间复制文件和目录\n\n```shell\n#!/bin/bash\ngit checkout develop\ngit pull\nnpm run build:test\nscp -r ./dist/. username@162.81.49.85:/usr/share/nginx/html/projectname/\n```\n\n**ps:**`ip`已经被我胡乱改了一把,别试着攻击我了。\n\n然而我发现在使用部署脚本的过程中,**每次操作都要输入密码**,很烦人。\n\n## ssh认证\n\n虽然很讨厌输密码,但是密码是安全的保证,如果不输入密码,只能通过`ssh`安全访问了。\n\n首先是在自己工作电脑的`~/.ssh`目录下**创建密钥对**。\n\n```shell\nssh-keygen -t rsa\n```\n\n根据个人情况按需修改密钥对的文件名,输入密码时回车即可,代表不需要使用密码\n\n![生成ssh密钥](https://qncdn.wbjiang.cn/生成密钥.png)\n\n接着要**把公钥传输到服务器**上\n\n```shell\nscp ~/.ssh/id_rsa.pub username@162.81.49.85:/home/username/.ssh/authorized_keys\n```\n\n> 如果服务器已经存在`authorized_keys`文件,那么可以直接在服务器上修改`authorized_keys`文件,在文件末加入你自己的`id_rsa.pub`内容即可。\n\n然后我们再修改部署脚本,改用`ssh`认证方式向`linux`服务器传输文件。\n\n```shell\n#!/bin/bash\ngit checkout develop\nnpm run build:test\nscp -i ~/.ssh/id_rsa -r ./dist/. username@162.81.49.85:/usr/share/nginx/html/projectname/\n```\n\n> `scp`的`-i`参数指定传输时使用的密钥文件,这样就可以通过`ssh`安全访问,而不用再每次输入密码了。`-r`参数则是`recursive`,代表递归复制整个目录。\n\n最后我们可以修改下`package.json`,通过`npm scripts`来执行`sh`\n\n```json\n\"scripts\": {\n \"deploy:test\": \"deploy-test.sh\"\n}\n```\n\n配合`vscode`的`npm scripts`快捷方式,用起来就很舒服了。\n\n![npm scripts](https://qncdn.wbjiang.cn/npmscripts.png)\n\n注意,如果`linux`文件**权限不够**也可能报错的,别忘了给`authorized_keys`文件赋予权限,**拥有者可读可写**即可。\n\n```shell\nchmod 600 authorized_keys\n```\n\n好了,按下那个`deploy:test`,静静等待一会吧。此时别忘了扭扭脖子,按按腰啊,程序员还是要注意身体,对自己好一点。\n\n![scp传输中](https://qncdn.wbjiang.cn/scp传输中.png)\n\n随着`bash`窗口的自动关闭,部署工作也画上了句号。\n\n![完工](https://qncdn.wbjiang.cn/fun3.gif)\n\n# last but not least\n\n这里还要考虑的一个问题是,部署过程中会不会造成用户访问问题?\n\n答案是**会影响用户访问**。比如部署脚本执行过程中,已经替换了`index.html`,正在部署静态资源,此时用户正好进入网站,新的`index.html`却访问不到新的静态资源,网页白屏报错。\n\n解决方法是**先上静态资源,再上页面**。因为静态资源经`webpack`构建后都带上了`hash`值,先上静态资源不会影响原有的版本,所以我们还需要再优化下部署脚本,分解下传输过程。\n\n很头疼的是`scp`命令竟然不能忽略文件,这就有点麻烦了。\n\n如果打包后的`dist`根目录文件不算很多,可以考虑手动列举的方式来排列传输顺序。举个例子:\n\n```shell\n#!/bin/bash\ngit checkout develop\ngit pull\nnpm run build:test\nscp -i ~/.ssh/id_rsa -r ./dist/static username@162.81.49.85:/usr/share/nginx/html/projectname/\nscp -i ~/.ssh/id_rsa ./dist/favicon.ico username@162.81.49.85:/usr/share/nginx/html/projectname/favicon.ico\nscp -i ~/.ssh/id_rsa ./dist/element-icons.ttf username@162.81.49.85:/usr/share/nginx/html/projectname/element-icons.ttf\nscp -i ~/.ssh/id_rsa ./dist/element-icons.woff username@162.81.49.85:/usr/share/nginx/html/projectname/element-icons.woff\nscp -i ~/.ssh/id_rsa ./dist/index.html username@162.81.49.85:/usr/share/nginx/html/projectname/index.html\n```\n\n如果觉得这样很傻X,那么可以考虑下`rsync`了,`rsync`是可以通过`--exclude`忽略文件的,这样的话理论上只需要写两条传输命令即可,也不用考虑后续构建可能会新增的内容。不过在`windows`和`linux`之间用`rsync`还是蛮复杂的,留给各位大佬自己探索啦。', '2020-01-16 22:07:18', '2024-10-31 16:41:24', 1, 59, 0, '手撸一个前端自动部署脚本', 'https://qncdn.wbjiang.cn/前端自动化部署海报.png', 0, 0);
-INSERT INTO `article` VALUES (204, '前端自动化部署的深度实践', '年前我也在**自动化部署**这方面下了点功夫,将自己的学习所得在[自动化部署的一小步,前端搬砖的一大步](https://juejin.im/post/5e206168f265da3e2b2d7560)这篇博客中做了分享。感谢两位网友`@_shanks`和`@TomCzHen`的意见,让我有了继续优化部署流程的动力。本文主要是在自动化部署流程中,对**版本管理**和**流程合理性**等方面做了一些改进,配合规范的工作流,使用体验更佳!\n\n# 更新日志自动生成\n\n之前我都是手动修改`CHANGELOG.md`,用来记录更新日志,感觉操作起来有点心累,也不是很规范。好在已有前人种树,于是我就考虑利用`conventional-changelog-cli`自动生成和更新`CHANGELOG.md`,真的好用!\n\n![真香警告](https://qncdn.wbjiang.cn/真香(程序员版).gif)\n\n## 什么是conventional-changelog\n\n> Generate a changelog from git metadata\n\n根据`git`元数据生成更新日志,而`conventional-changelog-cli`则是相关的命令行工具。\n\n## 安装conventional-changelog-cli\n\n```shell\nnpm install -g conventional-changelog-cli\n```\n\n## 初始化生成CHANGELOG.md\n\n```shell\ncd my-project\nconventional-changelog -p angular -i CHANGELOG.md -s\n```\n\n以上命令是基于最后一次的`Feature`, `Fix`, `Performance Improvement or Breaking Changes`等类型的`commit`记录生成或更新`CHANGELOG.md`。如果你希望根据之前所有的`commit`记录生成完整的`CHANGELOG.md`,那么可以试试下面这条命令:\n\n```shell\nconventional-changelog -p angular -i CHANGELOG.md -s -r 0\n```\n\n# 工作流\n\n## 代码添加到暂存区\n\n这一步没有什么特殊,日常撸代码,然后将工作区的内容添加到暂存区。\n\n```shell\ngit add .\n```\n\n## 规范commit message\n\n> 一个规范的commit message一般分为三个部分Header,Body 和 Footer。Header包含type, scope, subject等部分,分别用于描述commit类型,影响范围,commit简述。Body则是详细描述,可以分多行写。Footer主要用于描述不兼容改动(Breaking Change)或者关闭issue(Closes #issue)。\n\n格式如下:\n\n```xml\n(): \n\n\n\n\n```\n\n举个栗子:\n\n```\nfeat(支持自动部署): 结合conventional-changelog,配合部署脚本完成部署任务\n\nconventional-changelog是一个很好的工具,用于自动生成changelog,再配上自定义的部署脚本,整个部署流程就显得更规范了\n\nBreaking Change: 比较大的更新\nCloses #315\n```\n\n其中,`Header`是必需的,`Body`和`Footer`可以省略。\n\n大致了解规范后,就可以上工具了,这里我们用到的是`commitizen`。\n\n```shell\nnpm install -g commitizen\n```\n\n接着在项目根目录运行以下命令:\n\n```shell\ncommitizen init cz-conventional-changelog --save --save-exact\n```\n\n运行成功后,`package.json`会新增如下内容:\n\n```json\n\"devDependencies\": {\n \"cz-conventional-changelog\": \"^3.1.0\"\n},\n\"config\": {\n \"commitizen\": {\n \"path\": \"./node_modules/cz-conventional-changelog\"\n }\n}\n```\n\n`git commit`这一步用`git cz替代`,`cz`就是指`commitizen`,通过交互式命令行完成`commit`操作。\n\n```shell\nPS D:\\robin\\frontend\\spa-blog-frontend> git cz\ncz-cli@4.0.3, cz-conventional-changelog@3.1.0\n\n? Select the type of change that you\'re committing: feat: A new feature\n? What is the scope of this change (e.g. component or file name): (press enter to skip) 支持自动部署\n? Write a short, imperative tense description of the change (max 86 chars):\n (37) 结合conventional-changelog,配合部署脚本完成部署任务\n? Provide a longer description of the change: (press enter to skip)\n\n? Are there any breaking changes? No\n? Does this change affect any open issues? No\n[master ee41f35] feat(支持自动部署): 结合conventional-changelog,配合部署脚本完成部署任务\n 3 files changed, 15 insertions(+), 3 deletions(-)\n```\n\n## 处理版本号,更新CHANGELOG\n\n接着我们要更新`npm`包的版本号,结合`npm version`和`conventional-changelog`使用,可以同时更新`CHANGELOG.md`。\n\n好的,我们先准备好脚本:\n\n```json\n\"scripts\": {\n \"start\": \"vue-cli-service serve\",\n \"build\": \"vue-cli-service build\",\n \"deploy\": \"node deploy\",\n \"version\": \"conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md\",\n \"postversion\": \"npm run deploy\"\n}\n```\n\n根据实际版本情况选择更新`patch/minor/major`版本。假设我们更新的是`minor`版本号,那么操作命令如下:\n\n```shell\nnpm version minor -m \'特性版本更新\'\n```\n\n执行这条命令会更新`package.json`中的`version`字段,\n\n同时会执行`conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md`,更新`CHANGELOG.md`。\n\n执行完这条命令后,可以看到`CHANGELOG.md`已经被修改了。\n\n![CHANGELOG自动更新](https://qncdn.wbjiang.cn/CHANGELOG自动更新.png)\n\n## npm钩子触发部署脚本\n\n通过`postversion`钩子触发部署脚本`node deploy`,开始进行部署工作。`deploy.js`文件内容如下:\n\n```javascript\nconst { execFile } = require(\'child_process\');\n\nconst version = process.env.npm_package_version;\n\nexecFile(\'deploy.sh\', [version], { shell: true }, (err, stdout, stderr) => {\n if (err) {\n throw err;\n }\n console.log(stdout);\n});\n```\n\n这里利用了`nodejs`的 `child_process`模块执行子进程,调用了`execFile`执行了 `deploy.sh`,并将`npm`包版本号作为参数传递给了`deploy.sh`。\n\n`deploy.sh`文件内容如下:\n\n```shell\n#!/bin/bash\nnpm run build\nhtmldir=\"/usr/share/nginx/html\"\nuploadbasedir=\"${htmldir}/upgrade_blog_vue_ts\"\nappenddir=$1\nuploaddir=\"${uploadbasedir}/${appenddir}\"\nprojectdir=\"/usr/share/nginx/html/blog_vue_ts\"\nscp -r ./dist/. txcloud:${uploaddir}\nssh txcloud > /dev/null 2>&1 << eeooff\nln -snf ${uploaddir} ${projectdir}\nexit\neeooff\necho done\n```\n\n以上命令主要做的事情是:\n\n- `npm run build`执行构建任务\n- 将构建得到的`dist`文件夹中的内容通过`scp`传输到服务器,通过版本号区分各个版本。\n- `nginx`配置的是监听`80`端口,指向`/usr/share/nginx/html/blog_vue_ts`,而我通过软连接将`blog_vue_ts`再次指向到`upgrade_blog_vue_ts`下的版本目录,如`upgrade_blog_vue_ts/0.5.4`。每次发布版本时,以上脚本会修改软连接,指向目标版本,如`upgrade_blog_vue_ts/0.6.0`,完成版本过渡。\n\n我这里使用了软连接改进了之前的部署脚本,既可以在服务器保留各个历史版本文件夹,也不用考虑处理`index.html`与静态资源分离的问题。\n\n**强烈建议结合[自动化部署的一小步,前端搬砖的一大步](https://juejin.im/post/5e206168f265da3e2b2d7560)这篇文章一起看。**\n\n```shell\nlrwxrwxrwx 1 root root 47 Feb 3 21:35 blog_vue_ts -> /usr/share/nginx/html/upgrade_blog_vue_ts/0.6.0\n```\n\n![linux服务器项目版本文件夹](https://qncdn.wbjiang.cn/linux服务器项目版本文件夹.png)\n\n如果要回退版本,也可以通过修改软连接的方式实现,还是比较方便的。\n\n## 推送到remote\n\n最后别忘了把代码`push`到远程仓库。\n\n```shell\ngit push\n```\n\n更新日志`changelog`查看也变得很方便了,修改了什么内容一目了然,并且可以直接跳转到`commit`历史,`issue`等。\n\n![github上的changelog](https://qncdn.wbjiang.cn/github上的changelog.png)\n\n# 番外\n\n可以看到,我是通过`deploy.js`调用了`deploy.sh`。之前本想直接在`npm scripts`中调用`deploy.sh`并传入版本号参数的,但是试了几种写法都不行,这里也记录一下。\n\n```json\n\"deploy\": \"deploy.sh npm_package_version\"\n```\n\n```shell\n\"deploy\": \"deploy.sh $npm_package_version\"\n```\n\n看起来在`npm scripts`中调用`sh`脚本时,只能写字面量参数,传变量作为参数好像行不通。\n\n下面这种字面量参数写法是可以的,但是就有点呆呆的感觉了,而且与自动化部署的主题不符。\n\n```shell\n\"deploy\": \"deploy.sh 0.6.0\"\n```\n\n所以我目前还是选择通过`deploy.js`作为中间者来调用`deploy.sh`的。\n\n# 结语\n\n需要承认的是,我以上所述的部署流程是以我的个人项目为例说明,可能不是很规范,但是也算是通过自己的理解和摸索,完整地搞了一套部署流程,并没有借用`jenkins`等工具。有了这段自动化部署的学习经历后,相信学习和使用`jenkins`会变得更轻松。接下来我会继续优化和规范自己的部署流程,`jenkins`理所当然会出现在我的计划表中。', '2020-02-04 12:16:14', '2024-10-31 16:41:03', 1, 50, 0, '年前我也在自动化部署这方面下了点功夫,将自己的学习所得在自动化部署的一小步,前端搬砖的一大步这篇博客中做了分享。感谢两位网友@_shanks和@TomCzHen的意见,让我有了继续优化部署流程的动力。', 'https://qncdn.wbjiang.cn/自动化部署深度实践.png', 0, 0);
-INSERT INTO `article` VALUES (205, '共克时疫,https+小程序为“战疫”献上一份技术力量', '# 前言\n\n新型冠状病毒笼罩下的新年,让每个中国人都感到恐慌和揪心。我们每天为前线的白衣天使和平民英雄们的事迹感动而落泪,也为不法分子哄抬物价,无良个人以权谋私等自私自利的行为而感到痛心疾首。作为普通人,我们最大的贡献就是宅在家里,响应钟南山院士的号召,**做好个人防护,不为疫情添负担,不为他人添麻烦**。最近看到很多大佬都为“**战疫**”贡献了自己的技术力量,有的人提供了数据和接口支持,有的人做了`app`,有的人做了`webapp`。看到这些举动,我也跃跃欲试,静下心去做,总会做点东西出来,于是我做了一版微信小程序,主要是想方便自己和家人朋友们查询下最新的数据,毕竟大家都用微信。\n\n> 微信小程序的版本审核实在太慢了,昨天提交版本审核的,现在还没通过,唉,心累。\n\n# 数据获取和处理\n\n首先要感谢丁香园,数据源于丁香园-丁香医生。\n\n重点要感谢[掘金@普通程序员](https://juejin.im/user/5e2741925188254baf6c4cb1/activities)提供的数据接口能力,让我们菜鸡也有机会做一点微小的工作。\n\n为了防止给大佬的服务器增加访问压力,我每15分钟抓取一次接口数据,存储于个人服务器上,供自己和他人访问和使用。\n\n> Q: 为什么别人有提供接口,你还要再多此一举?A:我要做小程序,没有https搞不了。\n\n目前主要上线了以下接口:\n\n在线接口基地址: `https://wuhan.wbjiang.cn/api/`\n\n| 接口名 | 请求方式 | 接口描述 |\n| -------------------- | -------- | ------------------------------------------------------------ |\n| timeline | GET | 获取发生的事件,支持分页参数pageNo和pageSize |\n| stats | GET | 整体统计数据 |\n| rumour | GET | 最新辟谣 |\n| protect_wiki | GET | 最新防护知识 |\n| wiki | GET | 最新知识百科 |\n| help_links | GET | 便民信息/诊疗信息 |\n| province_stats | GET | 全国省份级患者分布数据 |\n| city_stats/:areaName | GET | 根据省份查市县疫情数据,areaName传入省级行政区的简写,如“湖南” |\n| oversea_stats | GET | 全球海外其他地区患者分布数据 |\n\n可以点击[在线访问整体统计数据](https://wuhan.wbjiang.cn/api/stats)试试看呢!\n\n```\nhttps://wuhan.wbjiang.cn/api/stats\n```\n\n该服务的源码我也上传到了`github`,欢迎访问[wuhan_best_wishes](https://github.com/cumt-robin/wuhan_best_wishes)查看,如果能顺手给个`star`那是极好的,感谢感谢!\n\n# HTTPS支持\n\n由于**微信小程序**需要调用`https`协议的接口,所以我利用`nginx`的能力和阿里云提供的`SSL`证书,对上述接口提供了`https`支持。\n\n# 服务整体框架\n\n**接口服务**:使用的是`nodejs`语言,技术框架是`express`。\n\n**应用管理**:利用`pm2`来管理`node`应用。\n\n**代理服务器**:利用`Nginx`监听`80`端口,转发到`node`服务所在的内部端口。\n\n# 小程序概述\n\n取名挺烦的,拟的名字要么是被行业限制,要么已经有人用了。最后就随便取了个名**wuhan速报**。\n\n技术方面,我暂时没有使用框架,用的是小程序原生的开发语言。为了快速出第一版效果,`UI`部分用到了我熟悉的`vant-weapp`。\n\n相关代码已开源,请访问[ncov-weapp](https://github.com/cumt-robin/ncov_weapp)查看源码。\n\n先发个小程序码,方便大家直接访问小程序(**暂时还没通过审核**,微信小程序审核速度你懂的,如果想体验一下的话,欢迎加我微信ice_lloly使用体验版)。\n\n![wuhan速报小程序码](https://qncdn.wbjiang.cn/武汉速报小程序码.jpg)\n\n# 小程序内容\n\n内容上,主要做了四个页面,分为**疫情地图**,**辟谣与防护**,**事件播报**,**疾病知识**等几块。\n\n> 疫情统计数据\n\n![首页-数据统计](https://qncdn.wbjiang.cn/统计数据.jpg)\n\n> 疫情地图与趋势\n\n\n\n![疫情地图和趋势图](https://qncdn.wbjiang.cn/疫情地图及趋势.jpg)\n\n> 国内省市疫情分布\n\n![地区疫情数据](https://qncdn.wbjiang.cn/国内疫情.jpg)\n\n> 海外疫情分布\n\n![海外疫情分布](https://qncdn.wbjiang.cn/海外疫情.jpg)\n\n> 辟谣与防护\n\n![辟谣与防护](https://qncdn.wbjiang.cn/谣言与防护.jpg)\n\n> 最新事件实时播报\n\n![事件播报](https://qncdn.wbjiang.cn/事件播报.jpg)\n\n> 疾病知识\n\n![疾病知识](https://qncdn.wbjiang.cn/疾病知识.jpg)\n\n\n\n为了快速出效果,做的时候有参考丁香园的设计,感谢丁香园技术和设计团队!\n\n# 结语\n\n由于时间有限,大概花了一天多的时间吧,所以做出来的效果是比较粗糙的。接下来我会在有余力的情况下,继续迭代更新,毕竟还是要远程办公的,大部分时间还是要聚焦于公司业务。', '2020-02-08 09:22:54', '2024-08-15 10:12:03', 1, 57, 0, '新型冠状病毒笼罩下的新年,让每个中国人都感到恐慌和揪心。作为程序员,我尽力了...', 'https://qncdn.wbjiang.cn/武汉加油.png', 0, 0);
-INSERT INTO `article` VALUES (206, '前端小微团队的Gitlab实践', '疫情期间我感觉整个人懒散了不少,慢慢有意识要振作起来了,恢复到正常的节奏。最近团队代码库从`Gerrit`迁移到了`Gitlab`,为了让前端团队日常开发工作**有条不紊**,**高效运转**,开发历史**可追溯**,我也查阅和学习了不少资料。参考业界主流的**Git工作流**,结合公司业务特质,我也梳理了一套**适合自己团队的Git工作流**,在这里做下分享。\n\n# 分支管理\n\n首先要说的是分支管理,分支管理是`git`工作流的基础,好的分支设计有助于规范开发流程,也是`CI/CD`的基础。\n\n## 分支策略\n\n业界主流的`git`工作流,一般会分为`develop`, `release`, `master`, `hotfix/xxx`, `feature/xxx`等分支。各个分支各司其职,贯穿了整个**开发,测试,部署**流程。我这里也基于主流的分支策略做了一些定制,下面用一张表格简单概括下:\n\n| 分支名 | 分支定位 | 描述 | 权限控制 |\n| ----------- | :------------- | ------------------------------------------------------------ | ----------------------------------------- |\n| develop | 开发分支 | 不可以在develop分支push代码,应新建feature/xxx进行需求开发。迭代功能开发完成后的代码都会merge到develop分支。 | Develper不可直接push,可发起merge request |\n| feature/xxx | 特性分支 | 针对每一项需求,新建feature分支,如feature/user_login,用于开发用户登录功能。 | Develper可直接push |\n| release | 提测分支 | 由develop分支合入release分支。ps: 应配置此分支触发CI/CD,部署至测试环境。 | Maintainer可发起merge request |\n| bug/xxx | 缺陷分支 | 提测后发现的bug,应基于`develop`分支创建`bug/xxx`分支修复缺陷,修改完毕后应合入develop分支等待回归测试。 | |\n| master | 发布分支 | master应处于随时可发布的状态,用于对外发布正式版本。ps: 应配置此分支触发CI/CD,部署至生产环境。 | Maintainer可发起merge request |\n| hotfix/xxx | 热修复分支 | 处理线上最新版本出现的bug | Develper可直接push |\n| fix/xxx | 旧版本修复分支 | 处理线上旧版本的bug | Develper可直接push |\n\n一般来说,`develop`, `release`, `master`分支是必备的。而`feature/xxx`, `bug/xxx`, `hotfix/xxx`, `fix/xxx`等分支纯属一种语义化的分支命名,如果要简单粗暴一点,这些分支可以不分类,都命名为`issue/issue号`,比如`issue/1`,但是要在`issue`中说明具体问题和待办事项,保证开发工作可追溯。\n\n## 保护分支\n\n利用`Protected Branches`,我们可以防止开发人员错误地将代码`push`到某些分支。对于普通开发人员,我们仅对`develop`分支提供`merge`权限。\n\n![保护分支](https://qncdn.wbjiang.cn/%E4%BF%9D%E6%8A%A4%E5%88%86%E6%94%AF.png)\n\n具体操作案例请前往下面的**实战案例**一节查看。\n\n# issue驱动工作\n\n我们团队采用的**敏捷开发**协作平台是腾讯的[TAPD](https://www.tapd.cn/ \'TAPD\'),日常迭代需求,缺陷等都会在`TAPD`上记录。为了让`Gitlab`代码库能与迭代日常事务关联上,我决定用`Gitlab issues`来做记录,方便追溯问题。\n\n## 里程碑\n\n**里程碑Milestone**可以认为是一个**阶段性的目标**,比如是一轮迭代计划。里程碑可以设定时间范围,用来约束和提醒开发人员。\n\n![milestones](https://qncdn.wbjiang.cn/%E9%87%8C%E7%A8%8B%E7%A2%91.png)\n\n里程碑可以**拆解为N个issue**,新建`issue`时可以**关联里程碑**。比如这轮迭代一共5个需求,那么就可以新建5个`issue`。在约定的时间范围内,如果一个里程碑关联的所有`issue`都`Closed`掉了,就意味着目标顺利达成。\n\n![创建issue](https://qncdn.wbjiang.cn/%E5%88%9B%E5%BB%BAissue.png)\n\n## 标签\n\n`Gitlab`提供了`label`来标识和分类`issue`,我觉得这是一个非常好的功能。我这里列举了几种`label`,用来标识`issue`的**分类**和**紧急程度**。\n\n![标签管理](https://qncdn.wbjiang.cn/%E6%A0%87%E7%AD%BE%E7%AE%A1%E7%90%86.png)\n\n## issue分类\n\n所有的开发工作应该通过`issue`记录,包括但不限于**需求**,**缺陷**,**开发自测试**,**用户体验**等范畴。\n\n### 需求&缺陷\n\n这里大概又分为两种情况,一种是`TAPD`记录在案的需求和缺陷,另一种是与产品或测试人员口头沟通时传达的简单需求或缺陷(小公司会有这种情况...)。\n\n对于`TAPD`记录的需求和缺陷,创建`issue`时应附上链接,方便查阅(上文中已有提到)。\n\n对于口头沟通的需求和缺陷,我定了个规则,要求提出人本人在`Gitlab`上创建`issue`,并将需求或缺陷简单描述清楚,否则口头沟通的开发工作我不接(也是为了避免事后扯皮)。\n\n**ps**:其实要求产品或者测试提`issue`,还不如上`Tapd`记录。我定这么个规则,其实就是借`Gitlab`找个说辞,**杜绝口头类需求或缺陷**,哈哈。\n\n### 开发自测试\n\n开发者自己发现了系统缺陷或问题,此时应该通过`issue`记录问题,并创建相应分支修改代码。\n\n![自测试issue](https://qncdn.wbjiang.cn/%E8%87%AA%E6%B5%8B%E8%AF%95issue.png)\n\n# 实战案例\n\n我前面也说了,我的原则是`issue`驱动开发工作。\n\n下面用几个例子来简单说明基本的开发流程。小公司里整个流程比较简单,没有复杂的集成测试,多轮验收测试,灰度测试等。我甚至连单元测试都没做(捂脸...)。\n\n> 公共库和公共组件其实是很有必要做单元测试的,这里立个flag,后面一定补上单元测试。\n\n## 需求开发\n\n> feature/1,一个特性分支,对应issue 1\n\n### 创建需求\n\n正常的需求当然来源于产品经理等需求提出方,由于是通过示例说明,这里我自己在`TAPD`上模拟着写一个需求。\n\n![TAPD创建需求](https://qncdn.wbjiang.cn/TAPD%E5%88%9B%E5%BB%BA%E9%9C%80%E6%B1%82.png)\n\n### 创建issue\n\n创建`Gitlab issue`,链接到`TAPD`中的相关需求。\n\n![创建issue](https://qncdn.wbjiang.cn/%E5%88%9B%E5%BB%BAissue.png)\n\n![一个issue](https://qncdn.wbjiang.cn/%E4%B8%80%E4%B8%AAissue.png)\n\n### 创建分支&功能开发\n\n基于`develop`分支创建`feature`分支进行功能开发(要保证本地git仓库当前处于develop分支,且与远程仓库develop分支同步)。\n\n```shell\ngit checkout -b feature/1\n```\n\n或者直接以远程仓库的`develop`分支为基础创建分支。\n\n```\ngit checkout -b feature/1 origin/develop\n```\n\nps:我这里用的`feature/1`作为分支名,其实这里的`1`是用的`issue`号,并没有用诸如`feature/login_verify`之类的名字,是因为我觉得用`issue`号可以更方便地找到对应的`issue`,更容易追踪代码。\n\n接着我们开始开发新功能......\n\n![快乐地撸代码](https://qncdn.wbjiang.cn/%E5%BF%AB%E4%B9%90%E5%9C%B0%E6%92%B8%E4%BB%A3%E7%A0%81.gif)\n\n### commit & push\n\n完成功能开发后,我们需要提交代码并同步到远程仓库。\n\n```\nPS D:\\projects\\gitlab\\project_xxx> git add .\nPS D:\\projects\\gitlab\\project_xxx> git cz\ncz-cli@4.0.3, cz-conventional-changelog@3.1.0\n\n? Select the type of change that you\'re committing: feat: A new feature\n? What is the scope of this change (e.g. component or file name): (press enter to skip)\n? Write a short, imperative tense description of the change (max 94 chars):\n (9) 登录校验功能\n? Provide a longer description of the change: (press enter to skip)\n\n? Are there any breaking changes? No\n? Does this change affect any open issues? Yes\n? If issues are closed, the commit requires a body. Please enter a longer description of the commit itself:\n -\n? Add issue references (e.g. \"fix #123\", \"re #123\".):\n fix #1\n\ngit push origin HEAD\n```\n\n`git cz`是利用了`commitizen`来替代`git commit`。详情请点击[前端自动化部署的深度实践](https://juejin.im/post/5e38ec1ce51d4526c932a4fb)深入了解。\n\n`fix #1`用于关闭`issue 1`。\n\n`git push origin HEAD`则代表推送到远程仓库同名分支。\n\n### 创建Merge Request\n\n开发人员发起`Merge Request`,请求将自己开发的功能特性合入`develop`分支。\n\n![创建Merge Request](https://qncdn.wbjiang.cn/%E5%88%9B%E5%BB%BAmerge%20request.png)\n\n接着`Maintainer`需要**Review代码**,确认无误后**同意Merge**。然后这部分代码就在远程`Git`仓库入库了,其他开发同学拉取`develop`分支就能看到了。\n\n## 版本提测\n\n> issue/2,处理更新日志,版本号等内容,对应issue 2\n\n每个团队的开发节奏都不同,有的团队会每日**持续集成**发版本提测,有的可能两天一次,这个就不深入讨论了......\n\n那么当我们准备提测时,应该怎么做呢?\n\n通过上节的了解,我们已经知道,迭代内的功能需求都会通过`feature/xxx`分支合入到`develop`分支。\n\n提测前,一般来说,还是要更新下`CHANGELOG.md`和`package.json`的版本号,可以由`Maintainer`或其他负责该项事务的同学执行。\n\n> 主要是执行npm version major/minor/patch -m \'something done\',具体操作可以参考[前端自动化部署的深度实践](https://juejin.im/post/5e38ec1ce51d4526c932a4fb#heading-7)一文。\n\n```\ngit checkout -b issue/2 origin/develop\nnpm version minor -m \'迭代1第一次提测\'\ngit push origin HEAD\n然后发起merge request合入develop分支\n```\n\n此时,应以最新的`develop`分支代码在开发环境跑一遍功能,保证版本自测通过。\n\n提测时,由`Maintainer`发起`Merge Request`,将`develop`分支代码合入`release`分支,此时自动触发`Gitlab CI/CD`,自动构建并发布至**测试环境**。\n\n版本提测后,各责任人应在`TAPD`上将相关需求和缺陷的状态变更为**“测试中”**。\n\n## 修复测试环境bug\n\n> bug/3,一个bug分支,对应issue 3\n\n这里说的是在迭代周期内由测试工程师发现的测试环境中的系统`bug`,这些`bug`会被记录在敏捷开发协作平台`TAPD`上。修复测试环境`bug`的步骤与开发需求类似,这里简单说下步骤:\n\n1. **在Gitlab上创建issue**\n\n > 创建issue,并附上TAPD上的缺陷链接,方便追溯\n\n2. **创建分支&修复缺陷**\n\n 基于`develop`分支创建分支:\n\n ```\n // 3是issue号\n git checkout -b bug/3 origin/develop\n ```\n\n 接着改代码......\n\n3. **commit & push**\n\n ```\n PS D:\\projects\\gitlab\\project_xxx> git cz\n cz-cli@4.0.3, cz-conventional-changelog@3.1.0\n \n ? Select the type of change that you\'re committing: fix: A bug fix\n ? What is the scope of this change (e.g. component or file name): (press enter to skip)\n ? Write a short, imperative tense description of the change (max 95 chars):\n (11) 修复一个测试环境bug\n ? Provide a longer description of the change: (press enter to skip)\n \n ? Are there any breaking changes? No\n ? Does this change affect any open issues? Yes\n ? If issues are closed, the commit requires a body. Please enter a longer description of the commit itself:\n -\n ? Add issue references (e.g. \"fix #123\", \"re #123\".):\n fix #3\n \n git push origin HEAD\n ```\n\n4. **发起Merge Request**\n\n 开发人员发起`Merge Request`,请求将自己修复缺陷引入的代码合入`develop`分支。\n\n 然后`Maintainer`需要**Review代码**,同意本次`Merge Request`。\n\n5. **等待回归测试**\n\n 该`bug`将在下一次`CI/CD`后,进入回归测试流程。\n\n6. **级别高的测试环境Bug**\n\n 如果是级别很高的`bug`,比如影响了系统运行,导致测试人员无法正常测试的,应以`release`分支为基础创建`bug`分支,修改完毕后合入`release`分支,再发起`cherry pick`合入`develop`分支。\n\n## 发布至生产环境\n\n经过几轮持续集成和回归测试后,一个迭代版本也慢慢趋于稳定,此时也迎来了激动人心的上线时间。我们要做的就是把通过了测试的`release`分支合入`master`分支。\n\n![release合入master](https://qncdn.wbjiang.cn/release%E5%90%88%E5%85%A5master.png)\n\n这一步相对简单,但是要特别注意权限控制(**防止生产环境事故**),具体权限控制可以回头看第一章节**分支管理**。\n\n与`release`分支类似,`master`分支自动触发`Gitlab CI/CD`,自动构建并发布至**生产环境**。\n\n## 线上回滚\n\n> revert/4,一个回滚分支,对应issue 4\n\n代码升级到线上,但是发现报错,系统无法正常运行。此时如果不能及时排查出问题,那么只能先进行版本回退操作。\n\n先说说**惯性思维**下,我的版本回退做法。\n\n首先还是创建`issue`记录下:\n\n![创建记录回滚的issue](https://qncdn.wbjiang.cn/%E5%88%9B%E5%BB%BA%E5%9B%9E%E6%BB%9A%E7%9A%84issue.png)\n\n基于`master`分支创建`revert/4`分支\n\n```\ngit checkout -b revert/4 origin/master\n```\n\n假设当前版本是`1.1.0`,我们想回退到上个版本`1.0.3`。那么我们需要先查看下`1.0.3`版本的信息。\n\n```\nPS D:\\tusi\\projects\\gitlab\\projectname> git show 1.0.3\ncommit 90c9170a499c2c5f8f8cf4e97fc49a91d714be50 (tag: 1.0.3, fix/1.0.2_has_bug)\nAuthor: tusi \nDate: Thu Feb 20 13:29:31 2020 +0800\n\n fix:1.0.2\n\ndiff --git a/README.md b/README.md\nindex ac831d0..2ee623b 100644\n--- a/README.md\n+++ b/README.md\n@@ -10,6 +10,8 @@\n\n 只想修改旧版本的bug,不改最新的master\n\n+1.0.2版本还是有个版本,再修复下\n+\n 特性2提交\n\n 特性3提交\n```\n\n主要是取到`1.0.3`版本对应的`commit id`,其值为`90c9170a499c2c5f8f8cf4e97fc49a91d714be50`。\n\n接着,我们根据`commit id`进行`reset`操作,再推送到远程同名分支。\n\n```\ngit reset --hard 90c9170a499c2c5f8f8cf4e97fc49a91d714be50\ngit push origin HEAD\n```\n\n接着发起`Merge Request`把`revert/4`分支合入`master`分支。\n\n![回滚分支合入master](https://qncdn.wbjiang.cn/%E5%9B%9E%E6%BB%9A%E5%88%86%E6%94%AF%E5%90%88%E5%85%A5master.png)\n\n一般来说,这波操作没什么问题,能解决常规的回滚问题。\n\n### 临时变通\n\n由于`master`分支是保护分支,设置了不可`push`。如果不想通过`merge`的方式回滚,所以只能先临时设置`Maintainer`拥有`push`权限,然后由`Maintainer`进行回滚操作。\n\n```\ngit checkout master\ngit pull\ngit show 1.0.3\ngit reset --hard 90c9170a499c2c5f8f8cf4e97fc49a91d714be50\ngit push origin HEAD\n```\n\n完事后,还需要记得把`master`设置为不可`push`。\n\n> Q: 为什么不让`Maintainer`一直拥有`master`的`push`权限?\n>\n> A: 主要还是为了防止出现生产环境事故,给予临时性权限更稳妥!\n\n### git reset --hard存在什么问题?\n\n如题,`git reset --hard`确实是存在问题的。`git reset --hard`属于霸道玩法,直接移动`HEAD`指针,会丢弃之后的提交记录,如果不慎误操作了也别慌,还是可以通过查询`git reflog`找到`commitId`抢救回来的;`git reset`后还存在一个隐性的问题,如果与旧的`branch`进行`merge`操作时,会把`git reset`回滚的代码重新引入。那么怎么解决这些问题呢?\n\n![一筹莫展](https://qncdn.wbjiang.cn/%E7%A8%8B%E5%BA%8F%E5%91%98%E4%BD%95%E8%8B%A6%E4%B8%BA%E9%9A%BE%E7%A8%8B%E5%BA%8F%E5%91%98.gif)\n\n别慌,这个时候你必须掏出`git revert`了。\n\n> Q: `git revert`的优势在哪?\n>\n> A: 首先,`git revert`是通过一次新的commit来进行回滚操作的,HEAD指针向前移动,这样就不会丢失记录;另外,`git revert`也不会引起`merge`旧分支时误引入回滚的代码。最重要的是,`git revert`在回滚的细节控制上更加优秀,可解决部分回滚的需求。\n\n举个栗子,本轮迭代团队共完成需求`2`项,而上线后发现其中`1`项需求有致命性缺陷,需要回滚这个需求相关的代码,同时要保留另一个需求的代码。\n\n![我太难了](https://qncdn.wbjiang.cn/%E6%88%91%E5%A4%AA%E9%9A%BE%E4%BA%86.jpg)\n\n首先我们查看下日志,找下这两个需求的`commitId`\n\n```shell\nPS D:\\tusi\\projects\\test\\git_test> git log --oneline\n86252da (HEAD -> master, origin/master, origin/HEAD) 解决冲突\ne3cef4e (origin/release, release) Merge branch \'develop\' into \'release\'\nf247f38 (origin/develop, develop) 需求2\n89502c2 需求1\n```\n\n我们利用`git revert`回滚需求1相关的代码\n\n```shell\ngit revert -n 89502c2\n```\n\n这时可能要解决冲突,解决完冲突后就可以`push`到远程`master`分支了。\n\n```shell\ngit add .\ngit commit -m \'回滚需求1的相关代码,并解决冲突\'\ngit push origin master\n```\n\n感觉还是菜菜的,如果大佬们有更优雅的解决方案,求指导啊!\n\n## 修复线上bug\n\n> hotfix/5,一个线上热修复分支,对应issue 5\n\n比如线上出现了系统无法登录的`bug`,测试工程师也在`TAPD`提交了缺陷记录。那么修复线上`bug`的步骤是什么呢?\n\n1. 创建`issue`,标题可以从`TAPD`中的`Bug`单中`copy`过来,而描述就贴上`Bug`单的链接即可。\n\n2. 基于`master`分支创建分支`hotfix/5`。\n\n ```\n git checkout -b hotfix/5 origin/master\n ```\n\n3. 撸代码,修复此bug......\n\n4. 修复完此`bug`后,提交该分支代码到远程仓库同名分支\n\n ```\n git push origin HEAD\n ```\n\n5. 然后发起`cherry pick`合入到`master`和`develop`分支,并在`master`分支打上新的版本`tag`(假设当前最大的`tag`是`1.0.0`,那么新的版本`tag`应为`1.0.1`)。\n\n## 修复线上旧版本bug\n\n> fix/6,一个线上旧版本修复分支,对应issue 6\n\n某些项目产品可能会有多个线上版本同时在运行,那么不可避免要解决旧版本的`bug`。针对线上旧版本出现的`bug`,修复步骤与上节类似。\n\n1. 创建`issue`,描述清楚问题\n\n2. 假设当前版本是`1.0.0`,而`0.9.0`版本出了一个`bug`,应基于`tag 0.9.0`创建`fix`分支。\n\n ```\n git checkout -b fix/6 0.9.0\n ```\n\n3. 修复缺陷后,应打上新的`tag 0.9.1`,并推送到远程。\n\n ```\n git tag 0.9.1\n git push origin tag 0.9.1\n ```\n\n4. 如果此`bug`也存在于最新的`master`分支,则需要`git push origin HEAD`提交该`fix`分支代码到远程仓库同名分支,然后发起`cherry pick`合入到`master`,此时很大可能存在冲突,需要解决冲突。\n\n ![cherry pick](https://qncdn.wbjiang.cn/cherry%20pick.png)\n\n## cherry pick\n\n在了解到`cherry pick`之前,我一直认为只有`git merge`可以合并代码,也好几次遇到合入了不想要的代码的问题。有了`cherry pick`,我们就可以合并单次提交记录,解决`git merge`时合并太多不想要的内容的烦恼,在解决`bug`时特别有用。\n\n## git rebase\n\n经过这段时间的使用,我发现使用`git merge`合并分支时,会让`git log`的`Graph`图看起来有点吃力。\n\n```\nPS D:\\tusi\\projects\\gitlab\\projectname> git log --pretty --oneline --graph\n* 7f513b0 (HEAD -> develop) Merge branch \'issue/55\' into \'release\'\n|\\\n| * 1c94437 (origin/issue/55, issue/55) fix: 【bug】XXX1\n| * c84edd6 Merge branch \'release\' of host:project_repository into release\n| |\\\n| |/\n|/|\n* | 115a26c Merge branch \'develop\' into \'release\'\n|\\ \\\n| * \\ 60d7de6 Merge branch \'issue/30\' into \'develop\'\n| |\\ \\\n| | * | 27c59e8 (origin/issue/30, issue/30) fix: 【bug】XXX2\n| | | * ea17250 Merge branch \'release\' of host:project_repository into release\n| | | |\\\n| |_|_|/\n|/| | |\n* | | | 9fd704b Merge branch \'develop\' into \'release\'\n|\\ \\ \\ \\\n| |/ / /\n| * | | a774d26 Merge branch \'issue/30\' into \'develop\'\n| |\\ \\ \\\n| | |/ /\n```\n\n接着我就了解到了`git rebase`,变基,哈哈哈。由于对`rebase`了解不深,目前也不敢轻易改用`rebase`,毕竟`rebase`还是有很多隐藏的坑的,使用起来要慎重!在这里先挖个坑吧,后面搞懂了再填坑......\n\n# 注意事项\n\n1. 一般而言,自己发起的`Merge Request`必须由别的同事`Review`并同意合入,这样更有利于发现代码问题。\n2. 对了,`TAPD`还支持与`Gitlab`协同的。详情见[源码关联指引](https://www.tapd.cn/help/view#1120003271001001346 \'源码关联指引\')。\n\n# 结语\n\n实践证明,这套`Git`工作流目前能覆盖我项目开发过程中的绝大部分场景。不过要注意的是,适合自己的才是最好的,盲目采用别人的方案有时候是会水土不服的。\n\n以上所述纯属前端小微团队内部的`Gitlab`实践,必然存在着很多不足之处,如有错误之处还请指正,欢迎交流。', '2020-03-09 00:06:01', '2024-07-25 02:20:08', 1, 45, 0, 'Gitlab工作流在前端小微团队的落地,推荐收藏!', 'https://qncdn.wbjiang.cn/%E5%89%8D%E7%AB%AFgitlab%E5%B0%81%E9%9D%A2.png', 0, 0);
-INSERT INTO `article` VALUES (207, '记一次Navicat for MySQL 10060错误的解决过程', '最近加班挺多,所以也好久没远程访问自己云服务器上的`MySQL`数据库了。今天本地启动`Node`服务时连不上`MySQL`,照常用`Navicat For MySQL`连接远程数据库进行检查,结果发现突然报错了。\n\n```\n2003-Can’t connect to MySQL server on ‘XXX.XX.XX.XX’(10060)\n```\n\n# 检查网络\n\n第一反应还是检查网络是不是正常,所以就马上`ping`测试一下,然而发现并不是网络问题,可以正常`ping`通。\n\n```\nping XXX.XX.XX.XX\n\n正在 Ping XXX.XX.XX.XX 具有 32 字节的数据:\n来自 XXX.XX.XX.XX 的回复: 字节=32 时间=64ms TTL=47\n来自 XXX.XX.XX.XX 的回复: 字节=32 时间=86ms TTL=47\n```\n\n# 检查安全组\n\n然后就想着看看云服务器的安全组设置是否有问题,但是之前都没出过这个问题,讲道理安全组出现问题的可能性不大,但还是先检查下为妙。\n\n登录腾讯云后,发现实例对应的安全组设置妥妥的,没有什么问题。\n\n![安全组正常](https://s1.ax1x.com/2020/03/17/8t5AeI.png)\n\n# 检查下用户权限\n\n由于是我自己的服务器,所以用的都是`root`用户。需要在`xshell`中登录`MySQL`查询下`user`表。\n\n```\nmysql -uroot -p\n输入密码\nmysql> use mysql\nmysql> select host,user from user;\n+-----------+------------------+\n| host | user |\n+-----------+------------------+\n| % | root |\n| localhost | mysql.infoschema |\n| localhost | mysql.session |\n| localhost | mysql.sys |\n+-----------+------------------+\n4 rows in set (0.00 sec)\n```\n\n可以发现,`root`对应的`host`是`%`,任意的意思,也就意味着`root`用户在连接`MySQL`时不受`ip`约束。\n\n所以说也不是这里的问题啦!\n\n# 检查CentOS防火墙\n\n这是很容易忽略的一步,可能很多人都会认为安全组已经设置好了,不必再检查`CentOS`的防火墙。其实是很有必要检查防火墙的,我们应该把`3306`放通,再重启防火墙。\n\n```\n[root@VM_0_14_centos ~]# firewall-cmd --permanent --zone=public --add-port=3306/tcp\nsuccess\n[root@VM_0_14_centos ~]# firewall-cmd --reload\nsuccess\n```\n\n然后一看,很愉快,`Navicat for MySQL`连接远程数据库成功!\n\n![Navicat for MySQL连接成功](https://s1.ax1x.com/2020/03/17/8t5RpD.png)', '2020-03-17 15:37:54', '2024-07-25 05:17:39', 1, 45, 0, '最近加班挺多,所以也好久没远程访问自己云服务器上的MySQL数据库了。今天本地启动Node服务时连不上MySQL,照常用Navicat For MySQL连接远程数据库进行检查,结果发现突然报错了。', 'https://s1.ax1x.com/2020/03/17/8NIffK.png', 0, 0);
-INSERT INTO `article` VALUES (208, '【读书笔记】js数据类型', '最近脑子里有冒出“多看点书”的想法,但我个人不是很喜欢翻阅纸质书籍,另一方面也是由于我能抽出来看书的时间比较琐碎,所以我就干脆用**app**看电子书了(如果有比较完整的阅读时间,还是建议看纸质书籍,排版看起来更舒服点)。考虑到平时工作遇到的大部分问题还是**javascript**强相关的,于是我选择从**《Javascript权威指南第6版》**开始。\n\n![Javascript权威指南](https://s1.ax1x.com/2020/04/24/JDmKaR.png)\n\n# 数据类型有哪些?\n\njavascript的数据类型分为两大类,一类是原始类型(primitive type),一类是对象类型(object type)。\n\n## 原始类型\n\n原始类型又称为基本类型,分为`Number`, `String`, `Boolean`, `Undefined`, `Null`几类。比较特殊的是,`undefined`是`Undefined`类型中的唯一一个值;同样地,`null`是`Null`类型中的唯一一个值。\n\n\n\n除此之外,`ES6`引入了一个比较特殊的原始类型`Symbol`,用于表示一个独一无二的值,具体使用方法可以看阮一峰老师的[ECMAScript6入门](https://es6.ruanyifeng.com/#docs/symbol),或者直接翻阅[MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol),我平时看**MDN**比较多,感觉比较权威,**API**也很完善。\n\n\n\n为什么说`Symbol`是原始类型,而不是对象类型呢?因为我们知道,大部分程序员都是没有对象的,那么要想找到女朋友,最快的办法就是`new`一个。\n\n```javascript\nconst options = {\n \'性格\': \'好\',\n \'颜值\': \'高\',\n \'对我\': \'好\'\n}\nconst gf = new GirlFriend(options) // new一个女朋友\n```\n\n![皮一下](https://s1.ax1x.com/2020/04/24/JDQcUH.jpg)\n\n好了,不皮了,回到正题,意思就是,`Symbol`是没有构造函数`constructor`的,不能通过`new Symbol()`获得实例。\n\n但是获取`symbol`值是通过调用`Symbol`函数得到的。\n\n```javascript\nconst symbol1 = Symbol(\'Tusi\')\n```\n\n## 对象类型\n\n对象类型也叫引用类型,简单地理解呢,对象就是键值对`key:value`的集合。常见的对象类型有`Object`, `Array`, `Function`, `Date`, `RegExp`等。\n\n除了这些,`Javascript`还有蛮蛮多的全局对象,具体见[JavaScript 标准内置对象](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects)。但是全局对象并不意味着它就是一种对象类型,就比如`JSON`是一个全局对象,但是它不是一种类型,这一点要搞清楚。\n\n前面说了,对象可以`new`出来,所以对象类型都有构造函数,`Object`类型对应的构造函数是`Object()`,`Array`类型对应的构造函数是`Array()`,不再赘述。\n\n```javascript\nvar obj = new Object() // 不过我们一般也不会这么写一个普通对象\nvar arr1 = new Array(1) // 创建一个length是1的空数组\nvar arr2 = new Array(1, 2) // 创建数组[1, 2]\n```\n\n\n\n## 数据类型的判断\n\n## 栈内存和堆内存\n\n原始类型的变量存储在栈内存中,其占内存大小是已知的或者是有范围的;而引用类型的变量是存在堆内存中,其占用内存大小是可变的,未知的。\n\n# 显示转换和隐式转换\n\n4\n\n## 显示转换\n\n5\n\n## 隐式转换\n\n6\n\n# toString和valueOf\n\n7\n\n## toString\n\n8\n\n## valueOf\n\n9\n\n', '2020-04-24 19:21:39', '2020-04-24 19:21:49', 1, 0, 0, '我的笔记', 'https://s1.ax1x.com/2020/04/24/JDmKaR.png', 1, 0);
-INSERT INTO `article` VALUES (209, 'js数据类型很简单,却也不简单', '最近脑子里有冒出“多看点书”的想法,但我个人不是很喜欢翻阅纸质书籍,另一方面也是因为我能抽出来看书的时间比较琐碎,所以就干脆用**app**看电子书了(如果有比较完整的阅读时间,还是建议看纸质书籍,排版看起来更舒服点)。考虑到平时工作遇到的大部分问题还是**javascript**强相关的,于是我选择从《Javascript权威指南第6版》开始。\n\n![Javascript权威指南第6版](https://s1.ax1x.com/2020/05/10/Y1LwPU.jpg)\n\n# 数据类型有哪些?\n\n`javascript`的数据类型分为两大类,一类是原始类型(primitive type),一类是对象类型(object type)。\n\n## 原始类型\n\n原始类型又称为基本类型,分为`Number`, `String`, `Boolean`, `Undefined`, `Null`几类。比较特殊的是,`undefined`是`Undefined`类型中的唯一一个值;同样地,`null`是`Null`类型中的唯一一个值。\n\n\n\n除此之外,`ES6`引入了一个比较特殊的原始类型`Symbol`,用于表示一个独一无二的值,具体使用方法可以看阮一峰老师的[ECMAScript6入门](https://es6.ruanyifeng.com/#docs/symbol \"ECMAScript6入门\"),或者直接翻阅[MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol \"MDN Symbol\"),我平时看**MDN**比较多,感觉比较权威,**API**也很完善。\n\n\n\n为什么说`Symbol`是原始类型,而不是对象类型呢?因为我们知道,大部分程序员都是没有对象的,那么要想找到女朋友,最快的办法就是`new`一个。\n\n```javascript\nconst options = {\n \'性格\': \'好\',\n \'颜值\': \'高\',\n \'对我\': \'好\'\n}\nconst gf = new GirlFriend(options) // new一个女朋友\n```\n\n![皮一下](https://s1.ax1x.com/2020/04/24/JDQcUH.jpg)\n\n好了,不皮了,回到正题,意思就是,`Symbol`是没有构造函数`constructor`的,不能通过`new Symbol()`获得实例。\n\n但是获取`symbol`类型的值是通过调用`Symbol`函数得到的。\n\n```javascript\nconst symbol1 = Symbol(\'Tusi\')\n```\n\n`Symbol`值是唯一的,所以下面的等式是不成立的。\n\n```javascript\nSymbol(1) === Symbol(1) // false\n```\n\n## 对象类型\n\n对象类型也叫引用类型,简单地理解呢,对象就是键值对`key:value`的集合。常见的对象类型有`Object`, `Array`, `Function`, `Date`, `RegExp`等。\n\n除了这些,`Javascript`还有蛮蛮多的全局对象,具体见[JavaScript 标准内置对象](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects \"JavaScript 标准内置对象\")。但是全局对象并不意味着它就是一种对象类型,就比如`JSON`是一个全局对象,但是它不是一种类型,这一点要搞清楚。\n\n前面说了,对象可以`new`出来,所以对象类型都有构造函数,`Object`类型对应的构造函数是`Object()`,`Array`类型对应的构造函数是`Array()`,不再赘述。\n\n```javascript\nvar obj = new Object() // 不过我们一般也不会这么写一个普通对象\nvar arr1 = new Array(1) // 创建一个length是1的空数组\nvar arr2 = new Array(1, 2) // 创建数组[1, 2]\n```\n\n## 栈内存和堆内存\n\n> 栈内存的优势是,存取速度比堆内存要快,充分考虑这一点,其实是可以优化代码性能的。\n\n### 栈内存\n\n原始类型是按值访问的,其值存储在栈内存中,所占内存大小是已知的或是有范围的;\n\n对基本类型变量的重新赋值,其本质上是进行压栈操作,写入新的值,并让变量指向一块栈顶元素(大概意思是这样,但是`v8`等引擎有没有做这方面的优化,就要细致去看了)\n\n```javascript\nvar a = 1; // 压栈,1成为栈顶元素,其值赋给变量a\na = 2; // 压栈,2成为栈顶元素,并赋值给变量a(内存地址变了)\n```\n\n### 堆内存\n\n而对象类型是按引用访问的,通过指针访问对象。\n\n指针是一个地址值,类似于基本类型,存储于栈内存中,是变量访问对象的中间媒介。\n\n而对象本身存储在堆内存中,其占用内存大小是可变的,未知的。\n\n举例如下:\n\n```javascript\nvar b = { name: \'Tusi\' }\n```\n\n运行这行代码,会在堆内存中开辟一段内存空间,存储对象`{name: \'Tusi\'}`,同时声明一个指针,其值为上述对象的内存地址,指针赋值给引用变量`b`,意味着`b`引用了上述对象。\n\n对象可以新增或删除属性,所以说对象类型占用的内存大小一般是未知的。\n\n```javascript\nb.age = 18; // 对象新增了age属性\n```\n\n那么,按引用访问是什么意思呢?\n\n我的理解是:对引用变量进行对象操作,其本质上改变的是引用变量所指向的堆内存地址中的对象本身。\n\n这就意味着,如果有两个或两个以上的引用变量指向同一个对象,那么对其中一个引用变量的对象操作,会影响指向该对象的其他引用变量。\n\n```javascript\nvar b = { name: \'Tusi\' }; // 创建对象,变量b指向该对象\nvar c = b; // 声明变量c,指向与b一致\nb.age = 18; // 通过变量b修改对象\n// 产生副作用,c受到影响\nconsole.log(c); // {name: \"Tusi\", age: 18}\n```\n\n考虑到对象操作的副作用,我们会在业务代码中经常使用深拷贝来规避这个问题。\n\n# 数据类型的判断\n\n判断数据类型是非常重要的基础设施之一,那么如何判断数据类型呢?请接着往下看。\n\n## typeof\n\n`javascript`本身提供了`typeof`运算符,可以辅助我们判断数据类型。\n\n> `typeof`操作符返回一个字符串,表示未经计算的操作数的类型。\n\n`typeof`的运算结果如下,引用自[MDN typeof](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/typeof \"MDF typeof\")\n\n| 数据类型 | 运算结果 |\n| ---------------------------------------------------------- | -------------- |\n| Undefined | \"undefined\" |\n| Null | \"object\" |\n| Boolean | \"boolean\" |\n| Number | \"number\" |\n| String | \"string\" |\n| Symbol | \"symbol\" |\n| Function | \"function\" |\n| 其他对象 | \"object\" |\n| 宿主对象(由JS环境提供,如Nodejs有global,浏览器有window) | 取决于具体实现 |\n\n可以看到,`typeof`能帮我们判断出大部分的数据类型,但是要注意的是:\n\n1. `typeof null`的结果也是`\"object\"`\n2. 对象的种类很多,`typeof`得到的结果无法判断出数组,普通对象,其他特殊对象\n\n那么如何准确地知道一个变量的数据类型呢?\n\n## 结合instanceof\n\n> `instanceof` 运算符用于检测构造函数的 `prototype` 属性是否出现在某个实例对象的原型链上。\n\n利用`instanceof`,我们可以判断一个对象是不是某个构造函数的实例。那么结合`typeof`,我们可以封装一个基本的判断数据类型的函数。\n\n基本思想是:首先看`typeof`是不是返回`\"object\"`,如果不是,说明是普通数据类型,那么直接返回`typeof`运算结果即可;如果是,则需要先把`null`这个坑货摘出来,然后依次判断其他对象类型。\n\n```javascript\nfunction getType(val) {\n const type = typeof val;\n if (type === \'object\') {\n if (val === null) {\n // null不是对象,所以不能用instanceof判断\n return \'null\'\n } else if (val instanceof Array) {\n return \'array\'\n } else if (val instanceof Date) {\n return \'date\'\n } else if (// 其他对象的instanceof判断) {\n return \'xxx\'\n } else if (val instanceof Object) {\n // 所有对象都是Object的实例,所以放最后\n return \'object\'\n }\n } else {\n return type\n }\n}\n// 测试下\ngetType(Symbol(1)) // \"symbol\"\ngetType(null) // \"null\"\ngetType(new Date()) // \"date\"\ngetType([1, 2, 3]) // \"array\"\ngetType({}) // \"object\"\n```\n\n但是,要把常用的对象类型都列举出来也是有点麻烦的,所以也不算一个优雅的方法。\n\n## 终极神器toString\n\n有没有终极解决方案?当然是有的。但是,不是标题中的`toString`,而是`Object.prototype.toString`。用上它,不仅上面的数据类型都能被判断出来,而且也可以判断`ES6`引入的一些新的对象类型,比如`Map`, `Set`等。\n\n```javascript\n// 利用了Object.prototype.toString和正则表达式的捕获组\nfunction getType(val) {\n return Object.prototype.toString.call(val).replace(/\\[object\\s(\\w+)\\]/, \'$1\').toLowerCase();\n}\n\ngetType(new Map()) // \"map\"\ngetType(new Set()) // \"set\"\ngetType(new Promise((resolve, reject) => {})) // \"promise\"\n```\n\n为什么普通的调用`toString`不能判断数据类型,而`Object.prototype.toString`可以呢?\n\n因为`Object`是基类,而各个派生类,如`Date`, `Array`等在继承`Object`的时候,一般都重写(`overwrite`)了`toString`方法,用以表达自身业务,从而失去了判断类型的能力。\n\n# 装箱和拆箱\n\n> 首先解释一下什么是装箱和拆箱,把原始类型转换为对应的对象类型的操作称为装箱,反之是拆箱。\n\n## 装箱\n\n我们知道,只有对象才可以拥有属性和方法,但是我们在使用一些基本类型数据的时候,却可以直接调用它们的一些属性或方法,这是怎么回事呢?\n\n```javascript\nvar a = 1;\na.toFixed(2); // \"1.00\"\n\nvar b = \'I love study\';\nb.length; // 12\nb.substring(2, 6); // \"love\"\n```\n\n其实在读取一些基本类型数据的属性或方法时,`javascript`会创建临时对象(也称为“包装对象”),通过这个临时对象来读取属性或方法。以上代码等价于:\n\n```javascript\nvar a = 1;\nvar aObj = new Number(a);\naObj.toFixed(2); // \"1.00\"\n\nvar b = \'I love study\';\nvar bObj1 = new String(b);\nbObj1.length; // 12\nvar bObj2 = new String(b);\nbObj2.substring(2, 6); // \"love\"\n```\n\n临时对象是只读的,可以理解为它们在发生读操作后就销毁了,所以不能给它们定义新的属性,也不能修改它们现有的属性。\n\n```\nvar c = \'123\';\nc.name = \'jack\'; // 给临时对象加新属性是无效的\nc.name; // undefined\nc.length; // 3\nc.length = 2; // 修改临时对象的属性值,是无效的\nc.length; // 3\n```\n\n> 我们也可以显示地进行装箱操作,即通过`String()`, `Number()`, `Boolean()`构造函数来显示地创建包装对象。\n\n```javascript\nvar b = \'I love study\';\nvar bObj = new String(b);\n```\n\n## 拆箱\n\n对象的拆箱操作是通过`valueOf`和`toString`完成的,且看下文。\n\n# 类型的转换\n\n`javascript`在某些场景会自动执行类型转换操作,而我们也会根据业务的需要进行数据类型的转换。类型的转换规则如下:\n\n![类型转换规则](https://s1.ax1x.com/2020/05/10/Y3YUi9.png)\n\n## 对象到原始值的转换\n\n### toString\n\n`toString()`是默认的对象到字符串的转换方法。\n\n```javascript\nvar a = {};\na.toString(); // \"[object Object]\"\n```\n\n但是很多类都自定义了`toString()`方法,举例如下:\n\n- Array:将数组元素用逗号拼接成字符串作为返回值。\n\n```javascript\nvar a = [1, 2, 3];\na.toString(); // 1,2,3\n```\n\n- Function:返回一个字符串,字符串的内容是函数源代码。\n- Date:返回一个日期时间字符串。\n\n```javascript\nvar a = new Date();\na.toString(); // \"Sun May 10 2020 11:19:29 GMT+0800 (中国标准时间)\"\n```\n\n- RegExp:返回表示正则表达式直接量的字符串。\n\n```javascript\nvar a = /\\d+/;\na.toString(); // \"/\\d+/\"\n```\n\n### valueOf\n\n`valueOf()`会默认地返回对象本身,包括`Object`, `Array`, `Function`, `RegExp`。\n\n日期类`Date`重写了`valueOf()`方法,返回一个1970年1月1日以来的毫秒数。\n\n```javascript\nvar a = new Date();\na.toString(); // 1589095600419\n```\n\n### 对象 --> 布尔值\n\n从上表可见,对象(包括数组和函数)转换为布尔值都是`true`。\n\n### 对象 --> 字符串\n\n对象转字符串的基本规则如下:\n\n- 如果对象具有`toString()`方法,则调用这个方法。如果它返回字符串,则作为转换的结果;如果它返回其他原始值,则将原始值转为字符串,作为转换的结果。\n- 如果对象没有`toString()`方法,或`toString()`不返回原始值(不返回原始值这种情况好像没见过,一般是自定义类的`toString()`方法吧),那么`javascript`会调用`valueOf()`方法。如果存在`valueOf()`方法并且`valueOf()`方法返回一个原始值,`javascript`将这个值转换为字符串(如果这个原始值本身不是字符串),作为转换的结果。\n- 否则,`javascript`无法从`toString()`或`valueOf()`获得一个原始值,会抛出异常。\n\n### 对象 --> 数字\n\n与对象转字符串的规则类似,只不过是优先调用`valueOf()`。\n\n- 如果对象具有`valueOf()`方法,且`valueOf()`返回一个原始值,则`javascript`将这个原始值转换为数字(如果原始值本身不是数字),作为转换结果。\n- 否则,如果对象有`toString()`方法且返回一个原始值,`javascript`将这个原始值转换为数字,作为转换结果。\n- 否则,`javascript`将抛出一个类型错误异常。\n\n## 显示转换\n\n使用`String()`, `Number()`, `Boolean()`函数强制转换类型。\n\n```javascript\nvar a = 1;\nvar b = String(a); // \"1\"\nvar c = Boolean(a); // true\n```\n\n## 隐式转换\n\n在不同的使用场景中,`javascript`会根据实际情况进行类型的隐式转换。举几个例子说明下。\n\n### 加法运算符+\n\n我们比较熟悉的运算符有算术运算符`+`, `-`, `*`, `/`,其中比较特殊的是`+`。因为加法运算符`+`可以用于数字加法,也可以用于字符串连接,所以加法运算符的两个操作数可能是类型不一致的。\n\n当两个操作数类型不一致时,加法运算符`+`会有如下的运算规则。\n\n- 如果其中一个运算符是对象,则会遵循对象到原始值的转换规则,对于非日期对象来说,对象到原始值的转换基本上是对象到数字的转换,所以首先调用`valueOf()`,然而大部分对象的`valueOf()`返回的值都是对象本身,不是一个原始值,所以最后也是调用`toString()`去获得原始值。对于日期对象来说,会使用对象到字符串的转换,所以首先调用`toString()`。\n\n```javascript\n1 + {}; // \"1[object Object]\"\n1 + new Date(); // \"1Sun May 10 2020 22:53:24 GMT+0800 (中国标准时间)\"\n```\n\n- 在进行了对象到原始值的转换后,如果加法运算符`+`的其中一个操作数是字符串的话,就将另一个操作数也转换为字符串,然后进行字符串连接。\n\n```javascript\nvar a = {} + false; // \"[object Object]false\"\n\nvar b = 1 + []; // \"1\"\n```\n\n- 否则,两个操作数都将转换为数字(或者NaN),然后进行加法操作。\n\n```javascript\nvar a = 1 + true; // 2\n\nvar b = 1 + undefined; // NaN\n\nvar c = 1 + null; // 1\n```\n\n### [] == ![]\n\n还有个很经典的例子,就是`[] == ![]`,其结果是`true`。一看,是不是觉得有点懵,一个值的求反竟然还等于这个值!其实仔细分析下过程,就能发现其中的奥秘了。\n\n1. 首先,我们要知道运算符的优先级是这样的,一元运算符`!`的优先级高于关系运算符`==`。\n\n![js运算符优先级](https://s1.ax1x.com/2020/05/10/Y843KP.png)\n\n2. 所以,右侧的`![]`首先会执行,而逻辑非运算符`!`会首先将其操作数转为布尔值,再进行求反。`[]`转为布尔值是`true`,所以`![]`的结果是`false`。此时的比较变成了`[] == false`。\n3. 根据比较规则,如果`==`的其中一个值是`false`,则将其转换为数字`0`,再与另一个操作数比较。此时的比较变成了`[] == 0`。\n4. 接着,再参考比较规则,如果一个值是对象,另一个值是数字或字符串,则将对象转为原始值,再进行比较。左侧的`[]`转为原始值是空字符串`\"\"`,所以此时的比较变成了`\"\" == 0`。\n5. 最后,如果一个值是数字,另一个是字符串,先将字符串转换为数字,再进行比较。空字符串会转为数字`0`,`0`与`0`自然是相等的。\n\n搞懂了这个问题,也可以分析下为什么`{} == !{}`的结果是`false`了,这个就比较简单了。\n\n看到这里,你还觉得数据类型是简答的知识点吗?有兴趣深究的朋友可以翻阅下[ES5的权威解释](http://es5.github.io/#x11.9.1 \"ES5官方注解\")。\n\n# 最后\n\n数据类型是`javascript`中非常重要的一部分,搞清楚数据类型的基本知识点,对于学习`javascript`的后续知识点多有裨益。\n\n另外,写笔记其实对思考问题很有帮助,就算只是总结很简单的基础知识,也是多有助益。\n\n以上内容是个人笔记和总结,难免有错误或遗漏之处,欢迎留言交流。', '2020-05-11 15:25:38', '2024-08-31 07:54:49', 1, 26, 0, '看似简单的东西背后却耐人寻味。每个知识点都值得再思索一番。[] == ![]背后的逻辑你清楚了吗?', 'https://s1.ax1x.com/2020/05/11/YJCmrV.jpg', 0, 0);
-INSERT INTO `article` VALUES (210, '关于数据类型的一些小疑惑', '上期在阅读《Javascript权威指南》第六版**类型转换**这一章节的时候,我虽然搞清楚了之前留下的很多疑问,比如说数据类型转换的基本规则,对象到原始值的转换规则等。但是对于书中3.8.3节(对象转换为原始值)中的一段文字存有疑惑,今天回头又看了一遍,总算是搞明白了。\n\n首先引用下这段文字。\n\n> “+”和“==”应用的对象到原始值的转换包含日期对象的一种特殊情形。日期类是JavaScript语言核心中唯一的预先定义类型,它定义了有意义的向字符串和数字类型的转换。对于所有非日期的对象来说,对象到原始值的转换基本上是对象到数字的转换(首先调用valueOf),日期对象则使用对象到字符串的转换模式,然而,这里的转换和上文讲述的并不完全一致:通过valueOf或toString返回的原始值将被直接使用,而不会被强制转换为数字或字符串。\n> \n\n> 和“==”一样,“<”运算符以及其他关系运算符也会做对象到原始值的转换,但要除去日期对象的特殊情形:任何对象都会首先尝试调用valueOf,然后调用toString。不管得到的原始值是否直接使用,它都不会进一步被转换为数字或字符串。\n> \n\n> “+”、“==”、“!=”和关系运算符是唯一执行这种特殊的字符串到原始值的转换方式的运算符。其他运算符到特定类型的转换都很明确,而且对日期对象来讲也没有特殊情况。例如“-”(减号)运算符把它的两个操作数都转换为数字。\n\n复制这么长一段文字呢,也不是为了凑字数,是我一开始真的没看明白这段。因为我一直纠结在这节内容前面说的对象转换为原始值的规则,死死地认为:\n\n1. 对象转原始值都应该按照两条路线走,一条路线是转为字符串,一条路线是转为数字。\n2. 对象转为字符串这条路线,是优先调用`toString()`方法,其次调用`valueOf()`方法,如果最后得到的原始值不是字符串,再把这个原始值转为字符串。\n3. 对象转为数字这条路线,是优先调用`valueOf()`方法,其次调用`toString()`方法,如果最后得到的原始值不是数字,再把这个原始值转为数字。\n4. 否则就抛出类型错误。\n\n这里写的转换规则比较粗略了,因为[上一篇笔记](https://juejin.im/post/5eb8e2fff265da7bb46bd8ae)中已经提到了比较详细的规则了,这里就捡重点看了。\n\n掉进这个规则里,我就产生了固化思维,觉得所有的对象转原始值的情况都应该按这个规则来。所以对上面引用的这段话就开始想不明白了。大概产生了这些疑问:\n\n1. 引文中第一段的最后一句“通过valueOf或toString返回的原始值将被直接使用,而不会被强制转换为数字或字符串。”。我的疑惑是:为什么最后不会再强制转换了?\n1. 第二段中提到的“关系运算符中对象到原始值的转换,都会首先调用valueOf,然后调用toString”。我的疑惑是:为什么日期对象又不特殊处理(首先调用toString)了呢?\n\n\n其实我上篇写到最后一小节[隐式转换](https://juejin.im/post/5eb8e2fff265da7bb46bd8ae#heading-21)的时候,已经提到了,不同运算符对于对象的转换规则是特殊的。\n\n> 在不同的使用场景中,`javascript`会根据实际情况进行类型的隐式转换。\n\n\n可能是写完之后回头看这段文字又串戏了,懵逼了。\n\n其实还是要看`javascript`到底期望什么类型的操作数。之所以`+`, `==`比较特殊,是因为`javascript`不太明确操作数的类型。\n\n就拿`+`来说吧,可能是用来做数字加法,也可能是用来拼接字符串。所以`javascript`必须把这些情况都考虑到,针对这个运算符来定个特殊的规则。\n\n而`==`是相等运算符,与恒等运算符`===`是不一样的。恒等运算符会首先判断数据类型是否一致,而`==`运算符不要求两个操作数类型一致,当两个操作数不一致时,会按照一定的规则进行操作数的隐式转换。\n\n而一些其他算术运算符,比如`-`, `*`, `/`,它们都很明确操作数的类型,希望操作数是数字。所以即使你给的操作数不是数字,它也会转为数字来运算。\n\n所以,如果其中一个操作数是对象,会发生对象到原始值的转换,然后这个原始值也会被转为数字(如果这个原始值本身不是数字)。\n\n```javascript\nvar a = {name: \'飞白\'};\na / 2; // NaN\n\nvar b = new Date();\nb / 2; // 795202699143\n```\n\n大概的思路是这个样子的,而每个运算符是怎么样的一个运算流程,还是要看去看它的官方说明。\n\n写这么一篇没什么实际内容的东西,主要还是想记录下自己的这种疑惑吧,希望自己以后不要再被这种文字绕进去了,要多想想程序这样设计到底是为了解决什么问题,这样才能更容易理解或猜到规则背后的逻辑。\n', '2020-05-30 18:02:57', '2024-08-08 09:06:30', 1, 20, 0, '上期在阅读《Javascript权威指南》第六版类型转换这一章节的时候,我虽然搞清楚了之前留下的很多疑问,比如说数据类型转换的基本规则,对象到原始值的转换规则等。但是对于书中3.8.3节(对象转换为原始值)中的一段文字存有疑惑,今天回头又看了一遍,总算是搞明白了。', 'https://qncdn.wbjiang.cn/1724ba0674616c9c', 0, 0);
-INSERT INTO `article` VALUES (211, '“入坑”自媒体写作,我有干货与你分享', '**自媒体时代**,人人都可以发声,大家都可以通过互联网发表自己的言论和观点。常见的自媒体平台有微信公众号,知乎,微博,头条,以及各个新闻博客平台等。自媒体写作与传统写作最大的不同,就是突出个性思维,只要找准自己的定位,一直坚持一个方向深耕,且不说会得到什么样的收益,但一定会提升自己的综合能力。之前也不止一次问过自己,你又不是大咖,写什么文章?质量还不高?我想,大概是兴趣驱动,写作是一种思考和沉淀的过程,于我而言是收获。\n\n借用《后浪》里的一句话:\n\n> 人与人之间的壁垒被打破,你们只凭相同的爱好,就能结交千万个值得干杯的朋友,你们拥有了我们曾经梦寐以求的权利——选择的权利。\n\n![选择的权利](http://qncdn.wbjiang.cn/%E9%80%89%E6%8B%A9%E7%9A%84%E6%9D%83%E5%88%A9.png)\n\n是的,我们有幸生在这个时代,自媒体已经没有职业之分了,任何职业的个体都可以在网络上发文分享自己的知识、经验、见识,也可以通过短视频这个当红的渠道快速传播。对于我们IT技术人来讲,一般会选择从技术知识写作入手,那么怎样才能写好一篇文章呢?作为一个粉丝数接近0的作者,我还是有一点**干货**与你分享。\n\n# 准备工作\n\n准备一篇有品质的文章,除了实打实的文章质量,美观的排版设计和丰富的素材也会加不少分。那么要从哪些方面入手呢?不着急,马上给您安排!\n\n## 写作方式\n\n最重要的当然是写作方式,选择一些好的编辑器往往让你事半功倍。一般而言,我们会选择**富文本**编辑器和**Markdown**编辑器两大类。\n\n### 富文本编辑器\n\n一般的写作平台都标配了富文本编辑器,所见即所得,这种感觉可能跟使用Word文档差不多。对于大多数人来说,富文本编辑器是一个非常棒的选择。下面是微信公众号的一个富文本编辑器界面:\n\n![微信公号富文本编辑器](http://qncdn.wbjiang.cn/%E5%BE%AE%E4%BF%A1%E5%85%AC%E5%8F%B7%E7%BC%96%E8%BE%91%E5%99%A8.png)\n\n虽然有了富文本编辑器,但是写作仍然是一个伤脑筋的事情,比如找一些关注引导推荐类的素材还不够方便,所以我们还需要一个有大量可用素材模板的编辑器。\n\n有流量的地方自然有人去做,微信公众号这么一个巨大的流量通道,市面上自然少不了各种微信图文编辑器。大家可以自行去搜索下,我这里就不实名推荐了,防止广告嫌疑。\n\n![图文编辑器](http://qncdn.wbjiang.cn/%E6%9F%90%E5%9B%BE%E6%96%87%E7%BC%96%E8%BE%91%E5%99%A8.png)\n\n这些编辑器内的素材都可以方便地复制到微信公众号的编辑器里,有的甚至做了一键导入到微信公众号的能力。\n\n当然这里不可避免会出现一些收费的素材,但是免费的基本上已经满足大众需求了,不用过于担心。\n\n### Markdown\n\n作为程序狗,自然忍不住要提一提Markdown。Markdown是一种标记语言。通过简单的标记语法,它可以使普通文本内容具有一定的格式,可以代表标题,文本,表格,图片,链接等等。\n\n你完全不用担心Markdown效果不好,虽然Markdown默认是极简风格,但是结合现在的表现层语言,它可以做得很漂亮。下面是一个简单的例子:\n\n![Markdown效果](http://qncdn.wbjiang.cn/markdown%E6%A1%88%E4%BE%8B.png)\n\n你也不用担心不会使用Markdown,市面上已经有很多非常nice的Markdown编辑器。有了这些编辑器,你可以像写word文档一般玩转Markdown。\n\n### typora\n\n这里必须强力推荐一款编辑器**typora**了,是因为它真的很好用。\n\n在typora,你可以选择各种主题:\n\n![主题](http://qncdn.wbjiang.cn/typora%E4%B8%BB%E9%A2%98.png)\n\n利用这些主题,你可以专注于把控文章质量,写好之后直接复制到微信公众号,知乎等平台上的编辑器中,效果非常棒!\n\n![主题效果](http://qncdn.wbjiang.cn/typora%E4%B8%BB%E9%A2%98%E5%9C%A8%E5%BE%AE%E4%BF%A1%E7%9A%84%E6%95%88%E6%9E%9C.png)\n\n### mdnice等在线编辑器\n\n如果不想用本地的markdown编辑器,也有在线的备选项,搜索“在线markdown编辑器”就好了。\n\n这里推荐一个[mdeditor](http://www.mdeditor.com),但是一般的在线markdown编辑器都没有主题功能,如果你需要做出个性化的效果,可能这些还不够。\n\n所以必须要推荐我现在很喜欢的一款编辑器[mdnice](https://mdnice.com/)了。mdnice可以说是把CSS的威力发挥得淋漓尽致了,支持的主题很多,也可以自行定义主题(如果你会使用CSS语言的话,可以尝试一下),下面这个主题是我自己定制的,个人觉得效果还行。\n\n![mdnice](http://qncdn.wbjiang.cn/mdnice%E8%87%AA%E5%AE%9A%E4%B9%89%E4%B8%BB%E9%A2%98.png)\n\nmdnice也支持一键复制到微信公众号,掘金,知乎等平台,可以满足多数人的需求。\n\n并且mdnice支持缓存你的历史修改版本,这样也可以从一定程度上防止写作内容丢失了!\n\n还有一款类似的编辑器,做得也挺好的,就是主题太少了点。附上链接:[https://lab.lyric.im/wxformat/#](https://lab.lyric.im/wxformat/#)\n\n之前还用过一款md2all,不过好像停更了,链接是:[http://md.aclickall.com/](http://md.aclickall.com/)\n\n### 语雀\n\n有一次我本地用typora写作,结果系统崩了,没保存到,心态有点炸。最近我开始慢慢地在语雀上写个大概的草稿,然后可以导出markdown格式,转到typora或者mdnice再细致改改。有云同步的功能还是保险啊。\n\n我在公司写文档也基本上是用语雀的(之前还试过自己搭gitbook,不过没语雀给力),这是阿里内部孵化的很好的一款工具,强烈推荐!\n\n![语雀](http://qncdn.wbjiang.cn/%E8%AF%AD%E9%9B%80%E5%89%8D%E7%AB%AF%E7%9F%A5%E8%AF%86%E5%BA%93.png)\n\n说了这么多编辑器,并不是说选择哪一款就可以解决所有问题,一般来说,结合起来使用,效果更棒,可以自行体会下!\n\n## 素材\n\n搞清楚了如何选择编辑器,接下来的重点就是素材的收集和处理了。灵活地运用素材,会让你的文章显得很饱满,生动有趣!\n\n### 无版权图\n\n首先要说的就是图片素材,写作的过程中,我们大部分时候会希望插入一些优质的图片,或是风景图,或是卡通,或是人物图。找图片素材自然要上一些靠谱的网站了,我这里也推荐一些,有些是否能商用要仔细看看,反正个人用途基本上没问题的。\n\n![wallhaven](http://qncdn.wbjiang.cn/wallhaven.png)\n\n- [https://wallhaven.cc/](https://wallhaven.cc/) 这个是我用得最多的,并且支持原图在线裁剪。\n- [https://www.pexels.com/zh-tw/](https://www.pexels.com/zh-tw/)\n- [https://pixabay.com/zh/photos/](https://pixabay.com/zh/photos/)\n- [https://stocksnap.io/](https://stocksnap.io/)\n\n### 图标\n\n有时候我们可能也不需要找很大的图片素材,如果只是寻找一个小图标的话,上[iconfont](https://www.iconfont.cn/)就基本上解决了,支持导出主流的图片格式,并且可以自定义修改颜色,大小等。对了,iconfont是前端工程师日常开发中一个很重要的工具哦!\n\n![iconfont](http://qncdn.wbjiang.cn/iconfont%E9%A6%96%E9%A1%B5.png)\n\n### 动图\n\n如果需要录制一些GIF动图,比如操作引导之类的,就需要一个比较方便的工具了。我这里使用的是**GifCam**,如果您有更好的工具,请不吝赐教。\n\n### 录屏\n\n如果要录视频的话,可以选择**FSCapture**,除了录屏之外,还可以实现截图,滚动截图等能力哦,可谓是小而美。\n\n之前还用过一款[超级录屏](https://www.onlinedown.net/soft/518458.htm),也是不错的。\n\n当然还有一个神器**OBS Studio**,接触过直播的应该都知道(别问我为什么知道)。\n\n### 表情包\n\n有时候还是要来点表情包活跃下气氛的,除了日常搜集的表情包,我们有时候可能希望制作一些跟我们写作主题相关的表情,这个时候就要搜一下**表情包制作**了,这种工具很多,自行筛选即可。这里也简单地列举几个吧:\n\n- [https://fabiaoqing.com/diy](https://fabiaoqing.com/diy)\n- [https://app.xuty.tk/static/app/index.html](https://app.xuty.tk/static/app/index.html)\n- [https://www.52doutu.cn/diy/](https://www.52doutu.cn/diy/)\n\n### Emoji\n\n插入emoji也是个基本操作了,但是很多平台的编辑器还不支持直接识别emoji编码,直接复制上去是不行的,会被认定为普通文本。\n\n\n而**typora**编辑器是支持emoji的,只要把emoji编码复制上去,马上就呈现出来了。\n\n![typora支持emoji](http://qncdn.wbjiang.cn/Typora_s4CNyC22mj.png)\n\n所以我们可以先在typora编辑器里面把emoji弄好,然后复制到其他平台上的富文本编辑器,就可以显示出来了。\n\n据我测试,大部分markdown编辑器是不支持这样做的,如果想把emoji复制到markdown编辑器上,需要先从typora复制到富文本编辑器,然后就可以把emoji表情从富文本编辑器复制到markdown编辑器上了。\n\n![emoji复制到markdown编辑器](http://qncdn.wbjiang.cn/emoji%E5%A4%8D%E5%88%B6%E5%88%B0markdown.png)\n\n关于emoji的编码,这里有比较完整的一个列表。\n[https://www.webfx.com/tools/emoji-cheat-sheet/](https://www.webfx.com/tools/emoji-cheat-sheet/)\n\n### 流程图/结构图/架构图\n\n画图是一个很有用的方法,可以帮助我们把一个复杂的过程简单而直观地展示出来,极大提高了我们的效率。\n\n- 桌面端有强大的visio\n- 在线的绘图平台有ProcessOn\n\n![image.png](http://qncdn.wbjiang.cn/processon.png)\n\n### 思维导图\n\nProcessOn也可以画思维导图,类似的还有百度脑图。不过我还是更习惯用桌面端的**xmind**。\n\n## 资源压缩\n\n一般来说,找一些在线的压缩工具即可。但是有的要收费,要注意避坑。\n\n- 压缩图片\n\n[https://tinypng.com/](https://tinypng.com/)\n\n[https://www.tutieshi.com/compress/](https://www.tutieshi.com/compress/)\n\n[https://www.soogif.com/compress](https://www.soogif.com/compress)\n\n[https://docsmall.com/image-compress](https://docsmall.com/image-compress)\n\n- 压缩视频\n\n我几乎不用视频素材,如果要压缩视频,一般是找桌面端工具了,可以自行搜索下,或者看看这篇知乎[如何压缩视频大小?](https://www.zhihu.com/question/62583598?sort=created)\n\n## 图床\n\n我目前只用了七牛云和路过图床。\n\n- 七牛云对象存储,如果不用https的话,存图片是免费的。\n- [路过图床](https://imgchr.com/),这个是免费的,但是复制到微信公众号编辑器上,直接加载失败,头疼,估计是做了防盗链。\n- 还有一个比较好的工具[PicGo](https://picgo.github.io/PicGo-Doc/zh/guide/#%E5%BA%94%E7%94%A8%E6%A6%82%E8%BF%B0),集合了多个图床平台,有兴趣的可以试试。\n\n\n## 在线作图\n\n有时候我们要处理下封面,或者公众号首图,光有素材可能还不够的,一般人也不会使用**PS**,那么在线作图还是很重要的,可以节省很多时间。\n\n这里推荐我用过的两款:\n\n- [稿定设计](https://www.gaoding.com/):素材比较丰富,也支持同步到微信公众号素材库。\n- [Canva](https://www.canva.com/zh_cn/) 我早期用的一个在线作图工具。\n\n# 骚操作\n\n## 图片加阴影\n\n我们经常会截图作为素材,但是有时候截图的轮廓感不是很强,这个时候就要给图片来加个阴影效果了。我试过直接在mdnice上用css的`box-shadow`做阴影,但是感觉比较容易受盒子的影响,出现不太好的效果。\n\n所以我觉得还是直接改图片本身比较好,我现在使用的图片处理工具是ShareX。如果你会使用PS,那就自然不必多说。\n\n![阴影效果](http://qncdn.wbjiang.cn/gitlab%E5%9B%BE%E7%89%87%E9%98%B4%E5%BD%B1.png)\n\n\n## typora主题定制\n\ntypora支持主题定制,首先要找到typora的主题目录。\n\n![主题目录](http://qncdn.wbjiang.cn/typora%E4%B8%BB%E9%A2%98%E6%96%87%E4%BB%B6)\n\n然后添加一个`css`文件,例如`feibai.css`,写下需要定制的css内容,比如:\n\n```css\nh1 {\n background: url(http://qncdn.xxx.cn/xxx.svg);\n background-size: 48px 48px;\n background-position: top center;\n background-repeat: no-repeat;\n text-align: center;\n font-size: 24px;\n}\n\nh1 > span {\n padding-top: 48px;\n display: block;\n color: #135ce0;\n}\n```\n\n接着在typora选择你刚才写的主题,就可以看到效果了。\n\n![主题效果](http://qncdn.wbjiang.cn/%E9%80%89%E6%8B%A9%E4%B8%BB%E9%A2%98.png)\n\n\n如果想要详细了解如何定制typora主题,可以主动联系我。\n\n# 最后\n\n做了这些准备工作,我不敢打包票说能写出很nice的文章,但至少能过得了自己眼球这一关吧。好了,本次分享结束,如果您觉得这篇文章还不错,欢迎**关注+在看+分享**鼓励一下我,也可以留言加我微信交流,谢谢!\n\n![欢迎交流](http://qncdn.wbjiang.cn/%E5%A4%A7%E5%89%8D%E7%AB%AF%E5%85%AC%E4%BC%97%E5%8F%B7%E5%90%8D%E7%89%87.png)', '2020-05-30 18:11:48', '2024-08-15 23:33:53', 1, 30, 0, '自媒体时代,人人都可以发声,大家都可以通过互联网发表自己的言论和观点。作为一个粉丝数接近0的作者,我还是有一点干货与你分享。', 'https://qncdn.wbjiang.cn/wallhaven-0j26pq_1280x600.png', 0, 0);
-INSERT INTO `article` VALUES (212, '千万别小看这些运算符背后的逻辑', '# 前言\n\n最近回顾`javascript`的一些基础知识点时,引起的思考确实颠覆了我之前的一些认知。我清楚地记得曾多次在网上看到一些奇奇怪怪的表达式,它们的运算结果着实让人懵逼。就比如我在[js数据类型很简单,却也不简单](https://juejin.im/post/5eb8e2fff265da7bb46bd8ae#heading-23)这一篇笔记中提到的`[] == ![]`这样一个表达式,它的运算结果是`true`。如果你不细致地去研究它背后的运算逻辑,你只会惊呼”这是什么鬼“?相反,当你静下心来看清楚它的运算逻辑后,你会感叹“妙哉妙哉”!没错,本文的主角就是这些容易让人小觑的运算符。\n\n# 加法运算符+\n\n首先说的是加法运算符`+`,这是一个很容易被人忽视的运算符。我们知道,`+`可以用来做数字运算,也可以用作字符串拼接,但是还有一些细节可能是大家不知道的。如果`+`运算符的两个操作数类型不一致,或者说两个操作数既不是字符串也不是数字,那么它的运算规则是什么?\n\n先举几个例子,你可以先思考下这些运算结果分别是什么。\n\n```javascript\nvar a = 1 + \"1\";\nvar b = 1 + {};\nvar c = 1 + [];\nvar d = 1 + true;\nvar e = { name: \'飞白\' } + [1, 2];\nvar f = null + undefined;\nvar g = true + null;\n```\n\n其实规则很简单,我们只要简单地列举出数据类型的可能性,就几乎得到了完整的答案。\n\n1. 如果操作数都是数字,进行数字的加法运算。\n2. 如果操作数都是字符串,进行字符串的拼接。\n3. 如果操作数是对象,会转换为原始值(一般是先调用`valueOf()`,日期对象比较特殊,会调用`toString()`),得到的原始值不再被强制转换为数字或字符串。在这种约束下,对象转为原始值基本都是字符串(如果你没有重写`valuOf()`或者`toString()`方法),根据下面的第四点,会执行字符串拼接操作。\n4. 如果其中一个操作数是字符串,另一个操作数也会被转为字符串,`+`运算符执行字符串拼接操作。\n5. 如果两个操作数都不是字符串或对象,则会进行算术加法运算(非数字的操作数会被强制转为数字)。\n\n所以,不难得出上面列举的表达式的运算结果。\n\n```javascript\nvar a = 1 + \"1\"; // \"11\"\nvar b = 1 + {}; // \"1[object Object]\"\nvar c = 1 + []; // \"1\"\nvar d = 1 + true; // 2\nvar e = { name: \'飞白\' } + [1, 2]; // \"[object Object]1,2\"\nvar f = null + undefined; // NaN\nvar g = true + null; // 1\n```\n\n要记住这些规则并不简单,一个记忆技巧是:`+`运算符偏爱字符串拼接操作。\n\n# 相等运算符==\n\n这个运算符的运算规则,在[js数据类型很简单,却也不简单](https://juejin.im/post/5eb8e2fff265da7bb46bd8ae#heading-23)这篇笔记中已经简单地解释过了。其实只要记住一条规则:对于`==`运算符,如果两个操作数是`null`或`undefined`,运算结果是`true`;否则,不管操作数的类型如何转换,`==`运算符最后都是数字的比较。\n\n举几个简单的例子说明下:\n\n```javascript\nnull == undefined; // true\n[1] == 1; // true\n1 == true; // true\n1 == \"1\" // true\nnew Date(2020, 0, 1, 0, 0, 0) == 1577808000000 // false\n```\n\n# 比较运算符\n\n大于`>`,大于等于`>=`,小于`<`,小于等于`<=`,用于比较数字的大小或字符在字母表中的排序。要注意的是,在`ASCII`中,大写字母排在小写字母前面。\n\n这些比较运算符更偏爱数字的比较,除非两个操作数都是字符串。\n\n对于字符串比较的情况,如果两个字符串的第一个字符是相同的,则会比较第二个字符,以此类推。\n\n这里有一个比较特殊的`NaN`,它与任何值做比较都会返回`false`。\n\n```javascript\nNaN < 1; // false\nNaN > 1; // false\n```\n\n# 位运算符\n\n位运算符很少用到,但是弄明白它们的运算逻辑是很有必要的。位运算符主要分为与`&`、或`|`、非`~`、异或`^`以及左移`<<`、带符号右移`>>`、无符号右移`>>>`等。\n\n位运算符都是二进制的运算,并且是基于32位整数运算。所以十进制,十六进制的操作数都会先转为32位的二进制后再进行运算。这里以`0x1234 & 0x00FF = 0x0034`为例说明下流程:\n\n1. `0x123`转为二进制是`0000 0000 0000 0000 0001 0010 0011 0100`,`0x00FF`转为二进制是`0000 0000 0000 0000 0000 0000 0011 0100`。\n2. 进行按位与操作,结果是`0000 0000 0000 0000 0000 0000 0011 0100`,最后转为十六进制就是`0x0034`。\n\n## 移位运算符\n\n在复习到移位运算符这块时,我不由得提出了一个疑问:“javascript中为什么没有无符号左移运算符?”要解答这样一个疑问,首先还是要看看左移和右移分别是怎么运算的。\n\n摘取《计算机组成原理教程》书中的一段描述:\n\n> 计算机中机器数的字长往往是固定的,当机器数左移n位或右移n位时,必然会使其n位低位或n位高位出现空位。那么,对空出的空位应该添补0还是1呢?这与机器数采用有符号数还是无符号数有关。对无符号数的移位称为逻辑移位,对有符号数的移位称为算术移位。\n\n**注意**:在javascript中,移位运算符只支持移动0~31位,如果移动的位数超过了`31`位,位数会取模`MOD 32`。也就是说:\n\n```javascript\n1 << 32\n// 等价于\n1 << 0\n```\n\n### 带符号右移>>\n\n对于带符号右移(算术右移)运算而言,第一个操作数是有符号数,它的最高位代表符号位,在移位后的符号位不改变。简单总结就是“低位舍弃,高位补符号位”。\n\n```javascript\nvar a = -1;\na >> 2; // -1\n// 用负数的补码形式进行算术右移,高位补1\n```\n\n如果你自己写几个右移运算表达式做试验,你就会产生一个疑惑,为什么有的正数在带符号右移后却变成了负数,比如下面这个:\n\n```javascript\n2147483648 >> 31 // -1\n```\n\n这是因为`32`位的最大带符号正整数是231 - 1,即`2147483647`,转换为二进制是`0111 1111 1111 1111 1111 1111 1111 1111`。正数的补码与原码相同,`2147483648`相当于在此基础上加`1`,就得到补码`1000 0000 0000 0000 0000 0000 0000 0000`,而这个补码是一个非常特殊的码,它没有对应的原码和补码,代表`32`位能表示的带符号数中最小的负数231 - 1,即`-2147483648`。而`2147483648`在`32`位带符号正数中是无法表示的,其值已经溢出了。\n\n![二进制真值表参考](https://qncdn.wbjiang.cn/%E5%8E%9F%E7%A0%81%E5%8F%8D%E7%A0%81%E8%A1%A5%E7%A0%81%E7%9C%9F%E5%80%BC%E8%A1%A8.png)\n\n计算机只理解二进制,与人类所理解的十进制之间永远存在一个精度问题,需要足够的精度才能更加准确地表示十进制,而计算机的位数永远都是有限的,这就是矛盾存在的地方,所以会出现溢出这种现象。\n\n\n\n就好比时钟一般,`23`时结束了又从`0`时开始。在带符号二进制表示法中,正数和负数首尾相连,形成一个环,在计算机可表示的范围内,溢出的那个数字在某种意义上能在另一个起点找到。\n\n![带符号二进制时钟示意](https://qncdn.wbjiang.cn/%E4%BA%8C%E8%BF%9B%E5%88%B6%E6%97%B6%E9%92%9F%E8%A1%A8%E7%A4%BA%E6%B3%95.jpg)\n\n所以,下面的位运算表达式也是等价的:\n\n```javascript\n2147483649 >> 1 // -1073741824\n-2147483647 >> 1 // 可以理解为:2147483649溢出的值为2,所以在位运算中,等价于第二小的负数-2147483647\n```\n\n### 无符号右移>>>\n\n无符号右移也称为逻辑右移。无符号右移的移位过程中,符号位可能会改变。因此移位后,原来的负数可能变成正数。可以简单记忆为“低位舍弃,高位补0”。\n\n```javascript\n-1 >>> 2; // 1073741823\n// 1000 0000 0000 0000 0000 0000 0000 0001 右移两位变成 0010 0000 0000 0000 0000 0000 0000 0000\n// 也就是2的30次方减去1,等于1073741823\n```\n\n### 左移<<\n\n翻阅《计算机组成原理教程》可以发现,书中有描述到算术左移和逻辑左移。也就是说,左移也分带符号左移和无符号左移。经测试,`javascript`中的左移运算符`<<`一般不会改变符号位,意味着它是算术左移(其实对比`<<`和`>>`也能知道,`<<`是带符号左移)。\n\n但是左移也要注意溢出的情况,比如:\n\n```javascript\n1 << 31; // -2147483648\n```\n\n\n\n那么为什么`javascript`中却没有逻辑左移呢?我找了一些资料,比如`es5`规范和注解,还有一些`javascript`的书籍,都没有找到解释。所以这里也没有一个权威的答案(如果有大佬知道的话,请不吝赐教)。\n\n\n\n我个人的想法是,应该是要回到移位运算的本质。\n\n> 二进制表示的机器数在相对于小数点作n位左移或右移时,其实质就是该数乘以或除以2n(n=1,2, …, n)。\n\n而在左移过程中,如果把符号位都丢了,就失去了乘以`2n`的意义了。所以不只是`javascript`,其他编程语言如`java`等也没有逻辑左移运算符。\n\n# 最后\n\n不得不说,大学课程真的很重要。如果一直都保持对计算机基础课程的关注,相信理解这些编程语言背后的本质会变得轻松很多。\n\n', '2020-06-05 11:08:09', '2024-08-13 18:52:12', 1, 31, 0, '带符号右移(算术右移)用补码运算,然后转为原码;无符号右移(逻辑右移)用原码或补码运算都可以,得到的结果真值是一样的。这是不是从另一个角度说明了逻辑运算不需要补码,补码只是为了带符号数的运算而设计?', 'http://qncdn.wbjiang.cn/wallhaven-0qx3x7_1200x500.png', 0, 0);
-INSERT INTO `article` VALUES (213, '「思维导图学前端 」一文搞懂Javascript对象,原型,继承', '# 前言\n\n去年开始我给自己画了一张知识体系的思维导图,用于规划自己的学习范围和方向。但是我犯了一个大错,我的思维导图只是一个全局的蓝图,而在学习某个知识点的时候没有系统化,知识太过于零散,另一方面也很容易遗忘,回头复习时没有一个提纲,整体的学习效率不高。意识到这一点,我最近开始用思维导图去学习和总结具体的知识点,效果还不错。试想一下,一张思维导图的某个端点是另一张思维导图,这样串起来的知识链条是多么“酸爽”!当然,YY一下就好了,我保证你没有足够的时间给所有知识点都画上思维导图,挑重点即可。\n\n![](https://qncdn.wbjiang.cn/%E6%9D%A5%E7%BB%99%E6%88%91%E7%94%BB100%E5%BC%A0.jpg)\n\n# 提纲思路\n\n当我们要研究一个问题或者知识点时,关注点无非是:\n\n1. 是什么?\n\n2. 做什么?\n\n3. 为什么?\n\n很明显,搞懂“是什么”是最最基础的,而这部分却很重要。万丈高楼平地起,如果连基础都不清楚,何谈应用实践(“做什么”),更加也不会理解问题的本质(“为什么”)。\n\n而要整理一篇高质量的思维导图,必须充分利用“总-分”的思路,首先要形成一个基本的提纲,然后从各个方面去延伸拓展,最后得到一棵较为完整的知识树。分解知识点后,在细究的过程中,你可能还会惊喜地发现一个知识点的各个组成部分之间的关联,对知识点有一个更为饱满的认识。\n\n梳理提纲需要对知识点有一个整体的认识。如果是学习比较陌生的领域知识,我的策略是从相关书籍或官方文档的目录中提炼出提纲。\n\n下面以我复习`javascript`对象这块知识时的一些思路为例说明。\n\n# javascript对象\n\n在复习`javascript`对象这块知识时,我从过往的一些使用经验,书籍,文档资料中提炼出了这么几个方面作为提纲,分别是:\n\n- 对象的分类\n\n- 对象的三个重要概念:类,原型,实例\n\n- 创建对象的方法\n\n- 对象属性的访问和设置\n\n- 原型和继承\n\n- 静态方法和原型方法\n\n由此展开得到了这样一个思维导图:\n\n![js对象](http://qncdn.wbjiang.cn/Object.png)\n\n## 对象的分类\n\n对象主要分为这么三大类:\n\n- **内置对象**:ECMAScript规范中定义的类或对象,比如`Object`, `Array`, `Date`等。\n\n- **宿主对象**:由javascript解释器所嵌入的宿主环境提供。比如浏览器环境会提供`window`,`HTMLElement`等浏览器特有的宿主对象。Nodejs会提供`global`全局对象\n\n- **自定义对象**:由javascript开发者自行创建的对象,用以实现特定业务。就比如我们熟悉的Vue,它就是一个自定义对象。我们可以对Vue这个对象进行实例化,用于生成基于Vue的应用。\n\n## 对象的三个重要概念\n\n### 类\n\njavascript在ES6之前没有`class`关键字,但这不影响javascript可以实现面向对象编程,javascript的类名对应构造函数名。\n\n在ES6之前,如果我们要定义一个类,其实是借助函数来实现的。\n\n```javascript\nfunction Person(name) {\n this.name = name;\n}\nPerson.prototype.sayHello = function() { \n console.log(this.name + \': hello!\');\n}\n\nvar person = new Person(\'Faker\');\nperson.sayHello();\n```\n\nES6明确定义了`class`关键字。\n\n```javascript\nclass Person {\n constructor(name) {\n this.name = name;\n }\n\n sayHello() {\n console.log(this.name + \': hello!\');\n }\n}\n\nvar person = new Person(\'Faker\');\nperson.sayHello();\n```\n\n### 原型\n\n原型是类的核心,用于定义类的属性和方法,这些属性和方法会被实例继承。\n\n定义原型属性和方法需要用到构造函数的`prototype`属性,通过`prototype`属性可以获取到原型对象的引用,然后就可以扩展原型对象了。\n\n```javascript\nfunction Person(name) {\n this.name = name;\n}\nPerson.prototype.sexList = [\'man\', \'woman\'];\nPerson.prototype.sayHello = function() {\n console.log(this.name + \': hello!\');\n}\n```\n\n### 实例\n\n类是抽象的概念,相当于一个模板,而实例是类的具体表现。就比如`Person`是一个类,而根据`Person`类,我们可以实例化多个对象,可能有小明,小红,小王等等,类的实例都是一个个独立的个体,但是他们都有共同的原型。\n\n```javascript\nvar xiaoMing = new Person(\'小明\');\nvar xiaoHong = new Person(\'小红\');\n\n// 拥有同一个原型\nObject.getPrototypeOf(xiaoMing) === Object.getPrototypeOf(xiaoHong); // true\n```\n\n## 如何创建对象\n\n### 对象直接量\n\n对象直接量也称为对象字面量。直接量就是不需要实例化,直接写键值对即可创建对象,堪称“简单粗暴”。\n\n```javascript\nvar xiaoMing = { name: \'小明\' };\n```\n\n每写一个对象直接量相当于创建了一个新的对象。即使两个对象直接量看起来一模一样,它们指向的堆内存地址也是不一样的,而对象是按引用访问的,所以这两个对象是不相等的。\n\n```javascript\nvar xiaoMing1 = { name: \'小明\' };\nvar xiaoMing2 = { name: \'小明\' };\nxiaoMing1 === xiaoMing2; // false\n```\n\n### new 构造函数\n\n可以通过关键词`new`调用javascript对象的构造函数来获得对象实例。比如:\n\n1. 创建内置对象实例\n\n```javascript\nvar o = new Object();\n```\n\n2. 创建自定义对象实例\n\n```javascript\nfunction Person(name) {\n this.name = name;\n};\nnew Person(\'Faker\');\n```\n\n### Object.create\n\n`Object.create`用于创建一个对象,接受两个参数,使用语法如下;\n\n```javascript\nObject.create(proto[, propertiesObject]);\n```\n\n第一个参数`proto`用于指定新创建对象的原型;\n\n第二个参数`propertiesObject`是新创建对象的属性名及属性描述符组成的对象。\n\n`proto`可以指定为`null`,但是意味着新对象的原型是`null`,它不会继承`Object`的方法,比如`toString()`等。\n\n`propertiesObject`参数与`Object.defineProperties`方法的第二个参数格式相同。\n\n```javascript\nvar o = Object.create(Object.prototype, {\n // foo会成为所创建对象的数据属性\n foo: { \n writable:true,\n configurable:true,\n value: \"hello\" \n },\n // bar会成为所创建对象的访问器属性\n bar: {\n configurable: false,\n get: function() { return 10 },\n set: function(value) {\n console.log(\"Setting o.bar to\", value);\n }\n }\n});\n```\n\n## 属性查询和设置\n\n### 属性查询\n\n属性查询也可以称为属性访问。在javascript中,对象属性查询非常灵活,支持点号查询,也支持字符串索引查询(之所以说是“字符串索引”,是因为写法看起像数组,索引是字符串而不是数字)。\n\n通过点号加属性名访问属性的行为很像一些静态类型语言,如java,C等。属性名是javascript标识符,必须直接写在属性访问表达式中,不能动态访问。\n\n```javascript\nvar o = { name: \'小明\' };\no.name; // \"小明\"\n```\n\n而根据字符串索引查询对象属性就比较灵活了,属性名就是字符串表达式的值,而一个表达式是可以接受变量的,这意味着可以动态访问属性,这赋予了javascript程序员很大的灵活性。下面是一个很简单的示例,而这种特性在业务实践中作用很大,比如深拷贝的实现,你往往不知道你要拷贝的对象中有哪些属性。\n\n```javascript\nvar o = { chineseName: \'小明\', englishName: \'XiaoMing\' };\n[\'chinese\', \'english\'].forEach(lang => {\n var property = lang + \'Name\';\n console.log(o[property]); // 这里使用了字符串索引访问对象属性\n})\n```\n\n对了,属性查询不仅可以查询自由属性,也可以查询继承属性。\n\n```javascript\nvar protoObj = { age: 18 };\nvar o = Object.create(protoObj);\no.age; // 18,这里访问的是原型属性,也就是继承得到的属性\n```\n\n### 属性设置\n\n通过属性访问表达式,我们可以得到属性的引用,就可以据此设置属性了。这里主要注意一下只读属性和继承属性即可,细节不再展开。\n\n## 原型和继承\n\n### 原型\n\n前面也提到了,原型是实现继承的基础。那么如何去理解原型呢?\n\n首先,要明确原型概念中的三角关系,三个主角分别是构造函数,原型,实例。我这里画了一张比较简单的图来帮助理解下。\n\n![原型三角关系](http://qncdn.wbjiang.cn/%E5%8E%9F%E5%9E%8B%E4%B8%89%E8%A7%92%E5%85%B3%E7%B3%BB.png)\n\n原型这东西吧,我感觉“没人能帮你理解,只有你自己去试过才是懂了”。\n\n不过这里说说我刚学习原型时的疑惑,疑惑的是为什么构造函数有属性`prototype`指向原型,而实例又可以通过`__proto__`指向原型,究竟`prototype`和`__proto__`谁是原型?其实这明显是没有理解对象是按引用访问这个特点了。原型对象永远只有一个,它存储于堆内存中,而构造函数的`prototype`属性只是获得了原型的引用,通过这个引用可以操作原型。\n\n同样地,`__proto__`也只是原型的引用,但是要注意了,`__proto__`不是`ECMAScript`规范里的东西,所以千万不要用在生产环境中。\n\n至于为什么不可以通过`__proto__`访问原型,原因也很简单。通过实例直接获得了原型的访问和修改权限,这本身是一件很危险的事情。\n\n举个例子,这里有一个类`LatinDancer`,意思是拉丁舞者。经过实例化操作,得到了多个拉丁舞者。\n\n```javascript\nfunction LatinDancer(name) {\n this.name = name;\n};\nLatinDancer.prototype.dance = function() {\n console.log(this.name + \'跳拉丁舞...\');\n}\n\nvar dancer1 = new LatinDancer(\'小明\');\nvar dancer2 = new LatinDancer(\'小红\');\nvar dancer3 = new LatinDancer(\'小王\');\ndancer1.dance(); // 小明跳拉丁舞...\ndancer2.dance(); // 小红跳拉丁舞...\ndancer3.dance(); // 小王跳拉丁舞...\n```\n\n大家欢快地跳着拉丁舞,突然小王这个家伙心血来潮,说:“我要做b-boy,我要跳Breaking”。于是,他私下改了原型方法`dance()`。\n```\ndancer3.__proto__.dance = function() {\n console.log(this.name + \'跳breaking...\');\n}\n\ndancer1.dance(); // 小明跳breaking...\ndancer2.dance(); // 小红跳breaking...\ndancer3.dance(); // 小王跳breaking...\n```\n\n这个时候就不对劲了,小明和小红正跳着拉丁,突然身体不受控制了,跳起了Breaking,心里暗骂:“沃尼玛,劳资不是跳拉丁的吗?”\n\n![看我表情](http://qncdn.wbjiang.cn/%E5%85%84%E5%98%9A%E7%9C%8B%E6%88%91%E8%A1%A8%E6%83%85.gif)\n\n这里只是举个例子哈,没有对任何舞种或者舞者不敬的意思,抱歉抱歉。\n\n所以,大家应该也明白了为什么不能使用`__proto__`了吧。\n\n### 原型链\n\n在javascript中,任何对象都有原型,除了`Object.prototype`,它没有原型,或者说它的原型是`null`。\n\n那么什么是原型链呢?javascript程序在查找一个对象的属性或方法时,会首先在对象本身上进行查找,如果找不到则会去对象的原型上进行查找。按照这样一个递归关系,如果原型上找不到,就会到原型的原型上找,这样一直查找下去,就会形成一个链,它的终点是`null`。\n\n还要注意的一点是,构造函数也是一个对象,也存在原型,它的原型可以通过`Function.prototype`获得,而`Function.prototype`的原型则可以通过`Object.prototype`获得。\n\n### 继承\n\n说到继承,可能大家脑子里已经冒出来“原型链继承”,“借用构造函数继承”,“寄生式继承”,“原型式继承”,“寄生组合继承”这些概念了吧。说实话,一开始我也是这么记忆,但是发现好像不是那么容易理解啊。最后,我发现,只要从原型三角关系入手,就能理清实现继承的思路。\n\n![原型三角关系](http://qncdn.wbjiang.cn/%E5%8E%9F%E5%9E%8B%E4%B8%89%E8%A7%92%E5%85%B3%E7%B3%BB.png)\n\n我们知道,对象实例能访问的属性和方法一共有三个来源,分别是:调用构造函数时挂载到实例上的属性,原型属性,对象实例化后自身新增的属性。\n\n很明显,第三个来源不是用来做继承的,那么前两个来源用来做继承分别有什么优缺点呢?很明显,如果只基于其中一种来源做继承,都不可能全面地继承来自父类的属性或方法。\n\n首先明确下继承中三个主体:**父类**,**子类**,**子类实例**。那么怎么才能让子类实例和父类搭上关系呢?\n\n#### 原型链继承\n\n所谓继承,简单说就是能通过子类实例访问父类的属性和方法。而利用原型链可以达成这样的目的,所以只要父类原型、子类原型、子类实例形成原型链关系即可。\n\n![原型链继承](https://qncdn.wbjiang.cn/%E5%8E%9F%E5%9E%8B%E9%93%BE%E7%BB%A7%E6%89%BF.png)\n\n代码示例:\n\n```javascript\nfunction Father() {\n this.nationality = \'Han\';\n};\nFather.prototype.propA = \'我是父类原型上的属性\';\nfunction Child() {};\nChild.prototype = new Father();\nChild.prototype.constructor = Child; // 修正原型上的constructor属性\nChild.prototype.propB = \'我是子类原型上的属性\';\nvar child = new Child();\nconsole.log(child.propA, child.propB, child.nationality); // 都可以访问到\nchild instanceof Father; // true\n```\n\n可以看到,在上述代码中,我们做了这样一个特殊处理`Child.prototype.constructor = Child;`。一方面是为了保证`constructor`的指向正确,毕竟实例由子类实例化得来,如果`constructor`指向父类构造函数也不太合适吧。另一方面是为了防止某些方法显示调用`constructor`时带来的麻烦。具体解释见[Why is it necessary to set the prototype constructor?](https://stackoverflow.com/questions/8453887/why-is-it-necessary-to-set-the-prototype-constructor#)\n\n**关键点**:让子类原型成为父类的实例,子类实例也是父类的实例。\n\n**缺点**:无法继承父类构造函数给实例挂载的属性。\n\n#### 借用构造函数\n\n在调用子类构造函数时,通过call调用父类构造函数,同时指定this值。\n\n```javascript\nfunction Father() {\n this.nationality = \'Han\';\n};\nFather.prototype.propA = \'我是父类原型上的属性\';\nfunction Child() {\n Father.call(this);\n};\nChild.prototype.propB = \'我是子类原型上的属性\';\nvar child = new Child();\nconsole.log(child.propA, child.propB, child.nationality);\n```\n\n这里的`child.propA`是`undefined`,因为子类实例不是父类的实例,无法继承父类原型属性。\n\n```javascript\nchild instanceof Father; // false\n```\n\n**关键点**:构造函数的复用。\n\n**缺点**:子类实例不是父类的实例,无法继承父类原型属性。\n\n#### 组合继承\n\n所谓组合继承,就是综合上述两种方法。实现代码如下:\n\n```javascript\nfunction Father() {\n this.nationality = \'Han\';\n};\nFather.prototype.propA = \'我是父类原型上的属性\';\nfunction Child() {\n Father.call(this);\n};\nChild.prototype = new Father();\nChild.prototype.constructor = Child; // 修正原型上的constructor属性\nChild.prototype.propB = \'我是子类原型上的属性\';\nvar child = new Child();\nconsole.log(child.propA, child.propB, child.nationality); // 都能访问到\n```\n\n一眼看上去没什么问题,但是`Father()`构造函数其实是被调用了两次的。第一次发生在`Child.prototype = new Father();`,此时子类原型成为了父类实例,执行父类构造函数`Father()`时,获得了实例属性`nationality`;第二次发生在`var child = new Child();`,此时执行子类构造函数`Child()`,而`Child()`中通过`call()`调用了父类构造函数,所以子类实例也获得了实例属性`nationality`。这样理解起来可能有点晦涩难懂,我们可以看看子类实例的对象结构:\n\n![组合继承的弊端](http://qncdn.wbjiang.cn/%E7%BB%84%E5%90%88%E7%BB%A7%E6%89%BF%E7%9A%84%E5%BC%8A%E7%AB%AF.png)\n\n可以看到,子类实例和子类原型上都挂载了执行父类构造函数时获得的属性`nationality`。然而我们做继承的目的是很单纯的,即“让子类继承父类属性和方法”,但并不应该给子类原型挂载不必要的属性而导致污染子类原型。\n\n有人会说“这么一点副作用怕什么”。当然,对于这么简单的父类而言,这种副作用微乎其微。假设父类有几百个属性或方法呢,这种白白耗费性能和内存的行为是有必要的吗?答案显而易见。\n\n**关键点**:实例属性和原型属性都得以继承。\n\n**缺点**:父类构造函数被执行了两次,污染了子类原型。\n\n#### 原型式继承\n\n原型式继承是相对于原型链继承而言的,与原型链继承的不同点在于,子类原型在创建时,不会执行父类构造函数,是一个纯粹的空对象。\n\n```javascript\nfunction Father() {\n this.nationality = \'Han\';\n};\nFather.prototype.propA = \'我是父类原型上的属性\';\nfunction Child() {};\nChild.prototype = Object.create(Father.prototype);\nChild.prototype.constructor = Child; // 修正原型上的constructor属性\nChild.prototype.propB = \'我是子类原型上的属性\';\nvar child = new Child();\nconsole.log(child.propA, child.propB, child.nationality); // 都可以访问到\nchild instanceof Father; // true\n```\n\n在`ES5`之前,可以这样模拟`Object.create`:\n\n```javascript\nfunction create(proto) {\n function F() {}\n F.prototype = proto;\n return new F();\n}\n```\n\n**关键点**:利用一个空对象过渡,解除子类原型和父类构造函数的强关联关系。这也意味着继承可以是纯对象之间的继承,无需构造函数介入。\n\n**缺点**:无法继承父类构造函数给实例挂载的属性,这一点和原型链继承并无差异。\n\n#### 寄生式继承\n\n寄生式继承有借鉴工厂函数的设计模式,将继承的过程封装到一个函数中并返回对象,并且可以在函数中扩展对象方法或属性。\n\n```javascript\nvar obj = {\n nationality: \'Han\'\n};\nfunction inherit(proto) {\n var o = Object.create(proto);\n o.extendFunc = function(a, b) {\n return a + b;\n }\n return o;\n}\nvar inheritObj = inherit(obj);\n```\n\n这里`inheritObj`不仅继承了`obj`,而且也扩展了`extendFunc`方法。\n\n**关键点**:工厂函数,封装过程函数化。\n\n**缺点**:如果在工厂函数中扩展对象属性或方法,无法得到复用。\n\n#### 寄生组合继承\n\n用以解决组合继承过程中存在的“父类构造函数多次被调用”问题。\n\n```javascript\nfunction inherit(childType, fatherType) {\n childType.prototype = Object.create(fatherType.prototype);\n childType.prototype.constructor = childType;\n}\n\nfunction Father() {\n this.nationality = \'Han\';\n}\nFather.prototype.propA = \'我是父类原型上的属性\';\nfunction Child() {}\ninherit(Child, Father); // 继承\nChild.prototype.propB = \'我是子类原型上的属性\';\nvar child = new Child();\nconsole.log(child);\n```\n\n**关键点**:解决父类构造函数多次执行的问题,同时让子类原型变得更加纯粹。\n\n### 静态方法\n\n何谓“静态方法”?静态方法为类所有,不归属于任何一个实例,需要通过类名直接调用。\n\n```javascript\nfunction Child() {}\nChild.staticMethod = function() { console.log(\"我是一个静态方法\") }\nvar child = new Child();\nChild.staticMethod(); // \"我是一个静态方法\"\nchild.staticMethod(); // Uncaught TypeError: child.staticMethod is not a function\n```\n\n`Object`类有很多的静态方法,我学习的时候习惯把它们分为这么几类(当然,这里没有全部列举开来,只挑了常见的方法)。\n\n#### 创建和复制对象\n\n- `Object.create()`:基于原型和属性描述符集合创建一个新对象。\n\n- `Object.assign()`:合并多个对象,会影响源对象。所以在合并对象时,为了避免这个问题,一般会这样做:\n\n```javascript\nvar mergedObj = Object.assign({}, a, b);\n```\n\n#### 属性相关\n\n- `Object.defineProperty`:通过属性描述符来定义或修改对象属性,主要涉及`value`, `configurable`, `writable`, `enumerable`四个特性。\n\n- `Object.defineProperties`:是`defineProperty`的升级版本,一次性定义或修改多个属性。\n\n- `Object.getOwnPropertyDescriptor`:获取属性描述符,是一个对象,包含`value`, `configurable`, `writable`, `enumerable`四个特性。\n\n- `Object.getOwnPropertyNames`:返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol值作为名称的属性)组成的数组。\n\n- `Object.keys`:会返回一个由一个给定对象的自身可枚举属性组成的数组,与`getOwnPropertyNames`最大的不同点在于:`keys`只返回`enumerable`为`true`的属性,并且会返回原型对象上的属性。\n\n#### 原型相关\n\n- `Object.getPrototypeOf`:返回指定对象的原型。\n\n```javascript\nfunction Child() {}\nvar child = new Child();\nObject.getPrototypeOf(child) === Child.prototype; // true\n```\n\n- `Object.setPrototypeOf`:设置指定对象的原型。这是一个比较危险的动作,同时也是一个性能不佳的方法,不推荐使用。\n\n#### 行为控制\n\n以下列举的这三个方式是一个递进的关系,我们按序来看:\n\n- `Object.preventExtensions`:让一个对象变的不可扩展,也就是永远不能再添加新的属性。\n\n- `Object.seal`:封闭一个对象,阻止添加新属性并将所有现有属性标记为不可配置。也就是说`Object.seal`在`Object.preventExtensions`的基础上,给对象属性都设置了`configurable`为`false`。\n\n这里有一个坑是:对于`configurable`为`false`的属性,虽然不能重新设置它的`configurable`和`enumerable`特性,但是可以把它的`writable`特性从`true`改为`false`(反之不行)。\n\n- `Object.freeze`:冻结一个对象,不能新增,修改,删除属性,也不能修改属性的原型。这里还有一个深冻结`deepFreeze`的概念,有点类似深拷贝的意思,递归冻结。\n\n#### 检测能力\n\n- `Object.isExtensible`:检测对象是否可扩展。\n\n- `Object.isSealed`:检测对象是否被封闭。\n\n- `Object.isFrozen`:检测对象是否被冻结。\n\n#### 兼容性差\n\n- `Object.entries`\n\n- `Object.values`\n\n- `Object.fromEntries`\n\n### 原型方法\n\n原型方法是指挂载在原型对象上的方法,可以通过实例调用,本质上是借助原型对象调用。例如:\n\n```javascript\nfunction Child() {}\nChild.prototype.protoMethod = function() { console.log(\"我是一个原型方法\") }\nvar child = new Child();\nchild.protoMethod(); // \"我是一个原型方法\"\n```\n\nECMAScript给`Object`定义了很多原型方法。\n\n![Object原型方法](https://qncdn.wbjiang.cn/Object%E5%8E%9F%E5%9E%8B%E6%96%B9%E6%B3%95.png)\n\n#### hasOwnProperty\n\n该方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性(也就是,是否有指定的键),常配合`for ... in`语句一起使用,用来遍历对象自身可枚举属性。\n\n#### isPrototypeOf\n\n该方法用于测试一个对象是否存在于另一个对象的原型链上。`Object.prototype.isPrototypeOf`与`Object.getPrototypeOf`不同点在于:\n\n- `Object.prototype.isPrototypeOf`判断的是原型链关系,并且返回一个布尔值。\n\n- `Object.getPrototypeOf`是获取目标对象的直接原型,返回的是目标对象的原型对象\n\n#### PropertyIsEnumerable\n\n该方法返回一个布尔值,表示指定的属性是否可枚举。它检测的是对象属性的`enumerable`特性。\n\n#### valueOf & toString\n\n对象转原始值会用到的方法,之前写过一篇笔记,具体见[js数据类型很简单,却也不简单](https://juejin.im/post/5eb8e2fff265da7bb46bd8ae#heading-14)。\n\n#### toLocaleString\n\n`toLocaleString`方法返回一个该对象的字符串表示。此方法被用于派生对象为了特定语言环境的目的(locale-specific purposes)而重载使用。常见于日期对象。\n\n# 最后\n\n通过阅读本文,读者们可以对Javascript对象有一个基本的认识。对象是Javascript中非常复杂的部分,绝非一篇笔记或一张思维导图可囊括,诸多细节不便展开,可关注我留言交流,回复“思维导图”可获取我整理的思维导图。', '2020-06-17 17:25:34', '2024-08-16 10:09:47', 1, 47, 0, '用思维导图的方式学习javascript对象,原型,继承', 'https://qncdn.wbjiang.cn/%E4%B8%80%E6%96%87%E6%90%9E%E6%87%82%E5%AF%B9%E8%B1%A1%E5%8E%9F%E5%9E%8B%E7%BB%A7%E6%89%BF.png', 0, 0);
-INSERT INTO `article` VALUES (214, '「冲击leetcode青铜5」回文数的两种解法', '我最近也开始看看leetcode了,有时间也刷个一两题,不得不说,现在这个行业对前端工程师的要求是越来越高了,除了写业务代码,还要懂框架原理,工程化,服务端,服务器部署,就连算法也逃不了。\n\n其实也没办法抱怨,现在高校计算机相关专业出来的毕业生中,越来越大比例的同学会选择从事前端开发,这些同学在学校或多或少都接触过数据结构和算法(鸭梨山大)。除此之外,前端开发过程中确实也有越来越多的场景会涉及到算法,简单的可能有数组转二叉树,各种排序算法,甚至有贪心算法,动态规划等......\n\n![学不动了](https://qncdn.wbjiang.cn/%E6%B1%82%E6%B1%82%E4%BD%A0%E4%BA%86%E5%AD%A6%E4%B8%8D%E5%8A%A8%E4%BA%86.gif)\n\n为了让自己学习(保持)更多知识(竞争力),我想还是冲击下leetcode青铜五吧。\n\n# 回文数\n\n打开leetcode第一天,系统给我推荐了一题回文数。\n\n> 判断一个整数是否是回文数。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。\n\n示例 1:\n\n```\n输入: 121\n输出: true\n```\n\n示例 2:\n\n```\n输入: -121\n输出: false\n解释: 从左向右读, 为 -121 。 从右向左读, 为 121- 。因此它不是一个回文数。\n```\n\n\n示例 3:\n\n```\n输入: 10\n输出: false\n解释: 从右向左读, 为 01 。因此它不是一个回文数。\n```\n\n看了下题干,我感觉还挺简单的啊,不愧是简单难度。\n\n![leetcode还挺友好](http://qncdn.wbjiang.cn/leetcode%E8%BF%98%E6%8C%BA%E5%8F%8B%E5%A5%BD.jpg)\n\n## 字符串反转\n\n我假装思考了一会儿,心想,好像只要把数字转为字符串,然后再反转一波就基本搞定了啊。\n\n对了,还要先判断一下负数的情况,显得我考虑比较周全。\n\n![张全蛋](http://qncdn.wbjiang.cn/%E5%85%A8%E8%9B%8B%E5%93%88%E5%93%88%E5%93%88.gif)\n\n```javascript\n/**\n * @param {number} x\n * @return {boolean}\n */\nvar isPalindrome = function(x) {\n if (x < 0) {\n return false;\n } else {\n var strNum = String(x);\n if (strNum.split(\'\').reverse().join(\'\') === strNum) {\n return true;\n }\n return false;\n }\n};\n```\n\n`split(\'\')`用于将字符串各个字符分割出来得到一个字符串数组,而`reverse()`是数组反转方法,`join(\'\')`则是将数组重新拼接为字符串。这样就实现了一个基于字符串反转得到的回文数解决方法。\n\n不出所料,测试用例执行通过。\n\n![测试用例执行通过](http://qncdn.wbjiang.cn/%E5%9B%9E%E6%96%87%E6%95%B0%E5%AD%97%E7%AC%A6%E4%B8%B2%E5%8F%8D%E8%BD%AC.png)\n\n## 数字反转\n\n正当我准备提交解题代码时,我发现题目中还有这么一句话:\n\n> 进阶: 你能不将整数转为字符串来解决这个问题吗?\n\n果然,问题没有这么简单,还剩最后一点倔强的我,必须要试试。\n\n我的思路是利用取模运算符`%`依次获得最后一位数字,这样就能把数字反转过来得到一个数组。最后求值的时候只需要遍历数组,乘以对应的10次幂即可。\n\n```javascript\n/**\n * @param {number} x\n * @return {boolean}\n */\nvar isPalindrome = function(x) {\n var reverseNumList = [];\n var tempNum = x;\n while(tempNum > 0) {\n var lastNum = tempNum % 10;\n reverseNumList.push(lastNum);\n tempNum = Math.floor(tempNum / 10);\n }\n var reversedValue = 0;\n for (var i = 0, len = reverseNumList.length; i < len; i++) {\n reversedValue += reverseNumList[i] * Math.pow(10, len - 1 - i)\n }\n return reversedValue === x;\n};\n```\n\n# 最后\n\nleetcode并没有限制你的解法,不管性能优劣如何,自己能把思路用代码写出来就是成功的。最后可以再看看一些排名靠前的解法,你也许能打开新世界的大门(当然,也许你会自暴自弃,哈哈...)。', '2020-06-22 19:52:59', '2024-08-25 05:03:36', 1, 22, 0, '现在这个行业对前端工程师的要求是越来越高了,除了写业务代码,还要懂框架原理,工程化,服务端,服务器部署,就连算法也逃不了。', 'http://qncdn.wbjiang.cn/leetcode%E6%8B%8D%E4%BA%86%E6%8B%8D%E6%88%91.png', 0, 0);
-INSERT INTO `article` VALUES (215, '「冲击leetcode青铜5」妙用数组fill处理每日温度', '在老家过完粽子节,回到工作地又可以一脸开(无)心(奈)地刷leetcode了。今天的题目是每日温度,给定一个温度数组,求解的目标是算出某一天需要等待几天才能超过该天的温度。\n\n# 每日温度\n\n请根据每日气温列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用`0`来代替。\n\n例如,给定一个列表`temperatures = [73, 74, 75, 71, 69, 72, 76, 73]`,你的输出应该是`[1, 1, 4, 2, 1, 1, 0, 0]`。\n\n提示:气温列表长度的范围是`[1, 30000]`。每个气温的值的均为华氏度,都是在`[30, 100]`范围内的整数。\n\n\n## 两层循环判断\n\n整体思路是,利用两层`for`循环,判断每个温度之后是否有更高的温度。要注意,第一层`for`循环到最后一个元素时,是不会进入第二层`for`循环的。此时直接通过`push`方法把`0`塞入数组。\n\n```javascript\n/**\n * @param {number[]} T\n * @return {number[]}\n */\nvar dailyTemperatures = function(T) {\n var result = [];\n for(var i = 0, len = T.length; i < len; i++) {\n if (i == len - 1) {\n result.push(0);\n } else {\n var currValue = T[i];\n var waitDay = 0;\n inner: for (var j = i + 1, len = T.length; j < len; j++) {\n if (T[j] > currValue) {\n waitDay = j - i;\n break inner;\n }\n }\n result.push(waitDay)\n }\n }\n return result;\n};\n```\n\n执行结果是:\n\n内存占用45.8M,超过100%用户?\n\n执行时间924ms,竟然只超过了20.19%的用户。\n\n## 考虑边界\n\n考虑温度的边界,如果当前温度是100,那肯定就不用进第二层循环了。\n\n```javascript\nvar dailyTemperatures = function(T) {\n var result = [];\n for(var i = 0, len = T.length; i < len; i++) {\n var currValue = T[i];\n if (currValue === 100) {\n result.push(0); \n } else if (i == len - 1) {\n result.push(0);\n } else {\n var waitDay = 0;\n inner: for (var j = i + 1, len = T.length; j < len; j++) {\n if (T[j] > currValue) {\n waitDay = j - i;\n break inner;\n }\n }\n result.push(waitDay)\n }\n }\n return result;\n};\n```\n\n## new Array & fill\n\n提交解法后,我看了一下第一名的解法,还是学到了一点东西。\n\n主要有两个地方不太一样:\n\n- 一个是使用`new Array`预先声明好数组空间,在大数组时性能表现更佳;\n- 第二个是使用了`Array.prototype.fill`预填充`0`,所以也不需要判断是否需要进第二层循环。\n\n```javascript\nvar dailyTemperatures = function(T) {\n const res = new Array(T.length).fill(0);\n for (let i = 0; i < T.length; i++) {\n for (let j = i + 1; j < T.length; j++) {\n if (T[j] > T[i]) {\n res[i] = j - i;\n break;\n } \n } \n }\n return res;\n};\n```\n\n特意对比了一下`fill`和`push`的执行时间,原来`fill`的性能挺好的。\n\n```javascript\nconsole.time(\'fill计时\');\nvar a = new Array(100).fill(0);\nconsole.timeEnd(\'fill计时\');\n// fill计时: 0.009033203125ms\n\nconsole.time(\'push计时\');\na.push(0);\na.push(0);\na.push(0);\n// 此处省略97行a.push(0);特意没有用for循环,毕竟循环也是要开销的。\nconsole.timeEnd(\'push计时\');\n// push计时: 0.02001953125ms\n```\n\n如果给数组初始化1000个值为0的元素呢?不得不说,数据量越大,`fill`性能越好。\n\n```javascript\n// fill计时: 0.01220703125ms\n// push计时: 0.136962890625ms\n```\n\n再看了看`fill`的兼容性,我只想说,在LeetCode中别怕,给我用最新的特性,IE不兼容的`fill`都可以用上。\n\n![fill兼容性](http://qncdn.wbjiang.cn/fill%E5%85%BC%E5%AE%B9%E6%80%A7.png)\n\n最后再把第一名的代码放上去提交一遍,尼玛,啪啪打脸。\n\n![打脸](http://qncdn.wbjiang.cn/%E6%AF%8F%E6%97%A5%E6%B8%A9%E5%BA%A6%E7%94%A8%E4%BA%86%E7%AC%AC%E4%B8%80%E5%90%8D%E7%9A%84%E4%BB%A3%E7%A0%81%E7%BB%93%E6%9E%9C%E6%89%93%E8%84%B8.png)\n\n同样的代码人家执行耗时`132ms`,我这里提交就是执行耗时`872ms`。我只想问,LeetCode执行用时是怎么算出来的?\n\n不过有一说一,第一名的解法确实性能更好,写法也很优雅,值得学习。', '2020-06-29 11:34:42', '2024-07-25 10:29:07', 1, 22, 0, '节后你的状态恢复了吗?奥利给!', 'http://qncdn.wbjiang.cn/leetcode%E6%8B%8D%E4%BA%86%E6%8B%8D%E6%88%91.png', 0, 0);
-INSERT INTO `article` VALUES (216, '「前端必看」这篇Nginx反向代理技巧,助你准时下班陪女神', '最近同事小G总是闷闷不乐,让我感觉慌慌的,难道是我平时压榨小G了?我转念一想,不应该啊,工作量事先都评估好了,没道理天天加班啊。\n\n坐下来聊聊后,小G向我吐槽说,”改bug效率太低了,每天加班改bug,都不能早点下班陪女神!”\n\n我深吸一口气,“卧槽,忘记传授小G秘籍了...”\n\n在一步步提问引导下,我搞清楚了小G的问题所在......\n\n![改bug好慢](http://qncdn.wbjiang.cn/%E6%94%B9bug%E5%A5%BD%E6%85%A22%E5%90%881.png)\n\n![切生产环境调试还要重跑服务](http://qncdn.wbjiang.cn/%E5%88%87%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E8%B0%83%E8%AF%952%E5%90%881.png)\n\n![我只想访问不同环境](http://qncdn.wbjiang.cn/%E6%88%91%E5%8F%AA%E6%83%B3%E8%AE%BF%E9%97%AE%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%832%E5%90%881.png)\n\n![代理层需要解耦](http://qncdn.wbjiang.cn/%E4%BB%A3%E7%90%86%E5%B1%82%E8%A7%A3%E8%80%A62%E5%90%881.png)\n\n# 问题引入\n\n相信很多前端朋友在线上debug时都吐槽过`npm run dev`或`npm start`太费时的问题吧(这里提到的两条npm脚本代指启动前端dev server)。\n\n由于环境差异,开发环境和生产环境下,我们访问的后端服务域名是不一样的。那么当我们debug生产问题时,难免还是要修改下`webpack devServer`的`proxy`配置指向生产环境域名,然后重启`devServer`,这个过程一般比较缓慢。\n\n有些时候可能测试环境也能复现bug,那么只要接入测试环境也能排查问题原因。但这不是本文关注的重点,本文主要说说如何提高debug效率。\n\n# webpack-dev-server反向代理\n\n0202年了,如果作为开发者的你还不了解反向代理,那么是很有必要去关注下了。\n\n我们知道,跨域对于前端而言是一个无法逃避的问题。如果不想在开发时麻烦后端同事,前端仔必须通过自己的手段解决跨域问题。当然,你帮后端同事买包辣条,他给你通过CORS解决跨域也是可以的。\n\n还好,`webpack-dev-server`帮我们解决了这个痛点,它基于Node代理中间件`http-proxy-middleware`实现。\n\n配置起来也非常简单:\n\n```javascript\nproxy: {\n // 需要代理的url规则\n \"/api\": {\n target: \"https://dev.xxx.tech\", // 反向代理的目标服务\n changeOrigin: true, // 开启后会虚拟一个请求头Origin\n pathRewrite: {\n \"^/api\": \"\" // 重写url,一般都用得到\n }\n }\n}\n```\n\n这个时候小G打断了我,表示不理解。\n\n![说了半天还不懂](https://qncdn.wbjiang.cn/%E7%90%86%E8%A7%A3%E4%B8%8B%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%862%E5%90%881.png)\n\n反向代理是个什么意思呢?举个例子,我想找马云借钱,马云是肯定不会借给我的。\n\n![马云对钱没兴趣](http://qncdn.wbjiang.cn/%E6%88%91%E5%AF%B9%E9%92%B1%E6%B2%A1%E5%85%B4%E8%B6%A3.jpg)\n\n但是我有一个好朋友老张,我于是找老张借了1W块,但是我没想到这个朋友和马云关系不错,他从马云那里借了1W块,然后转给我。也就是说,我不知道我借到的钱实际来源于哪里,我只知道我从我朋友老张那里借到了钱,老张给我做了一层反向代理。\n\n具体到开发中就是,我前端仔要从`https://dev.xxx.tech`这个域名调用后端接口,但是我前端开发服务运行在`http://localhost:8080`,直接调用后端接口会跨域,被浏览器同源策略阻塞,所以这条路是走不通的。\n\n因此我需要从前端服务器做个代理,这样我就可以用`http://localhost:8080/api/user/login`这种形式调用接口,就好像在调前端自己的接口一样(因为我访问的是前端的url嘛)。\n\n然而实际上是前端服务器做了一层代理,把`http://localhost:8080/api/user/login`这个接口代理到`https://dev.xxx.tech/user/login`。这对前端开发者而言是无感的。\n\n![代理层模型](http://qncdn.wbjiang.cn/%E4%BB%A3%E7%90%86%E5%B1%82%E6%A8%A1%E5%9E%8B.png)\n\n简单总结就是:反向代理隐藏了真实的服务端;相反地,正向代理隐藏了真实的客户端,类似kexueshangwang这种。\n\n# debug痛点\n\n问题来了,假设我们正在`feature`分支开发需求,这个时候上头通知要即时排查和解决一个生产bug,假设生产环境域名为`https://production.xxx.tech`。\n\n我们一般会`stash`代码,然后切`fix`分支,修改`target`的值为`\"https://production.xxx.tech\"`,然后重新运行`npm start`重启开发服务器接入生产环境,静静等待,放空自己......\n\n![放空自己](http://qncdn.wbjiang.cn/%E6%94%BE%E7%A9%BA%E8%87%AA%E5%B7%B1.png)\n\n这个时候我们就会幻想“唉,要是不用等这么久就好了!”\n\n是的,其实很多时候,一个bug并不复杂,可能解决bug只要1分钟,然而我们切换环境重新运行开发服务器就花了1分钟(大多数情况可能超过这个时间)。那么如何解决这个问题?\n\n# 代理层解耦\n\n是的,有的同学已经想到了,只要把代理服务器抽离出来,问题便可以得到解决,我不再需要把前端编译过程和服务代理目标捆绑在一起。在生产环境,这种Nginx转发对大多数人而言早已是熟门熟路,然而很少有人会尝试在开发环境中也这么做。那么不妨这样试试呢!\n\n## 下载Nginx\n\n我们照常下载[Nginx](http://nginx.org/en/download.html),选择Windows稳定版即可。\n\n## 固定前端代理\n\n为了避免在debug线上问题时需要切换proxy target而重新运行`npm start`,我们在前端层把proxy target固定下来。比如我固定访问`127.0.0.1:8090`(当然,实际上访问哪个端口可以视个人情况调整)。\n\n```javascript\nproxy: {\n \"/api\": {\n target: \"127.0.0.1:8090\", // 固定代理目标\n changeOrigin: true,\n pathRewrite: {\n \"^/api\": \"\"\n }\n }\n}\n```\n\n然后从`127.0.0.1:8090`肯定是无法访问到后端接口的,请接着往下看!\n\n## Nginx代理\n\n由于前端的接口访问已经固定为`127.0.0.1:8090`,那么剩下的工作就交给Nginx吧。我们只要在Nginx中监听本地8090端口,把请求统统转发给目标服务器即可,配置如下:\n\n```\nserver {\n listen 8090;\n server_name 127.0.0.1;\n\n location / {\n proxy_pass https://dev.xxx.tech;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n proxy_set_header Host $host;\n # proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n }\n}\n```\n\n可以看到,我在配置中注释了X-Real-IP,而我们在生产环境下配置Nginx时,一般会保留这几项Host,X-Real-IP,X-Forwarded-For,用以保留请求的服务器域名,原始客户端和代理服务器的IP等信息。\n\n如果不注释X-Real-IP,前端访问入口的真实IP是`127.0.0.1`或`localhost`,Nginx不认可这样的本地ip,直接返回404,客户端请求不予代理到其他远程服务器。不扯了,这里具体的原因我也不知,如有大佬知道原因,还请点拨下,太感谢了。\n\n好了,回到正题,有了以上的配置,我们就可以将前端代理层和Nginx代理层解耦,前端固定通过本地`127.0.0.1:8090`访问后端接口,而具体接口是代理到开发环境、测试环境或是生产环境,由Nginx决定,只需要修改`nginx.conf`后重启即可。\n\n而Nginx热重启是非常快的,一条命令即可实现,几乎零等待时间。\n\n```\n// windows下是这个命令\nnginx.exe -s reload\n// linux是这样的\nnginx -s reload\n```\n\n## 本地域名\n\n听到这里,小G又将了我一军。\n\n![端口占用](http://qncdn.wbjiang.cn/%E4%B8%87%E4%B8%80%E7%AB%AF%E5%8F%A3%E8%A2%AB%E5%8D%A0%E7%94%A82%E5%90%881.png)\n\n还好我早有准备,没有自乱阵脚。\n\n如果真的遇到本地端口被占用的情况,最简单的办法当然是换个端口。\n\n为了杜绝这种情况,我们可以引入本地域名,兼具“装逼”效果。\n\n我们知道,域名是通过解析后才能得到真实的服务IP。而域名解析过程中也有这么一些关键节点,是我们应该知道的。\n\n- 浏览器缓存\n- 操作系统hosts文件\n- Local DNS\n- Root DNS\n- gTLD Server\n\n借用网上一张图说明下大致流程(侵删)。\n\n![域名解析过程](http://qncdn.wbjiang.cn/%E5%9F%9F%E5%90%8D%E8%A7%A3%E6%9E%90%E8%BF%87%E7%A8%8B.png)\n\n上图没提到hosts文件,但是不影响我们魔改。我们只要在操作系统hosts文件这个节点动下手脚,就可以实现本地域名了。\n\n首先,我们找到`C:\\Windows\\System32\\drivers\\etc\\hosts`这个文件,打开后在最后新增一条解析记录\n\n```\n127.0.0.1 www.devtest.com\n```\n\n然后保存这个文件,保存hosts文件时需要administrator权限。\n\n这就相当于告诉本地操作系统,如果用户访问`www.devtest.com`,我就给他解析到`127.0.0.1`这个ip\n\n所以,我们在Nginx只要监听`127.0.0.1`的`80`端口即可,配置如下。\n\n```\nserver {\n listen 80;\n server_name 127.0.0.1;\n\n location / {\n proxy_pass https://dev.xxx.tech;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n }\n}\n```\n\n最后,我们只要在前端工程中把代理目标设置为`www.devtest.com`即可。\n\n```javascript\nproxy: {\n \"/api\": {\n target: \"http://www.devtest.com\", // 固定代理目标\n changeOrigin: true,\n pathRewrite: {\n \"^/api\": \"\"\n }\n }\n}\n```\n\n这样前端访问的某接口`http://localhost:8080/api/user/login`就会被代理到`http://www.devtest.com/user/login`,而`www.devtest.com`被本地hosts文件解析到`127.0.0.1`,接着Nginx监听了`127.0.0.1`的`80`端口,将请求转发到真实的后端服务,完美!\n\n对了,`www.devtest.com`是我特意命名的一个无法访问的域名,所以你千万别把`www.taobao.com`这种地址解析到本地哦,不然你没法给女神买礼物别怪我。。。\n\n![准时下班](http://qncdn.wbjiang.cn/%E6%97%A9%E7%82%B9%E4%B8%8B%E7%8F%AD2%E5%90%881.png)\n\n今天分享给大家的干货就这么多,祝愿大家准点下班陪女神!\n\n看到最后,求个关注点赞,欢迎大家加我微信交流技术,闲聊也可以哦!', '2020-07-07 09:54:05', '2024-07-25 07:34:30', 1, 34, 0, '准点下班陪女神的秘密,点开揭晓', 'http://qncdn.wbjiang.cn/%E6%88%91%E5%85%88%E4%B8%8B%E7%8F%AD%E4%BA%86.png', 0, 0);
-INSERT INTO `article` VALUES (217, '「思维导图学前端 」初中级前端值得收藏的正则表达式知识点扫盲', '本文是**思维导图学前端**系列第二篇,主题是正则表达式。首先还是想说下我的出发点,之所以自己画一遍思维导图,是因为我整理的思维导图里加入了自己的理解,更容易记忆。之前也看过很多别人整理的思维导图,虽然有点拨之用,但是要想吸收个二三分营养却也是很难。所以,建议本系列的读者在阅读文章之后,在时间允许的情况下,可以考虑自行整理知识点,便于更好地理解和吸收。\n\n推荐下同系列文章:\n\n- [「思维导图学前端」6k字一文搞懂Javascript对象,原型,继承](https://juejin.im/post/5ee9ac91f265da02aa2e751e)\n\n很多前端新手在遇到正则表达式时都望而却步,我自己初学时,也基本上是直接跳过了正则表达式这一章,除了copy网上的一些常用的正则表达式做表单校验,其余时候几乎没有去了解过如何写一个正则表达式。\n\n但是,当自己真正要去写一个适合特定业务的正则表达式时,我发现自己掌握的正则表达式知识真的是捉襟见肘。所以我这里也用思维导图整理了一些正则表达式必知必会的知识点。\n\n![正则表达式](https://qncdn.wbjiang.cn/%E6%AD%A3%E5%88%99%E8%A1%A8%E8%BE%BE%E5%BC%8F.png)\n\n# 什么是正则表达式\n\n> [正则表达式](https://baike.baidu.com/item/%E6%AD%A3%E5%88%99%E8%A1%A8%E8%BE%BE%E5%BC%8F/1700215?fr=aladdin),又称规则表达式。(英语:Regular Expression,在代码中常简写为regex、regexp或RE),计算机科学的一个概念。正则表达式通常被用来检索、替换那些符合某个模式(规则)的文本。\n\n在软件开发过程中,我们或多或少会接触到正则表达式,对于前端而言,正则表达式不仅可以校验表单,文本查找和替换,还可以做语法解析器,用于AST,编辑器等领域。\n\n# 正则表示法\n\n## 直接量表示法\n\n直接量也称为字面量,写法如下:\n\n```javascript\n/^\\d+$/g\n```\n\n直接量写法的正则表达式在执行时会转换为一个新的`RegExp`对象。我想应该是因为直接量是没有调用方法的能力的,只有转为了对象,才使得调用方法成为可能。所以才有了`/^\\d+$/.test()`。\n\n当在循环中用到正则对象`lastIndex`判断终止条件时,一定不要使用直接量正则表达式写法,否则每次循环`lastIndex`都会被重置为`0`,这是因为每次执行字面量正则表达式时,都会转换为一个新的`RegExp`对象,相应的`lastIndex`当然也会变成`0`。\n\n## RegExp对象表示法\n\n```javascript\nvar pattern = new RegExp(/^\\d+$/, \'g\')\n```\n\n第一个参数可以接受正则表达式直接量,也可以接受字符串,传递字符串作为第一个参数时,首尾不需要带斜杠,字符串中如果用到特殊字符`\\`,需要在`\\`前再加一个`\\`,防止`\\`在字符串中被转义。\n\n```javascript\n\"\\s\" === \"s\" // true\n```\n\n字符串`\"\\\\s\"`才能正确地表示`\\s`\n\n第二个参数代表标志`flags`,可接受的标志有`i`, `g`, `m`等。\n\n# 标志flags\n\n## i\n\n如果启用了标志`i`,正则表达式在执行时不区分大小写。\n\n`/abc/i.test(\'abc\')`等价于`/abc/i.test(\'ABC\')`\n\n## g\n\n如果启用了标志`g`,正则表达式会执行全局匹配,匹配到一个结果后不会立刻停止匹配,直到后续没有任何符合匹配规则的字符为止。\n\n## m\n\n如果启用了标志`m`,正则表达式会执行多行匹配,`^`可以匹配每一行的开始或整个字符串的开始,而`$`可以匹配每一行的结束或整个字符串的结束。\n\n示例如下:\n\n```javascript\n/^\\d+$/.test(\'123\\n456\') // false\n/^\\d+$/m.test(\'123\\n456\') // true\n```\n\n仍然可以匹配整个字符串\n\n```javascript\n/^\\d+\\n\\d+$/m.test(\'123\\n45\') // true\n```\n\n# 位置限定符\n\n## ^\n\n匹配字符的开始。比如必须以数字开始,可以这么写:\n\n```javascript\n/^\\d/\n```\n\n## $\n\n匹配字符的结束。比如必须以数字结束,可以这么写:\n\n```javascript\n/\\d$/\n```\n\n# 范围匹配\n\n范围匹配是利用方括号`[]`实现的。\n\n方括号`[]`用于范围匹配,也就是查找某个范围内的字符。比如`[0-9]`代表匹配数字,而`[a-z]`可以匹配小写字母a到z这26个字符中的任意一个。\n\n如果要匹配不在方括号中的字符,可以在方括号中以`^`开头,比如`[^0-9]`,用于匹配非数字,等价于`\\D`。\n\n# 主要元字符\n\n## .\n\n匹配除换行符`\\n`外的任意字符,如果要匹配任意字符,应该用`/[.\\n]*/`。\n\n## \\s\n\n匹配任意空字符,包括空格` `,制表符`\\t`,垂直制表符`\\v`,换行符`\\n`,回车符`\\r`,换页符`\\f`。`\\s`等价于`[ \\t\\v\\n\\r\\f]`,注意方括号内第一个位置有空格。\n\n这里也说下换行符和回车符的区别:\n\n- 换行符`\\n`:光标下移一行,不回行首。\n- 回车符`\\r`:光标回到行首,不换行。\n\n## \\S\n\n`\\S`是`\\s`的反集\n,利用`\\s`和`\\S`的这种互为反集的关系,我们就可以匹配任意字符,写法如下:\n\n```\n/[\\s\\S]/\n```\n\n## \\d\n\n`\\d`用于匹配数字,等价于`[0-9]`。\n\n## \\D\n\n`\\D`是`\\d`的反集,也就是匹配非数字,等价于`[^0-9]`。\n\n## \\w\n\n`\\w`用于匹配单词字符,包含`0-9`,`a-z`,`A-z`以及下划线`_`,等价于`[A-Za-z0-9_]`。\n\n## \\W\n\n`\\W`是`\\w`的反集,用于匹配非单词字符,等价于`[^A-Za-z0-9_]`。\n\n## \\n\n\n`\\n`是开发中经常遇到的换行符,而上面提到的`\\s`是包含`\\n`在内的。所以,能被`\\n`匹配的字符,也一定能被`\\s`匹配。\n\n## \\b\n\n`\\b`用于匹配单词的边界,即单词的开始或结束。\n\n一开始其实我不太能理解`\\b`在正则表达式中的作用。\n\n直到我自己试了一下这个案例\n\n```javascript\n\'I love you\'.match(/love/)\n\'Iloveyou\'.match(/love/)\n```\n\n这两个表达式都能匹配到结果`\"love\"`。\n\n但是有时候我们并不希望这样的字符串`\'Iloveyou\'`被匹配,因为它没有单词间的空格。\n\n所以`\\b`有了它存在的意义。看下面的例子:\n\n```javascript\n\'I love you\'.match(/\\blove\\b/)\n\'Iloveyou\'.match(/\\blove\\b/) // null\n```\n\n第一个表达式仍然可以正常匹配到结果,而第二个就无法匹配到结果了,这符合我们的预期。\n\n有的人可能会说,那我可以用空格匹配啊。\n\n```javascript\n\'I love you\'.match(/ love /)\n```\n\n空格和`\\b`在这种场景下还是有一点不一样的,这体现在`match`的结果上。\n\n如果是用空格匹配,那么`match`的结果数组中的第一项就是`\" love \"`,是带了空格的,然而很多时候我们不希望在结果中得到空格,所以`\\b`存在的意义也就比较明显了。\n\n## \\B\n\n与`\\b`相反,代表非单词边界。也就是说,使用`\\B`匹配时,目标字符前或后不能是空格。\n\n假设`\\B`在前,比如\n\n```javascript\n/\\Babc/.test(\'111 abc\') // false\n```\n\n假设`\\B`在后,比如\n\n```javascript\n/abc\\B/.test(\'abc 111\') // false\n```\n\n## 转义字符\\\n\n由于正则表达式中很多字符有特殊含义,比如`(`, `)`, `\\`, `[`, `]`, `+`,如果你真的要匹配它们,必须加上转义符`\\`。\n\n```javascript\n/\\//.test(\'/\'); // true\n```\n\n## 或 |\n\n实现或的逻辑是比较简单的,正则表达式提供了`|`。\n\n要注意的是,`|`隔断的是其左右的整个子表达式,而不是单个普通字符。\n\n所以,\n\n```javascript\n/^ab|cd|ef$/.test(\'ab\') // true\n/^ab|cd|ef$/.test(\'cd\') // true\n/^ab|cd|ef$/.test(\'ace\') // false\n```\n\n还要注意的是,`|`具有从左到右的优先级,如果左侧的匹配上了,右侧的就被忽略了,即便右侧的匹配看起来更“完美”。\n\n`/a|ab/.exec(\'ab\')`得到的结果是\n\n```javascript\n[\"a\", index: 0, input: \"ab\", groups: undefined]\n```\n\n# 量词\n\n## ?\n\n匹配前面的子表达式零次或一次\n\n## +\n\n匹配前面的子表达式一次或多次\n\n## *\n\n匹配前面的子表达式零次或任意次\n\n## {n,m}\n\n匹配前一个普通字符或者子表达式最少n次,最多m次\n\n## {n,}\n\n匹配前一个普通字符或者子表达式最少n次\n\n## {n}\n\n匹配前一个普通字符或者子表达式n次\n\n## 贪婪\n\n贪婪匹配是尽可能多地匹配,如果能满足匹配条件,就尽可能侵占后面的匹配规则。\n\n贪婪匹配是默认的,比如`/\\d?/`会尽可能地匹配`1`个数字,`/\\d+/`和`/\\d*/`会尽可能地匹配多个数字。\n\n举个例子,\n\n```javascript\n\'123456789\'.match(/^(\\d+)(\\d{2,})$/)\n```\n\n以上结果中捕获组的第一项是`\"1234567\"`,第二项是`\"89\"`。\n\n为什么会这样呢?因为`\\d+`是贪婪匹配,尽可能地多匹配,如果没有后面的`\\d{2,}`,捕获组第一项会直接是`\"123456789\"`。但是由于`\\d{2,}`的存在,`\\d+`会给`\\d{2,}`留个面子,满足它的最小条件,即匹配2个数字,而`\\d+`自己匹配7个数字。\n\n## 非贪婪\n\n非贪婪匹配是尽可能少地匹配,一般是在量词`?`, `+`, `*`之后再加一个`?`,表示尽可能少地匹配,把机会留给后面的匹配规则。\n\n还是拿贪婪模式中那个例子举例,稍微改一下,`\\d+`换成非贪婪模式`\\d+?`。\n\n```javascript\n\'123456789\'.match(/^(\\d+?)(\\d{2,})$/)\n```\n\n捕获组的第一项是`\"1\"`,第二项变成了`\"23456789\"`。\n\n为什么会这样呢?因为在非贪婪模式下,会尽可能少匹配,把机会留给后面的匹配规则。\n\n# 分组\n\n分组在正则中是一个非常有用的神器,用圆括号`()`来包裹的内容就是一个分组,在正则中是这种表示形式:\n\n```javascript\n/(\\d*)([a-z]*)/\n```\n\n## 捕获组()\n\n利用捕获组,我们能捕获到关键字符。\n\n比如\n\n```javascript\nvar group = \'123456789hahaha\'.match(/(\\d*)([a-z]*)/)\n```\n\n分组1用于匹配任意个数字,分组2用于匹配任意个小写字母。\n\n那么我们在`match`方法的返回结果中就可以取到这两个分组匹配的结果,`group[1]`是`\"123456789\"`,`group[2]`是`\"hahaha\"`。\n\n我们还可以在`RegExp`的静态属性`$1~$9`取得前`9`个分组匹配的结果。`RegExp.$1`是`\"123456789\"`,`RegExp.$2`是`\"hahaha\"`。但是`RegExp.$1~$9`是非标准的,虽然很多浏览器都实现了,尽量不要在生产环境中使用。\n\n这种捕获组的应用在字符串的`replace`方法中也是类似,不过在调用`replace`方法时,我们需要通过`$1`, `$2`, `$n`这种形式去引用分组。\n\n```javascript\n\"123456789hahaha\".replace(/(\\d*)([a-z]*)/, \"$1\") // \"123456789\"\n```\n\n利用`$1`,我们就可以把源字符串替换为分组1匹配到的字符串,也就是`\"123456789\"`。\n\n## 非捕获组(?:)\n\n非捕获组是不生成引用的分组,它也由圆括号`()`包裹起来,不过圆括号中起头的是`?:`,也就是`/(?:\\d*)/`这种形式。\n\n还是改造下之前的例子来看下:\n\n```javascript\nvar group = \'123456789hahaha\'.match(/(?:\\d*)([a-z]*)/)\n```\n\n由于非捕获组不生成引用,所以`group[1]`是`\"hahaha\"`;同样地,`RegExp.$1`也是`\"hahaha\"`。\n\n看到这里,我不禁也产生了疑问,既然我不需要引用非捕获组,那么非捕获组的意义何在?\n\n思考了一阵后,我觉得非捕获组大概有这么一些优势和必要性:\n\n1. 与捕获组相比,非捕获组在内存上开销更小,因为它不需要生成引用\n\n2. 分组是为了方便加量词。我们虽然可以不生成引用,但是如果没有分组,就不太方便加给一组字符加量词。\n\n```javascript\n\'1a2b3c...\'.match(/(?:\\d[a-z]){2,3}(\\.+)/)\n```\n\n## 引用\\num\n\n正则表达式中可以引用前面的具有引用的分组,通过`\\1`,`\\2`这种形式可以实现引用前面的子表达式。\n\n比如,我要匹配一个字符串,要求符合这样的规则:\n\n字符串由单引号或双引号开头和结束,中间内容可以是数字,单词。\n\n那我要保证的是首尾要么是单引号,要么是双引号,所以我的pattern写法可以是:\n\n```javascript\nvar pattern = /^([\"\'])[a-z\\d]*\\1$/\npattern.test(\"\'perfect123\'\") // true\npattern.test(\'\"1perfect2\"\') // true\n```\n\n# 零宽断言\n\n说实话,一开始看零宽断言的概念和解释时,我真的完全不懂在说什么。\n\n- 零宽正向先行断言(?=)\n- 零宽负向先行断言(?!)\n- 零宽正向后行断言(?<=)\n- 零宽负向后行断言(? ES2018才支持零宽后行断言,具体见[TC39 Proposals](https://github.com/tc39/proposals/blob/master/finished-proposals.md)\n\n## 零宽负向后行断言(? 注:ES2018才支持此特性。\n\n# RegExp\n\n说到正则表达式,就不得不提到`RegExp`对象。下面我们从原型方法,静态属性,实例属性等几个方面来认识下`RegExp`对象。\n\n## 原型方法\n\n### RegExp.prototype.test\n\n`test()`是我们平时最常用的正则方法,`test()`方法执行一个检索,用来查看正则表达式与指定的字符串是否匹配,返回一个布尔值`true`或`false`。\n\n如果正则表达式设置了全局标志`g`,执行`test()`会改变`RegExp.lastIndex`属性,用于记录上次匹配到的字符的起始索引。连续执行`test()`方法,后续的执行将会从`lastIndex`处开始匹配字符串。这种情况下,如果`test()`无法匹配到结果,`lastIndex`就会重置为`0`。\n\n### RegExp.prototype.exec\n\n`exec()`相较于`test()`能得到更丰富的匹配信息,其结果是一个数组,数组的第0个元素是匹配到的字符串,第1~n个元素是圆括号`()`分组捕获的结果。\n\n结果数组是数组,数组也是对象类型数据,所以结果数组还有两个属性分别是`index`和`input`\n\n- `index`代表匹配到的字符位于原始字符串的基于0的索引值\n- `input`则代表原始字符串\n\n与`test()`一致,如果正则表达式设置了`g`标志符,那么每次执行`exec()`都会更新`lastIndex`。\n\n## 静态属性\n\n静态属性不属于任何一个实例,必须通过类名访问,这一点在上一篇[「思维导图学前端」6k字一文搞懂Javascript对象,原型,继承](https://juejin.im/post/5ee9ac91f265da02aa2e751e)已经提到过。\n\n### RegExp.`$1-$9`\n\n用于获取分组的匹配结果,`RegExp.$1`获取的是第一个分组的匹配结果,`RegExp.$9`则是第九个分组的匹配结果。\n\n具体见上文[分组-捕获组](#)一节。\n\n## 实例属性\n\n### lastIndex\n\n`lastIndex`,从语义上理解,就是上次匹配到的字符的起始索引。要注意的是,只有设置了`g`标志,`lastIndex`才有效。\n\n当还未进行匹配时,`lastIndex`自然是`0`,代表从第`0`个字符串开始匹配。\n\n`lastIndex`会随着`exec()`和`test()`的执行而更新\n\n```javascript\nvar reg = /\\d/g\nreg.lastIndex // 0\nreg.test(\'123456\')\nreg.lastIndex // 1\nreg.exec(\'123456\')\nreg.lastIndex // 2\n```\n\n`lastIndex`可以手动修改,也就是说,你可以自由控制匹配的细节。\n\n### flags\n\n`flags`属性返回一个字符串,代表该正则表达式实例启用了哪些标志。\n\n```javascript\nvar reg = /\\d/ig\nreg.flags; // \"gi\"\n```\n\n### global\n\n`global`是布尔量,表明正则表达式是否使用了`g`标志。\n\n### ignoreCase\n\n`ignoreCase`是布尔量,表明正则表达式是否使用了`i`标志。\n\n### multiline\n\n`multiline`是布尔量,表明正则表达式是否使用了`m`标志。\n\n### source\n\n`source`,意为源,是正则表达式的字符串表示,不会包含正则字面量两边的斜杠以及任何的标志字符。\n\n# String涉及正则的方法\n\n## String.prototype.search\n\n`search()`方法用正则表达式对字符串对象进行一次匹配,结果返回一个`index`,代表正则表达式在字符串中首次匹配项的索引。如果无法匹配,则返回`-1`。\n\n`search()`方法的参数必须是正则表达式,如果不是也会被`new RegExp()`默默转换为正则表达式对象。\n\n```javascript\n\"123abc\".search(/[a-z]/); // 3\n```\n\n## String.prototype.match\n\n字符串的`match`方法用于检索字符串,和正则表达式的`exec`方法是相似的。`match`方法的参数也要求是正则表达式。`match`方法返回一个数组。\n\n与`exec()`的不同点在于,如果`match`方法传入的正则表达式带了标识`g`,则将返回与完整正则表达式匹配的所有结果,但不会返回捕获组。\n\n```javascript\n\"123abc456\".match(/([a-z])/g);\n// 返回[\"a\", \"b\", \"c\"]\n\nvar reg = /([a-z])/g;\nreg.exec(\'123abc456\');\n// 返回数组[\"a\", \"a\", index: 3, input: \"123abc456\", groups: undefined]\nreg.exec(\'123abc456\');\n// 返回数组[\"b\", \"b\", index: 4, input: \"123abc456\", groups: undefined]\nreg.exec(\'123abc456\');\n// 返回数组[\"c\", \"c\", index: 5, input: \"123abc456\", groups: undefined]\n```\n\n如果`match()`方法传入的正则表达式不带标志`g`,表现与`exec()`方法一致,只会返回第一个匹配结果和分组捕获到的结果。\n\n如果此时表达式中有圆括号分组,在`match()`的结果数组中也是可以获取到这些分组匹配的结果的,这一点在捕获组中也有提到。\n\n```javascript\n\"123abc456\".match(/([a-z])/);\n// 返回[\"a\", \"a\", index: 3, input: \"123abc456\", groups: undefined]\nRegExp.$1; // \"a\"\n```\n\n## String.prototype.replace\n\n`replace()`是字符串替换方法,它不要求第一个参数必须是正则表达式。如果第一个参数是正则表达式,并且包含分组,那么在`replace()`的第二个参数中,可以通过`\"$1\"`,`\"$2\"`这种形式引用分组匹配结果。\n\n```javascript\n\"123456789hahaha\".replace(/(\\d*)([a-z]*)/, \"$1\") // \"123456789\"\n```\n\n## String.prototype.split\n\n`split()`方法是字符串分割方法,也算平时用得很多的一个方法,但是很多人不知道它可以接受正则表达式作为参数。\n\n假设我们得到这样一个不太规律的字符串`\"1,2, 3 ,4, 5\"`,然后需要分割这个字符串得到纯数字组成的数组,直接使用`split(\",\")`是不行的,而利用正则表达式作为分割条件就可以做到。\n\n```javascript\nvar str = \"1,2, 3 ,4, 5\";\nstr.split(/\\s*,\\s*/);\n// 返回 [\"1\", \"2\", \"3\", \"4\", \"5\"]\n```\n\n# 最后\n\n正则表达式是一个非常重要却容易被忽视的知识点,在面试中也是一个频繁的考点,所以必须给予它足够的重视。经过上面的知识点梳理,相信能在后续的实战中胸有成竹,不慌不忙。', '2020-07-13 13:39:40', '2024-08-04 22:54:27', 1, 29, 0, '一文搞懂正则表达式关键知识点,面试不慌', 'http://qncdn.wbjiang.cn/%E6%AD%A3%E5%88%99%E8%A1%A8%E8%BE%BE%E5%BC%8F%E6%89%AB%E7%9B%B2.png', 0, 0);
-INSERT INTO `article` VALUES (218, '面试官真的会问:new的实现以及无new实例化', '面试官很忙,但我不单纯是蹭热点,今天聊的主题绝对是面试中命中率很高的知识点。我在复习javascript函数这块知识时,注意到一个有意思的点,就是构造函数显式return,并由此引发了一波头脑风暴......\n\n我们知道,如果不做特殊处理,new构造函数时会发生下面这几步。\n\n- 首先创建一个新对象,这个新对象的`__proto__`属性指向构造函数的`prototype`属性\n- 此时构造函数执行环境的`this`指向这个新对象\n- 执行构造函数中的代码,一般是通过`this`给新对象添加新的成员属性或方法。\n- 最后返回这个新对象。\n\n下面我们来验证下:\n\n```javascript\nfunction Test() {\n console.log(JSON.stringify(this));\n console.log(this.__proto__.constructor === Test);\n this.name = \'jack\';\n this.age = 18;\n console.log(JSON.stringify(this));\n}\nvar a = new Test();\n// Chrome控制台会输出以下内容\n// {}\n// true\n// {\"name\":\"jack\",\"age\":18}\n```\n\n这完全符合我们的认知,没毛病。\n\n# 实现一个new\n\n那么在认识到new实例化过程的几个关键步骤后,我们也能解答一道面试中常见的题目:如何实现一个new?\n\n实现一个new也就意味着不能用new关键字,那么要完成这么一系列步骤,当然是通过函数实现了。\n\n```javascript\n// func是构造函数,...args是需要传给构造函数的参数\nfunction myNew(func, ...args) {\n // 创建一个空对象,并且指定原型为func.prototype\n var obj = Object.create(func.prototype);\n // new构造函数时要执行函数,同时指定this\n func.call(obj, ...args);\n // 最后return这个对象\n return obj;\n}\n```\n\n以这四个关键步骤作为指导思想,我们很快就写出了代码实现。从这一点我也能体会到思路的重要性,别当工具人,代码才是工具!\n\n从实现逻辑上来看没什么问题,我们来验证下。\n\n```javascript\nfunction Test(name, age) {\n this.name = name;\n this.age = age;\n}\n\nmyNew(Test, \'小明\', 18);\n// Chrome控制台会输出以下内容\n// Test {name: \"小明\", age: 18}\n```\n\n# 构造函数显式return\n\n所谓显式return,就是在构造函数中主动return一个对象,这里说的对象不仅包括`Object`,也包含`Array`,`Date`等对象哦。\n\n我们可以试一试:\n\n```javascript\nfunction Test() {\n this.name = \'jack\';\n this.age = 18;\n return {\n content: \'我有freestyle\'\n }\n}\nnew Test();\n// Chrome控制台会输出以下内容\n// {content: \"我有freestyle\"}\n```\n\n那么return一个普通类型数据有没有用呢?比如字符串,数字?试试便知。\n\n```javascript\nfunction Test() {\n this.name = \'jack\';\n this.age = 18;\n return \'我有freestyle\'\n}\nnew Test();\n// Chrome控制台会输出以下内容\n// Test {name: \"jack\", age: 18}\n```\n\n可以看到,当我们return一个普通类型数据时,不会影响结果,依然会返回new出来的这个新对象。\n\n我们也应该知道,new构造函数就是为了创建对象,你return一个字符串之类的普通类型数据是没有任何意义的,所以我们的关注点应该是return一个特殊的对象。请接着往下看。\n\n# 无new实例化\n\n所谓“无new实例化”,就是指不通过new关键字实例化对象(当然,这里说的不通过new,只是调用层面的,底层还是用了new)。这一点我们使用jQuery的时候已经体验过了。\n\n```javascript\n// 实例化了一个jQuery对象,但是没有用到new\nvar ele = jQuery(\'freestyle
\');\n```\n\n那么这种黑科技是怎么实现的呢?\n\n前面已经提到了,我们可以在构造函数中通过显式return来返回一个自定义的对象,那么这里就有发挥的空间了。我们通过一个简单的例子来感受下:\n\n```javascript\nfunction Shadow() {\n this.name = \'jack\';\n this.age = 18;\n}\n\nfunction jQuery() {\n return new Shadow();\n}\n\nvar obj1 = jQuery();\nconsole.log(obj1)\n// Chrome控制台会输出以下内容\n// Shadow {name: \"jack\", age: 18}\n```\n\n`jQuery()`用了移花接木的障眼法完成了对象实例化,一手隐藏的`new Shadow()`让我们误以为不用`new`直接调用函数也能创建实例。\n\n我们再来试下`new jQuery()`,会发现,“卧槽,怎么跟`jQuery()`执行结果一模一样!”\n\n```javascript\nvar obj2 = new jQuery();\nconsole.log(obj2)\n// Chrome控制台会输出以下内容\n// Shadow {name: \"jack\", age: 18}\n```\n\n这是因为new构造函数显式return了`new Shadow()`,这样返回的结果也就是`new Shadow()`实例化出来的对象,而不使用`new`直接调用`jQuery()`,只是把`jQuery()`当成一个普通的函数执行,其结果不言而喻是`new Shadow()`实例化出来的对象。\n\n所以,这里`new jQuery()`和`jQuery()`是等价的。\n\n虽然jQuery已经用得越来越少,但是其设计思路非常值得我们学习。那么jQuery到底妙在哪里?可以说是很多,链式操作,插件体系这些特色都是我们耳熟能详的。不扯太多了,就让我们来简单分析下jQuery实例化的过程。\n\n我这里拿到了jQuery v1.12.4版本的代码,大概1W行,很舒服。\n\n翻啊翻啊,翻到了第71行,看到了这么一串代码:\n\n```javascript\njQuery = function( selector, context ) {\n return new jQuery.fn.init( selector, context );\n}\n```\n\n这不就是我们熟悉的移花接木技术吗?`jQuery.fn.init`似乎就是上面例子中的`Shadow`。看着有点像了,但是还是要好好研究下。\n\n## 为啥要搞个jQuery.fn?\n\njQuery.fn是jQuery.prototype的别名,是为了代码简洁的考虑。这一点参考源码第91行就可以知道。\n\n```javascript\njQuery.fn = jQuery.prototype = {\n// ......\n```\n\n## 移花接木如何保证原型指向?\n\n我们知道,如果仅仅通过`new jQuery.fn.init(selector, context)`是存在一个问题的,问题就是得到的实例不是`jQuery`的实例,而是`jQuery.fn.init`的实例。那么如何处理这个问题呢?\n\n我们翻到源码2866行,可以看到:\n\n```javascript\ninit = jQuery.fn.init = function( selector, context, root ) {\n // 创建实例的具体逻辑\n}\n```\n\n具体`init`方法怎么创建一个jQuery对象,做了哪些判断逻辑,这些都不是本文关注的重点。我们需要关注的是,jQuery是如何保证实例化的对象的原型指向是正确的?不然实例化的对象如何使用`jQuery.prototype`上面挂载的诸多方法呢,比如`this.show()`、`this.hide()`?\n\n紧接着翻到2982行,我有了答案:\n\n```javascript\ninit.prototype = jQuery.fn;\n```\n\n![牛逼](http://qncdn.wbjiang.cn/%E7%89%9B%E9%80%BC.gif)\n\n妙啊,这一手修改原型指向的操作,完美解决了这个问题。这样一来,`new init()`得到的实例自然也是`jQuery`的实例。\n\n```javascript\njQuery.prototype.init.prototype === jQuery.prototype; // true\nvar a = $(\'123
\')\na instanceof jQuery // true\na instanceof jQuery.fn.init // true\n```\n\n这样一来,我们可以得到一个基本的设计思路:\n\n```javascript\nfunction myModule(params) {\n return new myModule.fn.init(params);\n}\nmyModule.fn = myModule.prototype = {\n constructor: myModule\n}\nmyModule.fn.init = function(params) {\n // 可以对实例对象进行各种操作\n}\nmyModule.fn.init.prototype = myModule.prototype;\n```\n\n在这个基础上,我们可以扩展静态方法和原型方法,这个myModule模块就变得越来越丰富。\n\n# 最后\n\n妙啊,一个构造函数,让我陷入了思考......扶我起来,我还能学!\n\n![一起学前端吗](http://qncdn.wbjiang.cn/%E4%B8%80%E8%B5%B7%E5%AD%A6%E5%89%8D%E7%AB%AF%E5%90%97.jpg)', '2020-07-15 18:02:50', '2024-08-04 07:17:32', 1, 35, 0, 'new还是不new,这是一个问题', 'http://qncdn.wbjiang.cn/new%E8%BF%98%E6%98%AF%E4%B8%8Dnew.png', 0, 0);
-INSERT INTO `article` VALUES (219, '写给自己的Object和Function的3个灵魂拷问', '最近在研究函数和原型链这块内容时,我遇到了不少疑惑,对自己而言,这些疑惑可以算得上是灵魂拷问吧。在一步步探究和查证的过程中,我也许理解了一部分,也许还是什么都没懂吧,以文记之,只求能收获二三分。不知这里面有没有你遇到的疑惑呢?一起来看下吧!\n\n# Object和Function谁是谁的实例\n\n## Object instanceof Function\n\n`instanceof`检查的是右操作数的`prototype`属性是否在左操作数的原型链上。\n\n首先`Object`是一个对象类型的构造函数,而函数的构造函数是谁,当然是函数的鼻祖`Function`。所以`Object`是`Function`的实例这一点还是比较容易理解的。\n\n```javascript\nObject.__proto__ === Function.prototype;\n// true\n```\n\n其实通过下面的代码也可以侧面证明`Object`是`Function`的实例。\n\n```javascript\nObject.constructor === Function;\n// true\n```\n\n## Function instanceof Object\n\n`Function`反过来又是`Object`的实例,这又该如何理解呢?我们知道,除去`null`这种情况,原型链的顶端是`Object.prototype`,这一定程度上说明了javascript中所有的引用类型都是由`Object.prototype`构造而来。\n\n按照我们一般的思路来看,实例的原型可以通过构造函数的`prototype`属性来访问。那么这里的实例主角是谁?没错,是`Function`,那么`Function`有构造函数吗?显然,在我们认知的javascript中,`Function`本身就是函数的构造器,自然是没有`Function`的构造函数的,有的话,那也是`V8`引擎干的事了吧。\n\n那么没有构造函数就不配有原型了吗?答案是否定的。还记得我在[「思维导图学前端 」6k字一文搞懂Javascript对象,原型,继承](https://juejin.im/post/5ee9ac91f265da02aa2e751e \"「思维导图学前端 」6k字一文搞懂Javascript对象,原型,继承\")中提到的`Object.create()`方法吗?通过`Object.create()`可以直接创建一个新对象,并可以指定现有的对象作为这个新对象的原型,此过程并没有构造函数参与进来。你想啊,连`ES5`暴露给我们的API都能这么做,那么在实现`V8`等js引擎的过程当然也可以这么做。\n\n所以,`Function`也有原型,也就是`Function.__proto__`。那么`Function.__proto__`到底指向哪里?我们可以从下面这个语句中发现端倪。\n\n```javascript\nFunction.__proto__ === Function.prototype;\n// true\n```\n\n上面的表达式的结果是`true`。震惊,`Function.__proto__`竟然是`Function.prototype`!\n\n而`Function.prototype`的原型就是`Object.prototype`。这一点可以从下面的语句中得到验证!\n\n```javascript\nFunction.prototype.__proto__ === Object.prototype;\n// true\n```\n\n由于从`Function`到`Object.prototype`存在这样一段原型链关系,所以`Function instanceof Object`也是成立的。\n\n![原型链](http://qncdn.wbjiang.cn/%E5%8E%9F%E5%9E%8B%E9%93%BE.png)\n\n## Object instanceof Object\n\n从上面我们已经知道`Object instanceof Function`和`Function instanceof Object`都是成立的。根据这些已知结果,我们很容易推断出`Object instanceof Object`也是成立的。这是因为`Object`是`Function`的实例,`Function`是`Object`的实例,显然`Object`也是`Object`的实例。\n\n```javascript\nObject instanceof Object; // true\n```\n\n## Function instanceof Function\n\n有了以上的推论过程,我们自然也能理解`Function instanceof Function`是成立的。\n\n# Function.prototype是一个函数?\n\n```javascript\nFunction.prototype;\n// ƒ () { [native code] }\n\n// 以下代码可以正常执行\nFunction.prototype();\n```\n\n`Function.prototype()`可以执行,不会报错,说明`Function.prototype`真的是一个函数。\n\n```javascript\ntypeof Function.prototype; // \"function\"\n```\n\n还有个有意思的地方,就是:\n\n```javascript\nFunction.prototype.constructor === Function;\n// true\n```\n\n666,`Function`的原型指向`Function.prototype`,而`Function.prototype`的构造器反过来又是`Function`,有内味了!\n\n> 回头想了一下,这是原型三角关系,思考这部分的时候有点被绕进去了,小题大做了。\n\n# Function和Object鸡生蛋蛋生鸡?\n\n有了上面这些复杂的关系,我们不免要问问自己,到底是先有`Object`还是`Function`?\n\n我也尝试从V8源码去找一些线索,但是恕我太菜,学校教的C++基本忘光了,从源码中完全找不到思路。[V8的官方文档](https://v8.dev/docs \"V8的官方文档\")也没有说这些东西(可能是我没找到吧)。\n\n于是我找了一些分析这个问题的文章,大概有了一些认知。重要的事情说三遍:\n\n只是认知,不是答案!\n\n只是认知,不是答案!\n\n只是认知,不是答案!\n\n毕竟我也没找到直接甩V8源码进行分析的文章,如果有大佬知道这方面资源,还望分享一下链接,感谢!\n\nOK,总体的认知是这样的,加了一些我的理解在里面,希望对你有所帮助!\n\n- V8先构造了`Object`的原型[[Prototype]],简称OP,初始化其内部属性,但不包括其行为。这里有必要猜想一下,这里说的“内部属性”应该是OP在V8引擎中的属性,因为我看`Object.prototype`基本上是没有属性的,只有方法。而行为,则代表方法。\n- 基于OP构造了`Function`的原型[[Prototype]],简称FP,初始化其内部属性,但不包括其行为。\n- 将FP的原型[[Prototype]]指向OP。\n- 创建各种内置引用类型如`Object`, `Function`, `Array`, `Date`等。\n- 将各个内置引用类型的[[Prototype]]指向FP。\n- 将`Function`的`prototype`属性指向FP。\n- 将`Object`的`prototype`属性指向OP。\n- 用`Function`实例化OP,FP,`Object`的**行为**并挂载。这里别看错了,是实例化行为,也就是把OP,FP,`Object`的方法创建好,然后挂载到相应对象上。\n- 用`Object`实例化**除了**`Object`及`Function`**之外**的其他内置引用类型的`prototype`属性对象。**除了之外**这四个字是一个要关注的重点,另一个重点就是要理解`prototype`是一个对象,所以用`Object`实例化。\n- 用`Function`实例化**除了**`Object`及`Function`**之外**的其他内置引用类型的`prototype`属性对象的行为并挂载。我们知道,`prototype`是一个对象,在上一步被创建了,`prototype`对象下会有很多方法,比如数组的`push()`方法,就是在这个时候被创建的。而方法当然是用`Function`实例化。\n- 实例化内置对象Math以及Global对象。\n\n上面说的[[Prototype]]指的是一个对象的原型,与我们所熟知的`prototype`是有区别的,`prototype`只是一个属性,是指向原型的一个引用。\n\n理清[[Prototype]]和`prototype`的关系后,再仔细去想想,你会发现上面说的这些步骤是有道理的。慢慢品味吧!\n\n所以严格上来说,`Function`和`Object`没有创建时间上的先后顺序关系,与它们相比,先出现的也是它们的原型[[Prototype]]。而在它们的原型中,先有的是`Object`的原型,后有的是`Function`的原型。\n\n`Function`和`Object`没有所谓的鸡生蛋和蛋生鸡的关系,它们之间是一种互相成就的关系。\n\n灵魂拷问总是让人难以回答,啰嗦了一番,不知道我懂了没,也不知道在座的各位懂了没......\n\n可以参考的资料有:\n\n- [ECMAScript5注解](https://es5.github.io/ \"ECMAScript5注解\")\n\n- [高能!typeof Function.prototype 引发的先有 Function 还是先有 Object 的探讨](https://segmentfault.com/a/1190000005754797 \"高能!typeof Function.prototype 引发的先有 Function 还是先有 Object 的探讨\")', '2020-07-20 09:19:50', '2024-08-14 17:29:54', 1, 57, 0, '来自对象的灵魂拷问', 'http://qncdn.wbjiang.cn/%E5%AF%B9%E8%B1%A1%E7%9A%84%E7%81%B5%E9%AD%82%E6%8B%B7%E9%97%AE.png', 0, 0);
-INSERT INTO `article` VALUES (220, '解读闭包,这次从ECMAScript词法环境,执行上下文说起', '对于x年经验的前端仔来说,项目也做了好些个了,各个场景也接触过一些。但是假设真的要跟面试官敞开来撕原理,还是有点慌的。看到很多大神都在手撕各种框架原理还是有点羡慕他们的技术实力,羡慕不如行动,先踏踏实实啃基础。嗯...今天来聊聊闭包!\n\n讲闭包的文章可能大家都看了几十篇了吧,而且也能发现,一些文章(我没说全部)行文都是一个套路,基本上都在关注两个点,什么是闭包,闭包举例,很有搬运工的嫌疑。我看了这些文章之后,一个很大的感受是:如果让我给别人讲解闭包这个知识点,我能说得清楚吗?我的依据是什么?可信度有多大?我觉得我是怀疑我自己的,否定三连估计是妥了。\n\n![好像懂了吗](http://qncdn.wbjiang.cn/%E4%B8%8D%E4%BD%A0%E6%B2%A1%E6%87%82.png)\n\n不同的阶段做不同的事,当有一些基础后,我们还是可以适当地研究下原理,不要浮在问题表面!那么技术水平一般的我们,应该怎么办,怎么从这些杂乱的文章中突围?我觉得一个办法是从一些比较权威的文档上去找线索,比如ES规范,MDN,维基百科等。\n\n关于**闭包**(closure),总是有着不同的解释。\n\n第一种说法是,闭包是由**函数**以及声明该函数的**词法环境**组合而成的。这个说法来源于[MDN-闭包](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures)。\n\n另外一种说法是,闭包是指有权访问另外一个函数作用域中的变量的函数。\n\n从我的理解来看,我认为第一个说法是正确的,闭包不是一个函数,而是函数和词法环境组成的。那么第二种说法对不对呢?我觉得它说对了一半,在闭包场景下,确实存在一个函数有权访问另外一个函数作用域中的变量,但闭包不是函数。\n\n这就完了吗?显然不是!解读闭包,这次我们刨根究底(吹下牛逼)!\n\n本文会直接从**ECMAScript5规范**入手解读JS引擎的部分内部实现逻辑,基于这些认知再来重新审视**闭包**。\n\n回到主题,上文提到的**词法环境**(Lexical Environment)到底是什么?\n\n# 词法环境\n\n我们可以看看ES5规范第十章(可执行代码和执行上下文)中的第二节[词法环境](http://es5.github.io/#x10.2)是怎么说的。\n\n> A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code. \n\n词法环境是一种规范类型(specification type),它定义了标识符和ECMAScript代码中的特定变量及函数之间的联系。\n\n问题来了,规范类型(specification type)又是什么?specification type是Type的一种。从ES5规范中可以看到Type分为**language types**和**specification types**两大类。\n\n![类型示意图](http://qncdn.wbjiang.cn/ES5%E8%A7%84%E8%8C%83Type.png)\n\nlanguage types是语言类型,我们熟知的类型,也就是使用ECMAScript的程序员们可以操作的数据类型,包括`Undefined`, `Null`, `Number`, `String`, `Boolean`和`Object`。\n\n而规范类型(specification type)是一种更抽象的**元值**(meta-values),用于在算法中描述ECMAScript的语言结构和语言类型的具体语义。\n\n> A specification type corresponds to meta-values that are used within algorithms to describe the semantics of ECMAScript language constructs and ECMAScript language types.\n\n至于元值是什么,我觉得可以理解为**元数据**,而元数据是什么意思,可以简单看看这篇知乎[什么是元数据?为何需要元数据?](https://www.zhihu.com/question/20679872/answer/681988497)\n\n总的来说,**元数据是用来描述数据的数据**。这一点就可以类比于,高级语言总要用一个更底层的语言和数据结构来描述和表达。这也就是JS引擎干的事情。\n\n大致理解了规范类型是什么后,我们不免要问下:规范类型(specification type)包含什么?\n\n> The specification types are Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, and Environment Record. \n\n看到这里我好似明白了些什么,原来**词法环境**(Lexical Environment)和**环境记录**(Environment Record)都是一种**规范类型**(specification type),果然是更底层的概念。\n\n先抛开`List`, `Completion`, `Property Descriptor`, `Property Identifier`等规范类型不说,我们接着看词法环境(Lexical Environment)这种规范类型。\n\n下面这句解释了词法环境到底包含了什么内容:\n\n> A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment.\n\n词法环境包含了一个**环境记录**(Environment Record)和一个**指向外部词法环境的引用**,而这个引用的值可能为null。\n\n一个词法环境的结构如下:\n\n```\nLexical Environment\n + Outer Reference\n + Environment Record\n```\n\nOuter Reference指向外部词法环境,这也说明了词法环境是一个链表结构。简单画个结构图帮助理解下!\n\n![词法环境链表示意图](http://qncdn.wbjiang.cn/%E8%AF%8D%E6%B3%95%E7%8E%AF%E5%A2%83%E9%93%BE%E8%A1%A8%E7%BB%93%E6%9E%84%E7%A4%BA%E6%84%8F%E5%9B%BE.png)\n\n> Usually a Lexical Environment is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a WithStatement, or a Catch clause of a TryStatement and a new Lexical Environment is created each time such code is evaluated.\n\n通常,词法环境与ECMAScript代码的某些特定语法结构(如**FunctionDeclaration**,**WithStatement**或**TryStatement**的**Catch**子句)相关联,并且每次评估此类代码时都会创建一个新的词法环境。\n\nPS:evaluated是evaluate的过去分词,从字面上解释就是评估,而评估代码我觉得不是很好理解。我个人的理解是,评估代码代表着**JS引擎在解释执行javascript代码**。\n\n我们知道,执行函数会创建新的词法环境。\n\n我们也认同,with语句会“延长”作用域(实际上是调用了NewObjectEnvironment,创建了一个新的词法环境,词法环境的环境记录是一个对象环境记录)。\n\n以上这些是我们比较好理解的。那么catch子句对词法环境做了什么?虽然try-catch平时用得还比较多,但是关于词法环境的细节很多人都不会注意到,包括我!\n\n我们知道,catch子句会有一个错误对象`e`\n\n```javascript\nfunction test(value) {\n var a = value;\n try {\n console.log(b);\n // 直接引用一个不存在的变量,会报ReferenceError\n } catch(e) {\n console.log(e, arguments, this)\n }\n}\ntest(1);\n```\n\n在`catch`子句中打印`arguments`,只是为了证明`catch`子句不是一个函数。因为如果`catch`是一个函数,显然这里打印的`arguments`就不应该是`test`函数的`arguments`。既然`catch`不是一个函数,那么凭什么可以有一个仅限在`catch`子句中被访问的错误对象`e`?\n\n答案就是`catch`子句使用NewDeclarativeEnvironment创建了一个新的词法环境(catch子句中词法环境的外部词法环境引用指向函数test的词法环境),然后通过CreateMutableBinding和SetMutableBinding将标识符e与新的词法环境的环境记录关联上。\n\n有人会说,`for`循环中的`initialization`部分也可以通过`var`定义变量,和`catch`子句有什么本质区别吗?要注意的是,在ES6之前是没有块级作用域的。在`for`循环中通过`var`定义的变量原则上归属于所在函数的词法环境。如果`for`语句不是用在函数中,那么其中通过`var`定义的变量就是属于全局环境(The Global Environment)。\n\n**with语句和catch子句中建立了新的词法环境**这一结论,证据来源于上文中一句话“a new Lexical Environment is created each time such code is evaluated.”具体细节也可以看看[12.10 The with Statement](http://es5.github.io/#x12.10)和[12.14 The try Statement](https://es5.github.io/#x12.14)。\n\n# Environment Record\n\n了解了词法环境(Lexical Environment),接下来就说说词法环境中的**环境记录**(Environment Record)吧。环境记录与我们使用的变量,函数息息相关,可以说环境记录是它们的底层实现。\n\n规范描述环境记录的内容太长,这儿就不全部复制了,请直接打开[ES5规范第10.2.1节](https://es5.github.io/#x10.2.1)阅读。\n\n> There are two kinds of Environment Record values used in this specification: declarative environment records and object environment records. // 省略一大段\n\n从规范中我们可以看到环境记录(Environment Record)分为两种:\n\n- **declarative environment records** 声明式环境记录\n- **object environment records** 对象环境记录\n\nECMAScript规范约束了声明式环境记录和对象环境记录都必须实现环境记录类的一些公共的抽象方法,即便他们在具体实现算法上可能不同。\n\n这些公共的抽象方法有:\n\n- HasBinding(N)\n- CreateMutableBinding(N, D)\n- SetMutableBinding(N,V, S)\n- GetBindingValue(N,S)\n- DeleteBinding(N)\n- ImplicitThisValue()\n\n声明式环境记录还应该实现两个特有的方法:\n\n- CreateImmutableBinding(N)\n- InitializeImmutableBinding(N,V)\n\n关于不可变绑定(ImmutableBinding),在规范中有这么一段比较细致的场景描述:\n\n> If strict is true, then Call env’s CreateImmutableBinding concrete method passing the String \"arguments\" as the argument. \nCall env’s InitializeImmutableBinding concrete method passing \"arguments\" and argsObj as arguments. \nElse,Call env’s CreateMutableBinding concrete method passing the String \"arguments\" as the argument. \nCall env’s SetMutableBinding concrete method passing \"arguments\", argsObj, and false as arguments.\n\n也就是说,只有严格模式下,才会对函数的arguments对象使用不可变绑定。应用了不可变绑定(ImmutableBinding)的变量意味着不能再被重新赋值,举个例子:\n\n非严格模式下可以改变arguments的指向:\n\n```javascript\nfunction test(a, b) {\n arguments = [3, 4];\n console.log(arguments, a, b)\n}\ntest(1, 2)\n// [3, 4] 1 2\n```\n\n而在严格模式下,改变arguments的指向会直接报错:\n\n```javascript\n\"use strict\";\nfunction test(a, b) {\n arguments = [3, 4];\n console.log(arguments, a, b)\n}\ntest(1, 2)\n// Uncaught SyntaxError: Unexpected eval or arguments in strict mode\n```\n\n要注意,我这里说的是**改变arguments的指向**,而不是**修改arguments**。`arguments[2] = 3`这种操作在严格模式下是不会报错的。\n\n所以不可变绑定(ImmutableBinding)约束的是引用不可变,而不是约束引用指向的对象不可变。\n\n## declarative environment records\n\n在我们使用**变量声明**,**函数声明**,**catch子句**时,就会在JS引擎中建立对应的声明式环境记录,它们直接将identifier bindings与ECMAScript的language values关联到一起。\n\n## object environment records\n\n对象环境记录(object environment records),包含Program, WithStatement,以及后面说到的全局环境的环境记录。它们将identifier bindings与某些对象的属性关联到一起。\n\n看到这里,我自己就想问下:**identifier bindings**是啥?\n\n看了ES5规范中提到的环境记录(Environment Record)的抽象方法后,我有了一个大致的答案。\n\n先简单看一下javascript变量取值和赋值的过程:\n\n```javascript\nvar a = 1;\nconsole.log(a);\n```\n\n我们在给变量`a`初始化并赋值`1`的这样一个步骤,其实体现在JS引擎中,是执行了**CreateMutableBinding**(创建可变绑定)和**SetMutableBinding**(设置可变绑定的值)。\n\n而在对变量`a`取值时,体现在JS引擎中,是执行了**GetBindingValue**(获取绑定的值),这些执行过程中会有一些断言和判断,也会牵涉到严格模式的判断,具体见[10.2.1.1 Declarative Environment Records](https://es5.github.io/#immutable-binding)。\n\n这里也省略了一些步骤,比如说**GetIdentifierReference**, **GetValue(V)**, **PutValue(V)** 等。\n\n按我的理解,identifier bindings就是JS引擎中维护的一组**绑定关系**,可以与javascript中的**标识符**关联起来。\n\n# The Global Environment\n\n全局环境(The Global Environment)是一个特殊的词法环境,在ECMAScript代码执行之前就被创建。全局环境中的环境记录(Environment Record)是一个对象环境记录(object environment record),它被绑定到一个**全局对象**(Global Object)上,体现在**浏览器环境**中,与Global Object关联的就是**window对象**。\n\n全局环境是一个**顶层的词法环境**,因此全局环境不再有外部词法环境,或者说它的外部词法环境的引用是null。\n\n在[15.1 The Global Object](https://es5.github.io/#x15.1)一节也解释了Global Object的一些细节,比如为什么不能`new Window()`,为什么在不同的宿主环境中全局对象会有很大区别......\n\n# 执行上下文\n\n看了这些我们还是没有一个全盘的把握去解读**闭包**,不如接着看看**执行上下文**。在我之前的理解中,上下文应该是一个环境,包含了代码可访问的变量。当然,这显然还不够全面。那么上下文到底是什么?\n\n> When control is transferred to ECMAScript executable code, control is entering an execution context. Active execution contexts logically form a stack. The top execution context on this logical stack is the running execution context.\n\n当程序控制转移到**ECMAScript可执行代码**(executable code)时,就进入了一个执行上下文(execution context),执行上下文是一个逻辑上的**堆栈结构**(Stack)。堆栈中最顶层的执行上下文就是正在运行的执行上下文。\n\n很多人对**可执行代码**可能又有疑惑了,javascript不都是可执行代码吗?不是的,比如**注释**(Comment),**空白符**(White Space)就不是可执行代码。\n\n> An execution context contains whatever state is necessary to track the execution progress of its associated code.\n\n执行上下文包含了一些状态(state),这些状态用于跟踪与之关联的代码的执行进程。每个执行上下文都有这些**状态组件**(Execution Context State Components)。\n\n- **LexicalEnvironment**:词法环境\n- **VariableEnvironment**:变量环境\n- **ThisBinding**:与执行上下文直接关联的this关键字\n\n## 执行上下文的创建\n\n我们知道,解释执行global code或使用eval function,调用函数都会创建一个新的执行上下文,执行上下文是堆栈结构。\n\n> When control enters an execution context, the execution context’s ThisBinding is set, its VariableEnvironment and initial LexicalEnvironment are defined, and declaration binding instantiation (10.5) is performed. The exact manner in which these actions occur depend on the type of code being entered.\n\n当控制程序进入执行上下文时,会发生下面这3个动作:\n\n1. this关键字的值被设置。\n2. 同时VariableEnvironment(不变的)和initial LexicalEnvironment(可能会变,所以这里说的是initial)被定义。\n3. 然后执行声明式绑定初始化操作。\n\n以上这些动作的执行细节取决于代码类型(分为**global code**, **eval code**, **function code**三类)。\n\nPS:通常情况下,VariableEnvironment和LexicalEnvironment在初始化时是一致的,VariableEnvironment不会再发生变化,而LexicalEnvironment在代码执行的过程中可能会变化。\n\n那么进入global code,eval code,function code时,执行上下文会发生什么不同的变化呢?感兴趣的可以仔细阅读下[10.4 Establishing an Execution Context](http://es5.github.io/#x10.4)。\n\n# 词法环境的链表结构\n\n回顾一下上文,上文中提到,词法环境是一个链表结构。\n\n![词法环境链表示意图](http://qncdn.wbjiang.cn/%E8%AF%8D%E6%B3%95%E7%8E%AF%E5%A2%83%E9%93%BE%E8%A1%A8%E7%BB%93%E6%9E%84%E7%A4%BA%E6%84%8F%E5%9B%BE.png)\n\n众所周知,在理解闭包的时候,很多人都会提到**作用域链**(Scope Chain)这么一个概念,同时会引出**VO**(变量对象)和**AO**(活动对象)这些概念。然而我在阅读ECMAScript规范时,通篇没有找到这些关键词。我就在想,词法环境的链表结构是不是他们说的作用域链?VO,AO是不是已经过时的概念?但是这些概念又好像成了“权威”,一搜相关的文章,都在说VO, AO,我真的也要这样去理解吗?\n\n在ECMAScript中,找到[8.6.2 Object Internal Properties and Methods](http://es5.github.io/#x10.4)一节中的**Table 9 Internal Properties Only Defined for Some Objects**,的确存在[[Scope]]这么一个内部属性,按照Scope单词的意思,[[Scope]]不就是函数作用域嘛!\n\n在这个Table中,我们可以明确看到[[Scope]]的Value Type Domain一列的值是**Lexical Environment**,这说明[[Scope]]就是一种**词法环境**。我们接着看看Description:\n\n> A lexical environment that defines the environment in which a Function object is executed. Of the standard built-in ECMAScript objects, only Function objects implement [[Scope]].\n\n仔细看下,[[Scope]]是**函数对象被执行时所在的环境**,而且只有函数实现了[[Scope]]属性,这意味着[[Scope]]是函数特有的属性。\n\n所以,我是不是可以理解为:作用域链(Scope Chain)就是**函数执行时能访问的词法环境链**。而广义上的词法环境链表不仅包含了作用域链,还包括WithStatement和Catch子句中的词法环境,甚至包含ES6的Block-Level词法环境。这么看来,ECMAScript是非常严谨的!\n\n而VO,AO这两个相对陈旧的概念,由于没有官方的解释,所以基本上是“一千个读者,一千个哈姆雷特”了,我觉得可能这样理解也行:\n\n- VO是词法分析(Lexical Parsing)阶段的产物\n- AO是代码执行(Execution)阶段的产物\n\nES5及ES6规范中是没有这样的字眼的,所以干脆忘掉VO, AO吧!\n\n# 闭包\n\n## 什么是闭包?\n\n文章最开始提到了**闭包是由函数和词法环境组成**。这里再引用一段维基百科的闭包解释佐证下。\n\n> 在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。\n\n这是站在计算机科学的角度解释什么是闭包,当然这同样适用于javascript!\n\n里面提到了一个词“**自由变量**”,也就是闭包词法环境中我们重点关注的变量。\n\n## Chrome如何定义闭包?\n\nChrome浏览器似乎已经成为了前端的标准,那么在Chrome浏览器中,是如何判定闭包的呢?不妨来探索下!\n\n```javascript\nfunction test() {\n var a = 1;\n function increase() {\n debugger;\n var b = 2;\n a++;\n return a;\n };\n increase();\n}\ntest();\n```\n\n![闭包1](https://qncdn.wbjiang.cn/chrome%E9%97%AD%E5%8C%851.png)\n\n我把debugger置于内部函数`increase`中,调试时我们直接看右侧的高亮部分,可以发现,Scope中存在一个Closure(闭包),Closure的名称是外部函数`test`的函数名,闭包中的变量`a`是在函数`test`中定义的,而变量`b`是作为本地变量处于`Local`中。\n\nPS: 关于本地变量,可以参见[localEnv](http://es5.github.io/#x10.4.3)。\n\n假设我在外部函数`test`中再定义一个变量`c`,但是在内部函数`increase`中不引用它,会怎么样呢?\n\n```javascript\nfunction test() {\n var a = 1;\n var c = 3; // c不在闭包中\n function increase() {\n debugger;\n var b = 2;\n a++;\n return a;\n };\n increase();\n}\ntest();\n```\n\n经验证,内部函数`increase`执行时,变量`c`没有在闭包中。\n\n我们还可以验证,如果内部函数`increase`不引用任何外部函数`test`中的变量,就不会产生闭包。\n\n所以到这里,我们可以下这样一个结论,**闭包产生的必要条件**是:\n\n1. 存在函数嵌套;\n2. 嵌套的内部函数必须引用在外部函数中定义的变量;\n3. 嵌套的内部函数必须被执行。\n\n## 面试官最喜欢问的闭包\n\n在面试过程中,我们通常被问到的闭包场景是:内部函数引用了外部函数的变量,并且作为外部函数的返回值。这是一种特殊的闭包,举个例子看下:\n\n```javascript\nfunction test() {\n var a = 1;\n function increase() {\n a++;\n };\n function getValue() {\n return a;\n }\n return {\n increase,\n getValue\n }\n}\nvar adder = test();\nadder.increase(); // 自增1\nadder.getValue(); // 2\nadder.increase();\nadder.getValue(); // 3\n```\n\n在这个例子中,我们发现,每调用一次`adder.increase()`方法后,`a`的值会就会比上一次增加`1`,也就是说,变量`a`被保持在内存中没有被释放。\n\n那么这种现象背后到底是怎么回事呢?\n\n## 闭包分析\n\n既然闭包涉及到内存问题,那么不得不提一嘴V8的GC(垃圾回收)机制。\n\n我们从书本上了解最多的GC策略就是引用计数,但是现代主流VM(包括V8, JVM等)都不采用引用计数的回收策略,而是采用可达性算法。\n\n引用计数让人比较容易理解,所以常见于教材中,但是可能存在对象相互引用而无法释放其内存的问题。而可达性算法是从GC Roots对象(比如全局对象window)开始进行搜索存活(可达)对象,不可达对象会被回收,存活对象会经历一系列的处理。\n\n关于V8 GC的一些算法细节,有一篇文章讲得特别好,作者是洗影,非常建议去看看,已附在文末的参考资料中。\n\n而在我们关注的这种特殊闭包场景下,之所以闭包变量会保持在内存中,是因为闭包的词法环境没有被释放。我们先来分析下执行过程。\n\n```javascript\nfunction test() {\n var a = 1;\n function increase() {\n a++;\n };\n function getValue() {\n return a;\n }\n return {\n increase,\n getValue\n }\n}\nvar adder = test();\nadder.increase();\nadder.getValue();\n```\n\n1. 初始执行global code,创建全局执行上下文,随之设置`this`关键词的值为`window`对象,创建全局环境(Global Environment)。全局对象下有`adder`, `test`等变量和函数声明。\n\n![](http://qncdn.wbjiang.cn/%E5%88%9D%E5%A7%8B%E5%8C%96global%20code.png)\n\n2. 开始执行`test`函数,进入`test`函数执行上下文。在`test`函数执行过程中,声明了变量`a`,函数`increase`和`getValue`。最终返回一个对象,该对象的两个属性分别引用了函数`increase`和`getValue`。\n\n![](http://qncdn.wbjiang.cn/%E8%BF%9B%E5%85%A5test%E6%89%A7%E8%A1%8C%E4%B8%8A%E4%B8%8B%E6%96%87.png)\n\n3. 退出`test`函数执行上下文,`test`函数的执行结果赋值给变量`adder`,当前执行上下文恢复成全局执行上下文。\n\n![](http://qncdn.wbjiang.cn/%E9%80%80%E5%87%BAtest%E4%B8%8A%E4%B8%8B%E6%96%87.png)\n\n4. 调用`adder`的`increase`方法,进入`increase`函数的执行上下文,执行代码使变量`a`自增`1`。\n\n![](http://qncdn.wbjiang.cn/%E6%89%A7%E8%A1%8Cincrease%E5%87%BD%E6%95%B0.png)\n\n5. 退出`increase`函数的执行上下文。\n6. 调用`adder`的`getValue`方法,其过程与调用`increase`方法的过程类似。\n\n对整个执行过程有了一定认识后,我们似乎也很难解释为什么闭包中的变量`a`不会被GC回收。只有一个事实是很清楚的,那就是每次执行`increase`和`getValue`方法时,都依赖函数`test`中定义的变量`a`,但仅凭这个事实作为理由显然也是不具有说服力。\n\n这里不妨抛出一个问题,代码是如何解析`a`这个标识符的呢?\n\n通过阅读规范,我们可以知道,解析标识符是通过`GetIdentifierReference(lex, name, strict)`,其中`lex`是词法环境,`name`是标识符名称,`strict`是严格模式的布尔型标志。\n\n那么在执行函数`increase`时,是怎么解析标识符`a`的呢?我们来分析下!\n\n1. 首先,让`lex`的值为函数`increase`的`localEnv`(函数的本地环境),通过`GetIdentifierReference(lex, name, strict)`在`localEnv`中解析标识符`a`。\n2. 根据[GetIdentifierReference](http://es5.github.io/#x10.2.2.1)的执行逻辑,在`localEnv`并不能解析到标识符`a`(因为`a`不是在函数`increase`中声明的,这很明显),所以会转到`localEnv`的外部词法环境继续查找,而这个外部词法环境其实就是`increase`函数的内部属性[[Scope]](这一点我是从仔细看了多遍规范定义得出的),也就是`test`函数的`localEnv`的“**阉割版**”。\n3. 回到执行函数`test`那一步,执行完函数`test`后,函数`test`中`localEnv`中的其他变量的binding都能在后续GC的过程中被释放,唯独`a`的binding不能被释放,因为还有其他词法环境(`increase`函数的内部属性[[Scope]])会引用`a`。\n4. 闭包的词法环境和函数`test`执行时的`localEnv`是不一样的。函数`test`执行时,其`localEnv`会完完整整地重新初始化一遍,而退出函数`test`的执行上下文后,闭包词法环境只保留了其环境记录中的一部分bindings,这部分bindings会被其他词法环境引用,所以我称之为“阉割版”。\n\n这里可能会有朋友提出一个疑问(我也这样问过我自己),为什么`adder.increase()`是在全局执行上下文中被调用,它执行时的外部词法环境仍然是`test`函数的`localEnv`的“阉割版”?\n\n这就要回到外部词法环境引用的定义了,外部词法环境引用指向的是**逻辑上包围内部词法环境的词法环境**!\n\n> The outer reference of a (inner) Lexical Environment is a reference to the Lexical Environment that logically surrounds the inner Lexical Environment.\n\n## 闭包的优缺点\n\n网上的文章关于这一块还是讲得挺详细的,本文就不再举例了。总的来说,闭包有这么一些优点:\n\n- 变量常驻内存,对于实现某些业务很有帮助,比如计数器之类的。\n- 架起了一座桥梁,让函数外部访问函数内部变量成为可能。\n- 私有化,一定程序上解决命名冲突问题,可以实现私有变量。\n\n闭包是双刃剑,也存在这么一个比较明显的缺点:\n\n- 存在这样的可能,变量常驻在内存中,其占用内存无法被GC回收,导致内存溢出。\n\n# 小结\n\n本文从ECMAScript规范入手,一步一步揭开了闭包的神秘面纱。首先从闭包的定义了解到词法环境,从词法环境又引出环境记录,外部词法环境引用和执行上下文等概念。在对VO, AO等旧概念产生怀疑后,我选择了从规范中寻找线索,最终有了头绪。解读闭包时,我寻找了多方资料,从计算机科学的闭包通用定义入手,将一些关键概念映射到javascript中,结合GC的一些知识点,算是有了答案。\n\n写这篇文章花了不少时间,因为涉及到ECMAScript规范,一些描述必须客观严谨。解读过程必然存在主观成分,如有错误之处,还望指出!\n\n最后,非常建议大家在有空的时候多多阅读ECMAScript规范。阅读语言规范是一个很好的解惑方式,能让我们更好地理解一门语言的基本原理。就比如假设我们不清楚某个运算符的执行逻辑,那么直接看语言规范是最稳妥的!\n\n结尾附上一张可以帮助你理解ECMAScript规范的图片。\n\n![](http://qncdn.wbjiang.cn/ES5%E8%A7%84%E8%8C%83%E5%9F%BA%E6%9C%AC%E7%BB%93%E6%9E%84_%E6%97%8B%E8%BD%AC.png)\n\n如果方便的话,帮我点个赞哟,谢谢!欢迎加我微信`laobaife`交流,技术会友,闲聊亦可。\n\n# 参考资料\n\n- [ECMAScript规范](https://es5.github.io/)\n- [维基百科:一等对象(First-class object)](https://baike.hk.xileso.top/wiki/%E7%AC%AC%E4%B8%80%E9%A1%9E%E7%89%A9%E4%BB%B6)\n- [维基百科:头等函数(first-class function)](https://baike.hk.xileso.top/baike-%E5%A4%B4%E7%AD%89%E5%87%BD%E6%95%B0)\n- [维基百科:闭包](https://zh.wikipedia.org/wiki/%E9%97%AD%E5%8C%85_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6))\n- [解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法](https://developer.aliyun.com/article/592880)\n- [主流的垃圾回收机制都有哪些?](https://www.zhihu.com/question/32373436)\n- [V8 内存浅析](https://zhuanlan.zhihu.com/p/33816534)\n- [垃圾回收机制中,引用计数法是如何维护所有对象引用的?](https://www.zhihu.com/question/32373436)\n- [A tour of V8: Garbage Collection](http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection)\n', '2020-08-07 10:06:37', '2024-08-04 07:34:49', 1, 36, 0, '从ES规范出发聊聊闭包,顺便搞懂词法环境,执行上下文', 'https://qncdn.wbjiang.cn/%E7%9C%8B%E6%87%82%E8%A7%84%E8%8C%83%E4%B8%BA%E6%89%80%E6%AC%B2%E4%B8%BA.jpg', 0, 0);
-INSERT INTO `article` VALUES (221, '彻底搞懂闭包,柯里化,手写代码,金九银十不再丢分!', '这段时间我试着通过思维导图来总结知识点,主要关注的是一些相对重要或理解难度较高的内容。下面是同系列文章:\n\n- [「思维导图学前端 」6k字一文搞懂Javascript对象,原型,继承](https://juejin.im/post/6844904194097299463)\n- [「思维导图学前端 」初中级前端值得收藏的正则表达式知识点扫盲](https://juejin.im/post/6850037267365855239)\n\n如果您需要换个角度看闭包,请直接打开[解读闭包,这次从ECMAScript词法环境,执行上下文说起](https://juejin.im/post/6858052418862235656)。\n\n本文总结了javascript中函数的常见知识点,包含了基础概念,**闭包**,**this指向问题**,高阶函数,**柯里化**等,**手写代码**那部分也是满满的干货,无论您是想复习准备面试,还是想深入了解原理,本文都应该有你想看的点,总之还是值得一看的。\n\n\n\n老规矩,先上思维导图。\n\n![函数思维导图](http://qncdn.wbjiang.cn/%E5%87%BD%E6%95%B0.png)\n\n# 什么是函数\n\n> 一般来说,一个函数是可以通过外部代码调用的一个“子程序”(或在递归的情况下由内部函数调用)。像程序本身一样,一个函数由称为函数体的一系列语句组成。值可以传递给一个函数,函数将返回一个值。\n\n函数首先是一个对象,并且在javascript中,函数是一等对象(first-class object)。函数可以被执行(**callable**,拥有内部属性[[Call]]),这是函数的本质特性。除此之外,函数可以**赋值给变量**,也可以**作为函数参数**,还可以**作为另一个函数的返回值**。\n\n# 函数基本概念\n\n## 函数名\n\n函数名是函数的标识,如果一个函数不是匿名函数,它应该被赋予函数名。\n\n- 函数命名需要符合**javascript标识符**规则,必须以字母、下划线_或美元符$开始,后面可以跟数字,字母,下划线,美元符。\n- 函数命名不能使用javascript保留字,保留字是javascript中具有特殊含义的标识符。\n- 函数命名应该语义化,尽量采用动宾结构,小驼峰写法,比如`getUserName()`,`validateForm()`, `isValidMobilePhone()`。\n- 对于构造函数,我们通常写成大驼峰格式(因为构造函数与类的概念强关联)。\n\n下面是一些不成文的约定,不成文代表它不必遵守,但是我们按照这样的约定来执行,会让开发变得更有效率。\n\n- `__xxx__`代表非标准的方法。\n- `_xxx`代表私有方法。\n\n## 函数参数\n\n### 形参\n\n形参是函数定义时约定的参数列表,由一对圆括号`()`包裹。\n\n在MDN上有看到,一个函数最多可以有`255`个参数。\n\n然而形参太多时,使用者总是容易在引用时出错。所以对于数量较多的形参,一般推荐把所有参数作为属性或方法整合到一个对象中,各个参数作为这个对象的属性或方法来使用。举个例子,微信小程序的提供的API基本上是这种调用形式。\n\n```javascript\nwx.redirectTo(Object object)\n```\n\n调用示例如下:\n\n```javascript\nwx.redirectTo({\n url: \'/article/detail?id=1\',\n success: function() {},\n fail: function() {}\n})\n```\n\n形参的数量可以由函数的`length`属性获得,如下所示。\n\n```javascript\nfunction test(a, b, c) {}\ntest.length; // 3\n```\n\n### 实参\n\n实参是调用函数时传入的,实参的值在函数执行前被确定。\n\njavascript在函数定义时并不会约定参数的数据类型。如果你期望函数调用时传入正确的数据类型,你必须在函数体中对入参进行数据类型判断。\n\n```javascript\nfunction add(a, b) {\n if (typeof a !== \'number\' || typeof b !== \'number\') {\n throw new Error(\"参数必须是数字类型\")\n }\n}\n```\n\n好在Typescript提供了数据类型检查的能力,这一定程度上防止了意外情况的发生。\n\n实参的数量可以通过函数中`arguments`对象的`length`属性获得,如下所示。\n\n实参数量不一定与形参数量一致。\n\n```javascript\nfunction test(a, b, c) {\n var argLength = arguments.length;\n return argLength;\n}\ntest(1, 2); // 2\n```\n\n### 默认参数\n\n函数参数的默认值是`undefined`,如果你不传入实参,那么实际上在函数执行过程中,相应参数的值是`undefined`。\n\nES6也支持在函数声明时设置参数的默认值。\n\n```javascript\nfunction add(a, b = 2) {\n return a + b;\n}\nadd(1); // 3\n```\n\n在上面的`add`函数中,参数`b`被指定了默认值`2`。所以,即便你不传第二个参数`b`,也能得到一个预期的结果。\n\n假设一个函数有多个参数,我们希望不给中间的某个参数传值,那么这个参数值必须显示地指定为`undefined`,否则我们期望传给后面的参数的值会被传到中间的这个参数。\n\n```javascript\nfunction printUserInfo(name, age = 18, gender) {\n console.log(`姓名:${name},年龄:${age},性别:${gender}`);\n}\n// 正确地使用\nprintUserInfo(\'Bob\', undefined, \'male\');\n// 错误,\'male\'被错误地传给了age参数\nprintUserInfo(\'Bob\', \'male\');\n```\n\nPS:注意,如果你希望使用参数的默认值,请一定传`undefined`,而不是`null`。\n\n当然,我们也可以在函数体中判断参数的数据类型,防止参数被误用。\n\n```javascript\nfunction printUserInfo(name, age = 18, gender) {\n if (typeof arguments[1] === \'string\') {\n age = 18;\n gender = arguments[1];\n }\n console.log(`姓名:${name},年龄:${age},性别:${gender}`);\n}\n\nprintUserInfo(\'bob\', \'male\'); // 姓名:bob,年龄:18,性别:male\n```\n\n这样一来,函数的逻辑也不会乱。\n\n### 剩余参数\n\n> 剩余参数语法允许我们将一个不定数量的参数表示为一个数组。\n\n剩余参数通过剩余语法`...`将多个参数聚合成一个数组。\n\n```javascript\nfunction add(a, ...args) {\n return args.reduce((prev, curr) => {\n return prev + curr\n }, a)\n}\n```\n\n剩余参数和`arguments`对象之间的区别主要有三个:\n\n- 剩余参数只包含那些没有对应形参的实参,而`arguments`对象包含了传给函数的所有实参。\n- `arguments`对象不是一个真正的数组,而剩余参数是真正的`Array`实例,也就是说你能够在它上面直接使用所有的数组方法,比如`sort`,`map`,`forEach`或`pop`。而`arguments`需要借用`call`来实现,比如`[].slice.call(arguments)`。\n- `arguments`对象还有一些附加的属性(如`callee`属性)。\n\n剩余语法和展开运算符看起来很相似,然而从功能上来说,是完全相反的。\n\n> 剩余语法(Rest syntax) 看起来和展开语法完全相同,不同点在于, 剩余参数用于解构数组和对象。从某种意义上说,剩余语法与展开语法是相反的:展开语法将数组展开为其中的各个元素,而剩余语法则是将多个元素收集起来并“凝聚”为单个元素。\n\n### arguments\n\n函数的实际参数会被保存在一个类数组对象`arguments`中。\n\n类数组(ArrayLike)对象具备一个非负的`length`属性,并且可以通过从`0`开始的索引去访问元素,让人看起来觉得就像是数组,比如`NodeList`,但是类数组默认没有数组的那些内置方法,比如`push`, `pop`, `forEach`, `map`。\n\n我们可以试试,随便找一个网站,在控制台输入:\n\n```javascript\nvar linkList = document.querySelectorAll(\'a\')\n```\n\n会得到一个`NodeList`,我们也可以通过数字下标去访问其中的元素,比如`linkList[0]`。\n\n但是`NodeList`不是数组,它是类数组。\n\n```javascript\nArray.isArray(linkList); // false\n```\n\n回到主题,`arguments`也是类数组,`arguments`的`length`由**实参的数量**决定,而不是由形参的数量决定。\n\n```javascript\nfunction add(a, b) {\n console.log(arguments.length);\n return a + b;\n}\nadd(1, 2, 3, 4);\n// 这里打印的是4,而不是2\n```\n\n`arguments`也是一个和严格模式有关联的对象。\n\n- 在**非严格模式**下,`arguments`里的元素和函数参数都是指向同一个值的引用,对`arguments`的修改,会直接影响函数参数。\n\n```javascript\nfunction test(obj) {\n arguments[0] = \'传入的实参是一个对象,但是被我变成字符串了\'\n console.log(obj)\n}\ntest({name: \'jack\'})\n// 这里打印的是字符串,而不是对象\n```\n\n- 在**严格模式**下,`arguments`是函数参数的副本,对`arguments`的修改不会影响函数参数。但是`arguments`不能重新被赋值,关于这一点,我在[解读闭包,这次从ECMAScript词法环境,执行上下文说起](https://juejin.im/post/6858052418862235656)这篇文章中解读**不可变绑定**时有提到。在严格模式下,也不能使用`arguments.caller`和`arguments.callee`,限制了对调用栈的检测能力。\n\n## 函数体\n\n函数体(FunctionBody)是函数的主体,其中的函数代码(function code)由一对花括号`{}`包裹。函数体可以为空,也可以由任意条javascript语句组成。\n\n## 函数的调用形式\n\n大体来说,函数的调用形式分为以下四种:\n\n### 作为普通函数\n\n函数作为普通函数被调用,这是函数调用的常用形式。\n\n```javascript\nfunction add(a, b) {\n return a + b;\n}\nadd(); // 调用add函数\n```\n\n作为普通函数调用时,如果在**非严格模式**下,函数执行时,`this`指向全局对象,对于浏览器而言则是`window`对象;如果在**严格模式**下,`this`的值则是`undefined`。\n\n### 作为对象的方法\n\n函数也可以作为对象的成员,这种情况下,该函数通常被称为对象方法。当函数作为对象的方法被调用时,`this`指向该对象,此时便可以通过`this`访问对象的其他成员变量或方法。\n\n```javascript\nvar counter = {\n num: 0,\n increase: function() {\n this.num++;\n }\n}\ncounter.increase();\n```\n\n### 作为构造函数\n\n函数配合`new`关键字使用时就成了构造函数。构造函数用于实例化对象,构造函数的执行过程大致如下:\n\n1. 首先创建一个新对象,这个新对象的`__proto__`属性指向构造函数的`prototype`属性。\n2. 此时构造函数的`this`指向这个新对象。\n3. 执行构造函数中的代码,一般是通过`this`给新对象添加新的成员属性或方法。\n4. 最后返回这个新对象。\n\n实例化对象也可以通过一些技巧来简化,比如在构造函数中显示地`return`另一个对象,jQuery很巧妙地利用了这一点。具体分析详见[面试官真的会问:new的实现以及无new实例化](https://juejin.im/post/6850037282319204360)。\n\n### 通过call, apply调用\n\n`apply`和`call`是函数对象的原型方法,挂载于`Function.prototype`。利用这两个方法,我们可以显示地绑定一个`this`作为调用上下文,同时也可以设置函数调用时的参数。\n\n`apply`和`call`的区别在于:提供参数的形式不同,`apply`方法接受的是一个参数**数组**,`call`方法接受的是参数**列表**。\n\n```javascript\nsomeFunc.call(obj, 1, 2, 3)\nsomeFunc.apply(obj, [1, 2, 3])\n```\n\n注意,在非严格模式下使用`call`或者`apply`时,如果第一个参数被指定为`null`或`undefined`,那么函数执行时的`this`指向全局对象(浏览器环境中是`window`);如果第一个参数被指定为原始值,该原始值会被包装。这部分内容在下文中的手写代码会再次讲到。\n\n`call`是用来实现继承的重要方法。在子类构造函数中,通过`call`来调用父类构造函数,以使对象实例获得来自父类构造函数的属性或方法。\n\n```javascript\nfunction Father() {\n this.nationality = \'Han\';\n};\nFather.prototype.propA = \'我是父类原型上的属性\';\nfunction Child() {\n Father.call(this);\n};\nChild.prototype.propB = \'我是子类原型上的属性\';\nvar child = new Child();\nchild.nationality; // \"Han\"\n```\n\n# call, apply, bind\n\n`call`,`apply`,`bind`都可以绑定`this`,区别在于:`apply`和`call`是绑定`this`后直接调用该函数,而`bind`会返回一个新的函数,并不直接调用,可以由程序员决定调用的时机。\n\n`bind`的语法形式如下:\n\n```javascript\nfunction.bind(thisArg[, arg1[, arg2[, ...]]])\n```\n\n`bind`的`arg1, arg2, ...`是给新函数预置好的参数(预置参数是可选的)。当然新函数在执行时也可以继续追加参数。\n\n# 手写call, apply, bind\n\n提到`call`,`apply`,`bind`总是无法避免**手写代码**这个话题。手写代码不仅仅是为了应付面试,也是帮助我们理清思路和深入原理的一个好方法。手写代码**一定不要抄袭**,如果实在没思路,可以参考下别人的代码整理出思路,再自己按照思路独立写一遍代码,然后验证看看有没有缺陷,这样才能有所收获,否则忘得很快,只能短时间应付应付。\n\n那么如何才能顺利地手写代码呢?首先是要清楚一段代码的作用,可以从官方对于它的定义和描述入手,同时还要注意一些特殊情况下的处理。\n\n就拿`call`来说,`call`是函数对象的原型方法,它的作用是绑定`this`和参数,并执行函数。调用形式如下:\n\n```javascript\nfunction.call(thisArg, arg1, arg2, ...)\n```\n\n那么我们慢慢来实现它,将我们要实现的函数命名为`myCall`。首先`myCall`是一个函数,接受的第一个参数`thisArg`是目标函数执行时的`this`的值,从第二个可选参数`arg1`开始的其他参数将作为目标函数执行时的实参。\n\n这里面有很多细节要考虑,我大致罗列了一下:\n\n1. 要考虑是不是严格模式。如果是非严格模式,对于`thisArg`要特殊处理。\n2. 如何判断严格模式?\n3. `thisArg`被处理后还要进行非空判断,然后考虑是以方法的形式调用还是以普通函数的形式调用。\n4. 目标函数作为方法调用时,如何不覆盖对象的原有属性?\n\n实现代码如下,请仔细看我写的注释,这是主要的思路!\n\n```javascript\n// 首先apply是Function.prototype上的一个方法\nFunction.prototype.myCall = function() {\n // 由于目标函数的实参数量是不定的,这里就不写形参了\n // 实际上通过arugments对象,我们能拿到所有实参\n // 第一个参数是绑定的this\n var thisArg = arguments[0];\n // 接着要判断是不是严格模式\n var isStrict = (function(){return this === undefined}())\n if (!isStrict) {\n // 如果在非严格模式下,thisArg的值是null或undefined,需要将thisArg置为全局对象\n if (thisArg === null || thisArg === undefined) {\n // 获取全局对象时兼顾浏览器环境和Node环境\n thisArg = (function(){return this}())\n } else {\n // 如果是其他原始值,需要通过构造函数包装成对象\n var thisArgType = typeof thisArg\n if (thisArgType === \'number\') {\n thisArg = new Number(thisArg)\n } else if (thisArgType === \'string\') {\n thisArg = new String(thisArg)\n } else if (thisArgType === \'boolean\') {\n thisArg = new Boolean(thisArg)\n }\n }\n }\n // 截取从索引1开始的剩余参数\n var invokeParams = [...arguments].slice(1);\n // 接下来要调用目标函数,那么如何获取到目标函数呢?\n // 实际上this就是目标函数,因为myCall是作为一个方法被调用的,this当然指向调用对象,而这个对象就是目标函数\n // 这里做这么一个赋值过程,是为了让语义更清晰一点\n var invokeFunc = this;\n // 此时如果thisArg对象仍然是null或undefined,那么说明是在严格模式下,并且没有指定第一个参数或者第一个参数的值本身就是null或undefined,此时将目标函数当成普通函数执行并返回其结果即可\n if (thisArg === null || thisArg === undefined) {\n return invokeFunc(...invokeParams)\n }\n // 否则,让目标函数成为thisArg对象的成员方法,然后调用它\n // 直观上来看,可以直接把目标函数赋值给对象属性,比如func属性,但是可能func属性本身就存在于thisArg对象上\n // 所以,为了防止覆盖掉thisArg对象的原有属性,必须创建一个唯一的属性名,可以用Symbol实现,如果环境不支持Symbol,可以通过uuid算法来构造一个唯一值。\n var uniquePropName = Symbol(thisArg)\n thisArg[uniquePropName] = invokeFunc\n // 返回目标函数执行的结果\n return thisArg[uniquePropName](...invokeParams)\n}\n```\n\n写完又思考了一阵,我突然发现有个地方考虑得有点多余了。\n\n```javascript\n// 如果在非严格模式下,thisArg的值是null或undefined,需要将thisArg置为全局对象\nif (thisArg === null || thisArg === undefined) {\n // 获取全局对象时兼顾浏览器环境和Node环境\n thisArg = (function(){return this}())\n} else {\n```\n\n其实这种情况下不用处理`thisArg`,因为代码执行到该函数后面部分,目标函数会被作为普通函数执行,那么`this`自然指向全局对象!所以这段代码可以删除了!\n\n接着来测试一下`myCall`是否可靠,我写了一个简单的例子:\n\n```javascript\nfunction test(a, b) {\n var args = [].slice.myCall(arguments)\n console.log(arguments, args)\n}\ntest(1, 2)\n\nvar obj = {\n name: \'jack\'\n};\nvar name = \'global\';\nfunction getName() {\n return this.name;\n}\ngetName();\ngetName.myCall(obj);\n```\n\n我不敢保证我写的这个`myCall`方法没有bug,但也算是考虑了很多情况了。就算是在面试过程中,面试官主要关注的就是你的思路和考虑问题的全面性,如果写到这个程度还不能让面试官满意,那也无能为力了......\n\n理解了手写`call`之后,手写`apply`也自然触类旁通,只要注意两点即可。\n\n- `myApply`接受的第二个参数是数组形式。\n- 要考虑实际调用时不传第二个参数或者第二个参数不是数组的情况。\n\n直接上代码:\n\n```javascript\nFunction.prototype.myApply = function(thisArg, params) {\n var isStrict = (function(){return this === undefined}())\n if (!isStrict) {\n var thisArgType = typeof thisArg\n if (thisArgType === \'number\') {\n thisArg = new Number(thisArg)\n } else if (thisArgType === \'string\') {\n thisArg = new String(thisArg)\n } else if (thisArgType === \'boolean\') {\n thisArg = new Boolean(thisArg)\n }\n }\n var invokeFunc = this;\n // 处理第二个参数\n var invokeParams = Array.isArray(params) ? params : [];\n if (thisArg === null || thisArg === undefined) {\n return invokeFunc(...invokeParams)\n }\n var uniquePropName = Symbol(thisArg)\n thisArg[uniquePropName] = invokeFunc\n return thisArg[uniquePropName](...invokeParams)\n}\n```\n\n用比较常用的`Math.max`来测试一下:\n\n```javascript\nMath.max.myApply(null, [1, 2, 4, 8]);\n// 结果是8\n```\n\n接下来就是手写`bind`了,首先要明确,`bind`与`call`, `apply`的不同点在哪里。\n\n- `bind`返回一个新的函数。\n- 这个新的函数可以预置参数。\n\n好的,按照思路开始写代码。\n\n```javascript\nFunction.prototype.myBind = function() {\n // 保存要绑定的this\n var boundThis = arguments[0];\n // 获得预置参数\n var boundParams = [].slice.call(arguments, 1);\n // 获得绑定的目标函数\n var boundTargetFunc = this;\n if (typeof boundTargetFunc !== \'function\') {\n throw new Error(\'绑定的目标必须是函数\')\n }\n // 返回一个新的函数\n return function() {\n // 获取执行时传入的参数\n var restParams = [].slice.call(arguments);\n // 合并参数\n var allParams = boundParams.concat(restParams)\n // 新函数被执行时,通过执行绑定的目标函数获得结果,并返回结果\n return boundTargetFunc.apply(boundThis, allParams)\n }\n}\n```\n\n本来写到这觉得已经结束了,但是翻到一些资料,都提到了手写`bind`需要支持`new`调用。仔细一想也对,`bind`返回一个新的函数,这个函数被作为构造函数使用也是很有可能的。\n\n我首先思考的是,能不能直接判断一个函数是不是以构造函数的形式执行的呢?如果能判断出来,那么问题就相对简单了。\n\n于是我想到构造函数中很重要的一点,那就是**在构造函数中,this指向对象实例**。所以,我利用`instanceof`改了一版代码出来。\n\n```javascript\nFunction.prototype.myBind = function() {\n var boundThis = arguments[0];\n var boundParams = [].slice.call(arguments, 1);\n var boundTargetFunc = this;\n if (typeof boundTargetFunc !== \'function\') {\n throw new Error(\'绑定的目标必须是函数\')\n }\n function fBound () {\n var restParams = [].slice.call(arguments);\n var allParams = boundParams.concat(restParams)\n // 通过instanceof判断this是不是fBound的实例\n var isConstructor = this instanceof fBound;\n if (isConstructor) {\n // 如果是,说明是通过new调用的(这里有bug,见下文),那么只要把处理好的参数传给绑定的目标函数,并通过new调用即可。\n return new boundTargetFunc(...allParams)\n } else {\n // 如果不是,说明不是通过new调用的\n return boundTargetFunc.apply(boundThis, allParams)\n }\n }\n return fBound\n}\n```\n\n最后看了一下MDN提供的[bind函数的polyfill](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/bind),发现思路有点不一样,于是我通过一个实例进行对比。\n\n```javascript\nfunction test() {}\nvar fBoundNative = test.bind()\nvar obj1 = new fBoundNative()\nvar fBoundMy = test.myBind()\nvar obj2 = new fBoundMy()\nvar fBoundMDN = test.mdnBind()\nvar obj3 = new fBoundMDN()\n```\n\n![bind效果对比](https://qncdn.wbjiang.cn/bind%E6%95%88%E6%9E%9C%E5%AF%B9%E6%AF%94.png)\n\n我发现我的写法看起来竟然更像原生`bind`。瞬间怀疑自己,但一下子却没找到很明显的bug......\n\n终于我还是意识到了一个很大的问题,`obj1`是`fBoundNative`的实例,`obj3`是`fBoundMDN`的实例,但`obj2`不是`fBoundMy`的实例(实际上`obj2`是`test`的实例)。\n\n```javascript\nobj1 instanceof fBoundNative; // true\nobj2 instanceof fBoundMy; // false\nobj3 instanceof fBoundMDN; // true\n```\n\n存在这个问题麻烦就大了,假设我要在`fBoundMy.prototype`上继续扩展原型属性或方法,`obj2`是无法继承它们的。所以最直接有效的方法就是用继承的方法来实现,虽然不能达到原生`bind`的效果,但已经够用了。于是我参考MDN改了一版。\n\n```javascript\nFunction.prototype.myBind = function() {\n var boundTargetFunc = this;\n if (typeof boundTargetFunc !== \'function\') {\n throw new Error(\'绑定的目标必须是函数\')\n }\n var boundThis = arguments[0];\n var boundParams = [].slice.call(arguments, 1);\n function fBound () {\n var restParams = [].slice.call(arguments);\n var allParams = boundParams.concat(restParams)\n return boundTargetFunc.apply(this instanceof fBound ? this : boundThis, allParams)\n }\n fBound.prototype = Object.create(boundTargetFunc.prototype || Function.prototype)\n return fBound\n}\n```\n\n这里面最重要的两点:**处理好原型链关系**,以及**理解bind中构造实例的过程**。\n\n- **原型链处理**\n\n```javascript\nfBound.prototype = Object.create(boundTargetFunc.prototype || Function.prototype)\n```\n\n这一行代码中用了一个`||`运算符,`||`的两端充分考虑了`myBind`函数的两种可能的调用方式。\n\n1. **常规的函数绑定**\n\n```javascript\nfunction test(name, age) {\n this.name = name;\n this.age = age;\n}\nvar bound1 = test.myBind(\'小明\')\nvar obj1 = new bound1(18)\n```\n\n这种情况把`fBound.prototype`的原型指向`boundTargetFunc.prototype`,完全符合我们的思维。\n\n2. **直接使用Function.prototype.myBind**\n\n```javascript\nvar bound2 = Function.prototype.myBind()\nvar obj2 = new bound2()\n```\n\n这相当于创建一个新的函数,绑定的目标函数是`Function.prototype`。这里必然有朋友会问了,`Function.prototype`也是函数吗?是的,请看!\n\n```javascript\ntypeof Function.prototype; // \"function\"\n```\n\n虽然我还不知道第二种调用方式存在的意义,但是存在即合理,既然存在,我们就支持它。\n\n- **理解bind中构造实例的过程**\n\n首先要清楚`new`的执行过程,如果您还不清楚这一点,可以看看我写的这篇[面试官真的会问:new的实现以及无new实例化](https://juejin.im/post/6850037282319204360)。\n\n还是之前那句话,先要判断是不是以构造函数的形式调用的。核心就是这:\n\n```javascript\nthis instanceof fBound\n```\n\n我们用一个例子再来分析下`new`的过程。\n\n```javascript\nfunction test(name, age) {\n this.name = name;\n this.age = age;\n}\nvar bound1 = test.myBind(\'小明\')\nvar obj1 = new bound1(18)\nobj1 instanceof bound1 // true\nobj1 instanceof test // true\n```\n\n1. 执行构造函数`bound1`,实际上是执行`myBind`执行后返回的新函数`fBound`。首先会创建一个新对象`obj1`,并且`obj1`的非标准属性`__proto__`指向`bound1.prototype`,其实就是`myBind`执行时声明的`fBound.prototype`,而`fBound.prototype`的原型指向`test.prototype`。所以到这里,原型链就串起来了!\n2. 执行的构造函数中,`this`指向这个`obj1`。\n3. 执行构造函数,由于`fBound`是没有实际内容的,执行构造函数本质上还是要去执行绑定的那个目标函数,本例中也就是`test`。因此如果是以构造函数形式调用,我们就把实例对象作为`this`传给`test.apply`。\n4. 通过执行`test`,对象实例被挂载了`name`和`age`属性,一个崭新的对象就出炉了!\n\n最后附上[Raynos大神写的bind实现](https://github.com/Raynos/function-bind/blob/master/implementation.js),我感觉又受到了“暴击”!有兴趣钻研`bind`终极奥义的朋友请点开链接查看源码!\n\n![](https://qncdn.wbjiang.cn/%E5%85%83%E6%B0%94%E6%BB%A1%E6%BB%A1.jpg)\n\n# this指向问题\n\n分析`this`的指向,首先要确定当前执行代码的环境。\n\n## 全局环境中的this指向\n\n全局环境中,this指向全局对象(视宿主环境而定,浏览器是window,Node是global)。\n\n## 函数中的this指向\n\n在上文中介绍**函数的调用形式**时已经比较详细地说过`this`指向问题了,这里再简单总结一下。\n\n函数中`this`的指向取决于函数的调用形式,在一些情况下也受到严格模式的影响。\n\n- 作为普通函数调用:严格模式下,`this`的值是`undefined`,非严格模式下,`this`指向全局对象。\n- 作为方法调用:`this`指向所属对象。\n- 作为构造函数调用:`this`指向实例化的对象。\n- 通过call, apply, bind调用:如果指定了第一个参数`thisArg`,`this`的值就是`thisArg`的值(如果是原始值,会包装为对象);如果不传`thisArg`,要判断严格模式,严格模式下`this`是`undefined`,非严格模式下`this`指向全局对象。\n\n# 函数声明和函数表达式\n\n撕了这么久代码,让大脑休息一会儿,先看点轻松点的内容。\n\n## 函数声明\n\n函数声明是**独立的函数语句**。\n\n```javascript\nfunction test() {}\n```\n\n函数声明存在提升(Hoisting)现象,如变量提升一般,对于同名的情况,函数声明优于变量声明(前者覆盖后者,我说的是**声明阶段**哦)。\n\n## 函数表达式\n\n函数表达式不是独立的函数语句,常作为表达式的一部分,比如赋值表达式。\n\n函数表达式可以是命名的,也可以是匿名的。\n\n```javascript\n// 命名函数表达式\nvar a = function test() {}\n// 匿名函数表达式\nvar b = function () {}\n```\n\n匿名函数就是没有函数名的函数,它不能单独使用,只能作为表达式的一部分使用。匿名函数常以**IIFE**(立即执行函数表达式)的形式使用。\n\n```javascript\n(function(){console.log(\"我是一个IIFE\")}())\n```\n\n# 闭包\n\n关于闭包,我已经写了一篇超详细的文章去分析了,是个人原创总结的干货,建议直接打开[解读闭包,这次从ECMAScript词法环境,执行上下文说起](https://juejin.im/post/6858052418862235656)。\n\nPS:阅读前,您应该对ECMAScript5的一些术语有一些简单的了解,比如Lexical Environment, Execution Context等。\n\n# 纯函数\n\n- 纯函数是具备幂等性(对于相同的参数,任何时间执行纯函数都将得到同样的结果),它不会引起副作用。\n\n- 纯函数与外部的关联应该都来源于函数参数。如果一个函数直接依赖了外部变量,那它就不是纯函数,因为外部变量是可变的,那么纯函数的执行结果就不可控了。\n\n```javascript\n// 纯函数\nfunction pure(a, b) {\n return a + b;\n}\n// 非纯函数\nfunction impure(c) {\n return c + d\n}\nvar d = 10;\npure(1, 2); // 3\nimpure(1); // 11\nd = 20;\nimpure(1); // 21\npure(1, 2); // 3\n```\n\n# 惰性函数\n\n相信大家在兼容事件监听时,都写过这样的代码。\n\n```javascript\nfunction addEvent(element, type, handler) {\n if (window.addEventListener) {\n element.addEventListener(type, handler, false);\n } else if (window.attachEvent){\n element.attachEvent(\'on\' + type, handler);\n } else {\n element[\'on\' + type] = handler;\n }\n}\n```\n\n仔细看下,我们会发现,每次调用`addEvent`,都会做一次`if-else`的判断,这样的工作显然是重复的。这个时候就用到惰性函数了。\n\n> 惰性函数表示函数执行的分支只会在函数第一次调用的时候执行。后续我们所使用的就是这个函数执行的结果。\n\n利用惰性函数的思维,我们可以改造下上述代码。\n\n```javascript\nfunction addEvent(element, type, handler) {\n if (window.addEventListener) {\n addEvent = function(element, type, handler) {\n element.addEventListener(type, handler, false);\n }\n } else if (window.attachEvent){\n addEvent = function(element, type, handler) {\n element.attachEvent(\'on\' + type, handler);\n }\n } else {\n addEvent = function(element, type, handler) {\n element[\'on\' + type] = handler;\n }\n }\n addEvent(element, type, handler);\n}\n```\n\n这代码看起来有点low,但是它确实减少了重复的判断。在这种方式下,函数第一次执行时才确定真正的值。\n\n我们还可以利用**IIFE**提前确定函数真正的值。\n\n```javascript\nvar addEvent = (function() {\n if (window.addEventListener) {\n return function(element, type, handler) {\n element.addEventListener(type, handler, false);\n }\n } else if (window.attachEvent){\n return function(element, type, handler) {\n element.attachEvent(\'on\' + type, handler);\n }\n } else {\n return function(element, type, handler) {\n element[\'on\' + type] = handler;\n }\n }\n}())\n```\n\n# 高阶函数\n\n函数在javascript中是一等公民,函数可以作为**参数**传给其他函数,这让函数的使用充满了各种可能性。\n\n不如来看看维基百科中高阶函数(High-Order Function)的定义:\n\n> 在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:\n>\n> 1. 接受一个或多个函数作为输入\n> 2. 输出一个函数\n\n看到这,大家应该都意识到了,平时使用过很多高阶函数。数组的一些高阶函数使用得尤为频繁。\n\n```javascript\n[1, 2, 3, 4].forEach(function(item, index, arr) {\n console.log(item, index, arr)\n})\n[1, 2, 3, 4].map(item => `小老弟${item}`)\n```\n\n可以发现,传入`forEach`和`map`的就是一个函数。我们自己也可以封装一些复用的高阶函数。\n\n我们知道`Math.max`可以求出参数列表中最大的值。然而很多时候,我们需要处理的数据并不是`1, 2, 3, 4`这么简单,而是对象数组。\n\n假设有这么一个需求,存在一个数组,数组元素都是表示人的对象,我们想从数组中选出年纪最大的人。\n\n这个时候,就需要一个高阶函数来完成。\n\n```javascript\n/**\n * 根据求值条件判断数组中最大的项\n * @param {Array} arr 数组\n * @param {String|Function} iteratee 返回一个求值表达式,可以根据对象属性的值求出最大项,比如item.age。也可以通过自定义函数返回求值表达式。\n */\nfunction maxBy(arr, iteratee) {\n let values = [];\n if (typeof iteratee === \'string\') {\n values = arr.map(item => item[iteratee]);\n } else if (typeof iteratee === \'function\') {\n values = arr.map((item, index) => {\n return iteratee(item, index, arr);\n });\n }\n const maxOne = Math.max(...values);\n const maxIndex = values.findIndex(item => item === maxOne);\n return arr[maxIndex];\n}\n```\n\n利用这个高阶函数,我们就可以求出数组中年纪最大的那个人。\n\n```javascript\nvar list = [\n {name: \'小明\', age: 18},\n {name: \'小红\', age: 19},\n {name: \'小李\', age: 20}\n]\n// 根据age字段求出最大项,结果是小李。\nvar maxItem = maxBy(list, \'age\');\n```\n\n我们甚至可以定义更复杂的求值规则,比如我们需要根据一个字符串类型的属性来判定优先级。这个时候,就必须传一个自定义的函数作为参数了。\n\n```javascript\nconst list = [\n {name: \'小明\', priority: \'middle\'},\n {name: \'小红\', priority: \'low\'},\n {name: \'小李\', priority: \'high\'}\n]\nconst maxItem = maxBy(list, function(item) {\n const { priority } = item\n const priorityValue = priority === \'low\' ? 1 : priority === \'middle\' ? 2 : priority === \'high\' ? 3 : 0\n return priorityValue;\n});\n```\n\n`maxBy`接受的参数最终都应该能转化为一个`Math.max`可度量的值,否则就没有可比较性了。\n\n要理解这样的高阶函数,我们可以认为**传给高阶函数的函数就是一个中间件,它把数据预处理好了,然后再转交给高阶函数继续运算**。\n\nPS:写完这句总结,突然觉得挺有道理的,反手给自己一个赞!\n\n![](http://qncdn.wbjiang.cn/%E7%9C%BC%E7%A5%9E%E6%9A%97%E7%A4%BA.gif)\n\n# 柯里化\n\n说柯里化之前,首先抛出一个疑问,如何实现一个`add`函数,使得这个`add`函数可以灵活调用和传参,支持下面的调用示例呢?\n\n```javascript\nadd(1, 2, 3) // 6\nadd(1) // 1\nadd(1)(2) // 3\nadd(1, 2)(3) // 6\nadd(1)(2)(3) // 6\nadd(1)(2)(3)(4) // 10\n```\n\n要解答这样的疑问,还是要先明白什么是柯里化。\n\n> 在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。\n\n这段解释看着还是挺懵逼的,不如举个例子:\n\n本来有这么一个求和函数`dynamicAdd()`,接受任意个参数。\n\n```javascript\nfunction dynamicAdd() {\n return [...arguments].reduce((prev, curr) => {\n return prev + curr\n }, 0)\n}\n```\n\n现在需要通过柯里化把它变成一个新的函数,这个新的函数预置了第一个参数,并且可以在调用时继续传入剩余参数。\n\n看到这,我觉得有点似曾相识,预置参数的特性与`bind`很相像。那么我们不如用`bind`的思路来实现。\n\n```javascript\nfunction curry(fn, firstArg) {\n // 返回一个新函数\n return function() {\n // 新函数调用时会继续传参\n var restArgs = [].slice.call(arguments)\n // 参数合并,通过apply调用原函数\n return fn.apply(this, [firstArg, ...restArgs])\n }\n}\n```\n\n接着我们通过一些例子来感受一下柯里化。\n\n```javascript\n// 柯里化,预置参数10\nvar add10 = curry(dynamicAdd, 10)\nadd10(5); // 15\n// 柯里化,预置参数20\nvar add20 = curry(dynamicAdd, 20);\nadd20(5); // 25\n// 也可以对一个已经柯里化的函数add10继续柯里化,此时预置参数10即可\nvar anotherAdd20 = curry(add10, 10);\nanotherAdd20(5); // 25\n```\n\n可以发现,柯里化是在一个函数的基础上进行变换,得到一个新的预置了参数的函数。最后在调用新函数时,实际上还是会调用柯里化前的原函数。\n\n并且柯里化得到的新函数可以继续被柯里化,这看起来有点像**俄罗斯套娃**的感觉。\n\n![](http://qncdn.wbjiang.cn/%E5%A5%97%E5%A8%83.jpg)\n\n实际使用时也会出现柯里化的变体,**不局限于只预置一个参数**。\n\n```javascript\nfunction curry(fn) {\n // 保存预置参数\n var presetArgs = [].slice.call(arguments, 1)\n // 返回一个新函数\n return function() {\n // 新函数调用时会继续传参\n var restArgs = [].slice.call(arguments)\n // 参数合并,通过apply调用原函数\n return fn.apply(this, [...presetArgs, ...restArgs])\n }\n}\n```\n\n其实`Function.protoype.bind`就是一个柯里化的实现。不仅如此,很多流行的库都大量使用了柯里化的思想。\n\n实际应用中,被柯里化的原函数的参数可能是定长的,也可能是不定长的。\n\n## 参数定长的柯里化\n\n假设存在一个原函数`fn`,`fn`接受三个参数`a`, `b`, `c`,那么函数`fn`最多被柯里化三次(**有效地绑定参数算一次**)。\n\n```javascript\nfunction fn(a, b, c) {\n return a + b + c\n}\nvar c1 = curry(fn, 1);\nvar c2 = curry(c1, 2);\nvar c3 = curry(c2, 3);\nc3(); // 6\n// 再次柯里化也没有意义,原函数只需要三个参数\nvar c4 = curry(c3, 4);\nc4();\n```\n\n也就是说,我们可以**通过柯里化缓存的参数数量,来判断是否到达了执行时机**。那么我们就得到了一个柯里化的通用模式。\n\n```javascript\nfunction curry(fn) {\n // 获取原函数的参数长度\n const argLen = fn.length;\n // 保存预置参数\n const presetArgs = [].slice.call(arguments, 1)\n // 返回一个新函数\n return function() {\n // 新函数调用时会继续传参\n const restArgs = [].slice.call(arguments)\n const allArgs = [...presetArgs, ...restArgs]\n if (allArgs.length >= argLen) {\n // 如果参数够了,就执行原函数\n return fn.apply(this, allArgs)\n } else {\n // 否则继续柯里化\n return curry.call(null, fn, ...allArgs)\n }\n }\n}\n```\n\n这样一来,我们的写法就可以支持以下形式。\n\n```javascript\nfunction fn(a, b, c) {\n return a + b + c;\n}\nvar curried = curry(fn);\ncurried(1, 2, 3); // 6\ncurried(1, 2)(3); // 6\ncurried(1)(2, 3); // 6\ncurried(1)(2)(3); // 6\ncurried(7)(8)(9); // 24\n```\n\n## 参数不定长的柯里化\n\n解决了上面的问题,我们难免会问自己,假设原函数的参数不定长呢,这种情况如何柯里化?\n\n首先,我们需要理解参数不定长是指函数声明时不约定具体的参数,而在函数体中通过`arguments`获取实参,然后进行运算。就像下面这种。\n\n```\nfunction dynamicAdd() {\n return [...arguments].reduce((prev, curr) => {\n return prev + curr\n }, 0)\n}\n```\n\n回到最开始的问题,怎么支持下面的所有调用形式?\n\n```javascript\nadd(1, 2, 3) // 6\nadd(1) // 1\nadd(1)(2) // 3\nadd(1, 2)(3) // 6\nadd(1)(2)(3) // 6\nadd(1)(2)(3)(4) // 10\n```\n\n思考了一阵,我发现在**参数不定长**的情况下,要同时支持`1~N`次调用还是挺难的。`add(1)`在一次调用后可以直接返回一个值,但它也可以作为函数接着调用`add(1)(2)`,甚至可以继续`add(1)(2)(3)`。那么我们实现`add`函数时,到底是返回一个函数,还是返回一个值呢?这让人挺犯难的,我也不能预测这个函数将如何被调用啊。\n\n而且我们可以拿上面的成果来验证下:\n\n```javascript\ncurried(1)(2)(3)(4);\n```\n\n运行上面的代码会报错:**Uncaught TypeError: curried(...)(...)(...) is not a function**,因为执行到`curried(1)(2)(3)`,结果就不是一个函数了,而是一个值,一个值当然是不能作为函数继续执行的。\n\n所以如果要支持参数不定长的场景,**已经柯里化的函数在执行完毕时不能返回一个值,只能返回一个函数;同时要让JS引擎在解析得到的这个结果时,能求出我们预期的值。**\n\n大家看了这个可能还是不懂,好,说人话!我们实现的`curry`应该满足:\n\n1. 经`curry`处理,得到一个新函数,这一点不变。\n\n```javascript\n// curry是一个函数\nvar curried = curry(add);\n```\n\n2. 新函数执行后仍然返回一个结果函数。\n\n```javascript\n// curried10也是一个函数\nvar curried10 = curried(10);\nvar curried30 = curried10(20);\n```\n\n3. 结果函数可以被Javascript引擎解析,得到一个预期的值。\n\n```javascript\ncurried10; // 10\n```\n\n好,关键点在于3,如何让Javascript引擎按我们的预期进行解析,这就回到Javascript基础了。在解析一个函数的原始值时,会用到`toString`。\n\n我们知道,`console.log(fn)`可以把函数fn的源码输出,如下所示:\n\n```javascript\nconsole.log(fn)\nƒ fn(a, b, c) {\n return a + b + c;\n}\n```\n\n那么我们只要重写`toString`,就可以巧妙地实现我们的需求了。\n\n```javascript\nfunction curry(fn) {\n // 保存预置参数\n const presetArgs = [].slice.call(arguments, 1)\n // 返回一个新函数\n function curried () {\n // 新函数调用时会继续传参\n const restArgs = [].slice.call(arguments)\n const allArgs = [...presetArgs, ...restArgs]\n return curry.call(null, fn, ...allArgs)\n }\n // 重写toString\n curried.toString = function() {\n return fn.apply(null, presetArgs)\n }\n return curried;\n}\n```\n\n这样一来,魔性的`add`用法就都被支持了。\n\n```javascript\nfunction dynamicAdd() {\n return [...arguments].reduce((prev, curr) => {\n return prev + curr\n }, 0)\n}\nvar add = curry(dynamicAdd);\nadd(1)(2)(3)(4) // 10\nadd(1, 2)(3, 4)(5, 6) // 21\n```\n\n至于为什么是重写`toString`,而不是重写`valueOf`,这里留个悬念,大家可以想一想,也欢迎与我交流!\n\n## 柯里化总结\n\n柯里化是一种**函数式编程**思想,实际上在项目中可能用得少,或者说用得不深入,但是如果你掌握了这种思想,也许在未来的某个时间点,你会用得上!\n\n大概来说,柯里化有如下特点:\n\n- **简洁代码**:柯里化应用在较复杂的场景中,有简洁代码,可读性高的优点。\n- **参数复用**:公共的参数已经通过柯里化预置了。\n- **延迟执行**:柯里化时只是返回一个预置参数的新函数,并没有立刻执行,实际上在满足条件后才会执行。\n- **管道式流水线编程**:利于使用函数组装管道式的流水线工序,不污染原函数。\n\n# 小结\n\n本文是笔者回顾函数知识点时总结的一篇非常详细的文章。在理解一些晦涩的知识模块时,我加入了一些个人解读,相信对于想要深究细节的朋友会有一些帮助。如果您觉得这篇文章有所帮助,请无情地关注点赞支持一下吧!同时也欢迎加我微信`laobaife`一起交流学习。', '2020-08-24 11:27:15', '2024-08-22 00:02:52', 1, 58, 0, '函数知识点查漏补缺,深入闭包,手写代码,柯里化都在这了', 'https://qncdn.wbjiang.cn/%E5%AD%A6%E5%A5%BD%E5%87%BD%E6%95%B0.jpg', 0, 0);
-INSERT INTO `article` VALUES (222, '从亲身经历谈谈如何用Git分支解决项目生产实践中的痛点', '> 原创不易,欢迎阅后点赞关注支持,本期内部分享PPT可自取,见文末!\n\n最近笔者所在公司发生了一起小风波,事情大概是这样的:市场部老大在给客户现场演示系统时,正讨论着一个主题,恰巧系统在切换到相关功能时出现了异常,导致功能不可用,现场有点尴尬。\n\n![](http://qncdn.wbjiang.cn/%E4%BD%A0%E6%90%9E%E4%BA%8B%E5%95%8A.gif)\n\n显然,问题归咎于研发部。严肃的气氛下,我下意识在想自己是不是凉了,于是我迅速定位原因,发现是后端接口发生变更而未通知前端,责任人正好是刚来没多久的后端新人。虽然这次事故不是前端的责任,但让我发现了后端Team存在的问题,在版本控制上有较大的隐患,**代码未经Review就入库发版了**,这本质上是分支管理不合理导致的。\n\n研发部门是一个整体,当着客户的面出了生产事故,这让大家面子上都不好看,所以我自告奋勇提出在研发部内部做一次Git分支管理的分享,看看能不能帮大家解决这个问题。我入职以来一直比较注意版本控制这块,但也是今年才比较系统地梳理**研发流程**和**版本控制**(去年是快速出产品的一年,管理上稍微糙一点),几个月前还特意总结了一篇[《前端小微团队的Gitlab实践》](https://juejin.im/post/6844904085053800461),经过数月的不断实践和改进,我感觉这套Git体系基本覆盖了我司的研发流程,至今没出过事故,发版节奏一直良好。\n\n其实几个月前我就想在部门内分享下我这套版本控制流程,但是一方面是考虑到自己刚摸索出来,不太熟练,另一方面是自己资历尚浅,如果跨Team直接给后端老哥们“上课”也不太好吧(其实这个顾虑是多余的~_~)。\n\n嗯,大概是这么一个心路历程,而现在正是必须站出来的时候,我希望这次我的分享能为团队尽绵薄之力!具体分享的内容是这样的,且听我慢慢道来!\n\n# 个人感受\n\nGit对我们来说既熟悉又陌生。感觉熟悉是因为我们似乎已经掌握了大量常用的Git命令,感到陌生是因为我们在实际项目中总是用不好它。是的,我也有过这样的感受,直到现在,我觉得Git仍有很多待探索的空间,比如难以理解的**git rebase**,又或者是Git提供的**Hooks**,让自动化部署有了更多可能。甚至一些平台将**代码托管**,**敏捷开发**,**CI/CD**,**DevOps**融合到了一起,提供了一站式解决方案。\n\n始于Git,却不止于Git,Git还有太多值得我们折腾的小惊喜。那么,今天我以**如何在实际项目中运用Git分支管理**这个主题作为切入点做一次内部分享。\n\n# 分布式版本控制\n\n我们知道,Git是一个开源的分布式版本控制系统,这让团队协作成为了可能。我们可以通过fetch/pull将远程仓库的代码拉取到本地,也可以将本地代码push到远程仓库。\n\n![](http://qncdn.wbjiang.cn/%E5%88%86%E5%B8%83%E5%BC%8F%E7%89%88%E6%9C%AC%E6%8E%A7%E5%88%B6%E6%A8%A1%E5%9E%8B.png)\n\n而我们向版本库提交代码的一个基本方向是:\n\n**工作区 --> 暂存区 --> 版本库**\n\n![](http://qncdn.wbjiang.cn/Git%E5%88%86%E5%8C%BA.png)\n\n- 当对工作区修改(或新增)的文件执行**git add**命令时,暂存区的目录树被更新。\n- 当执行**git commit**命令进行提交操作时,暂存区的目录树写到版本库中。\n\n# 分支管理\n\nGit最核心的内容当然是分支管理,设置合理的分支可以让研发流程有条不紊。使用分支意味着你可以**从开发主线上抽离出来,不影响主线的前提下进行工作**,最后完成工作再通过`git merge`将代码合入到主干分支上。\n\n## 简单的分支管理\n\n在生产实践中,一般来说,我们会保持至少三个分支,分别是**开发分支develop**,**测试分支release**,**生产主干分支master**。不同的团队或个人在分支命名上可能会有所差异,但是基本逻辑都是大体一致的。\n\n- 开发分支`develop`:**最不稳定**的分支,所有和特性,缺陷相关的代码都会陆续地被提交到这个分支。\n- 测试分支`release`:一个敏捷迭代结束时,正常情况下,所有`develop`分支的代码都会被`merge`到`release`分支,准备发测试版本。\n- 生产分支`master`:**最稳定**的分支,待交付的版本上线前,测试通过的`release`分支会被`merge`到`master`分支。\n\n然而很多团队在管理develop分支时存在一个很大的问题:所有开发者都**直接向develop分支push代码**。\n\n这样会造成很多隐患,包括但不限于:\n\n- **团队成员间代码冲突**。当然,直接向`develop`分支`push`代码也不是造成冲突的根本原因。但是,这会让冲突更容易发生!\n- **代码质量不可控**。这个问题大家都比较清楚了,这是因为所有代码都没有经过**Review**就入库了!\n- **版本不可控**。相信大家都遇到过,临到上线时间点,突然发现某某开发者的转测功能存在重大缺陷,不能上线。这个时候,选出能上线的代码让人头疼!根本原因是开发者的代码都直接进了`develop`分支,这让挑选代码变成了一件非常复杂的事情!\n\n![](http://qncdn.wbjiang.cn/%E7%AE%80%E5%8D%95%E5%88%86%E6%94%AF%E7%AE%A1%E7%90%86.png)\n\n## 可控的分支管理\n\n那么如何才能解决上述痛点呢?我们可以从分支的设计上入手。\n\n- 保护分支(Protected Branchs)。禁止开发者直接向保护分支提交代码,`develop`,`release`,`master`都应该被设置为保护分支!\n- 增加特性/缺陷分支,避免直接向`develop`分支`push`代码。\n- 增加代码Review环节,基本上所有代码托管平台都支持这个环节!\n\n![](http://qncdn.wbjiang.cn/%E5%8F%AF%E6%8E%A7%E5%88%86%E6%94%AF.png)\n\n具体操作流程是这样的:\n\n1. 如上图所示,我们约定一个特性或一个缺陷就是一个开发任务,**所有的开发任务都应该在本地建立独立的分支**。\n2. 开发者在特性/缺陷分支上进行开发。由于我们禁止了向保护分支直接`push`代码,所以开发者完成代码编写后,需要将本地分支**同步到远程同名分支**。\n3. 在代码托管平台如Gitlab上发起**Merge Request**,请求将特性/缺陷分支合入到`develop`分支。\n4. Maintainer(一般是团队资深成员,拥有同意MR的权限)负责**Code Review**,确认基本无误后同意MR,代码就顺利进入`develop`分支了。\n5. 后面全量发版本的流程就简单了,无脑`merge`即可!\n6. 如果不能全量发版,必须进行代码挑选,此时就需要`cherry-pick`出场了!\n\n特别注意,一定要**保证分支的原子性**,一个分支只干一件事。千万不要写着写着代码,突然萌生了在当前分支顺手改另一个问题的想法,这可能会让你陷入更大的麻烦!\n\n## 分支命名\n\n取名字永远是个难题,组件如何命名,方法如何命名,这些问题在平时开发过程中总是让人抓耳挠腮。当然,Git分支命名也不例外。\n\n![](http://qncdn.wbjiang.cn/%E5%88%86%E6%94%AF%E5%91%BD%E5%90%8D%E9%9A%BE%E9%A2%98.png)\n\n我之前也试过分支语义化命名,但是也发现了要用有限的单词描绘出复杂的含义永远是个伪命题。如上图所示,我们可能会在做一个新功能时,把相关分支命名为`feature/xxx`,而后面有优化类需求时,又会新建一个`feature/xxx-optimization`之类的分支。然而,往往一个功能会有一次又一次的优化、变更或bug,采取这样的命名策略永远会让自己直面灵魂拷问!\n\n并且在追溯问题时,这种分支命名方式往往让人心力交瘁!\n\n那么如何命名能解决这样的问题呢?我采用了下面这种策略!\n\n![](http://qncdn.wbjiang.cn/%E5%88%86%E6%94%AF%E5%91%BD%E5%90%8D%E6%8A%80%E5%B7%A7.png)\n\n我在观察很多开源软件时发现,他们的维护者都会用issue来记录各种开发相关的活动。比如需求,缺陷都会被记录在issue中,这让我觉得**用issue来管理分支**也是一个非常棒的idea!\n\n我们可以在创建issue时填写标题和描述,并且可以通过链接等形式与敏捷管理平台的需求和缺陷关联上,还可以给issue打上不同的标签,看起来会非常直观。\n\n![](https://qncdn.wbjiang.cn/%E6%A0%87%E7%AD%BE%E7%AE%A1%E7%90%86.png)\n\nissue还可以与milestone(里程碑)关联,用于检验和衡量阶段性的成果!想要知道更多细节,不妨打开[《前端小微团队的Gitlab实践》](https://juejin.im/post/6844904085053800461#heading-5)细致阅读!\n\n而issue本身有一个编号,或者叫ID,这种唯一标识让我们命名分支变得简单。假定一个issue的编号是1,那么我们在本地创建分支时,只需要将分支命名为`issue/1`即可,根据这个编号,我就能查到这个分支处理的是哪个issue,而打开Gitlab的issue,我就能知道这个issue与什么需求或缺陷有关。这不仅给开发者带来了方便,也让管理者变得更轻松!\n\n# 实际项目中如何操作?\n\n对上文中的知识有了一定了解后,接下来就是看看如何在项目中把这些知识运用起来,形成一个合理,高效的流程!我以新需求为例,简单画了一下流程,请看下图:\n\n![](http://qncdn.wbjiang.cn/git%E5%AE%9E%E6%93%8D.png)\n\n打通了这么一个主流程后,相信无论是修复bug,还是其他的场景,你都能**举一反三**!\n\n# 分支节点可拓展\n\n实际上,不同公司在分支节点上的数量是不一样的。有的公司可能从开发到上线,会涉及多套环境验证,这样下来,就可能对应多个Git分支节点。加节点也不用怕,结合`git merge`和`git cherry-pick`,理论上再多节点也能应付得过来!\n\n所以,我也在内部分享结尾时,提出了增加预发布环境的建议。测试环境尽可能发挥想象,可以测试各种极端情况。而预发布环境尽量模拟生产环境,保证数据和流程的合理性。这样一来,结合测试环境和预发布环境,我们能覆盖更多的测试用例,上线故障率会更低!\n\n![](http://qncdn.wbjiang.cn/git%E6%B3%B3%E9%81%93.png)\n\n# VSCODE必备扩展:GitLens\n\n最后推荐大家安装一个非常好用的VSCODE扩展:GitLens\n\n![](http://qncdn.wbjiang.cn/gitlens.png)\n\n有了它,我们就可以随时看到每一行代码最近一次的改动都是谁提交的。\n\n![](http://qncdn.wbjiang.cn/gitlens%E7%9C%8B%E8%AE%B0%E5%BD%95.png)\n\n这也避免了大家查问题时,突然翻到一行可疑代码,然后感叹:这是哪个傻X写的!\n\n最后一查记录发现是自己写的......\n\n科科,GitLens它不香吗?\n\n![](http://qncdn.wbjiang.cn/%E7%9C%9F%E9%A6%99.jpg)\n\n# 感谢阅读\n\n由于时间有限,本次分享的PPT和作图都有些简单,请勿介意!本次分享主要讲解了笔者是如何运用Git分支去解决项目中实际遇到的痛点,总的来说还是干货满满的,希望对大家有所帮助,喜欢的朋友请留下您的关注和点赞支持一下我吧!\n\n> PPT也分享出来了,有需要的请自取。搜索公众号【程序员白彬】,回复**PPT**,获取本期内部分享PPT!', '2020-09-09 09:44:02', '2024-08-10 20:17:56', 1, 170, 0, 'Git对我们来说既熟悉又陌生。感觉熟悉是因为我们似乎已经掌握了大量常用的Git命令,感到陌生是因为我们在实际项目中总是用不好它。本文将从生产实践谈谈如何用Git分支做好版本控制!', 'https://qncdn.wbjiang.cn/git%E8%BF%98%E8%AE%A9%E4%BD%A0%E5%A4%B4%E7%96%BC%E5%90%97.jpg', 0, 0);
-INSERT INTO `article` VALUES (223, 'then, catch, finally如何影响返回的Promise实例状态', '虽然**Promise**是开发过程中使用非常频繁的一个技术点,但是它的一些细节可能很多人都没有去关注过。我们都知道,`.then`, `.catch`, `.finally`都可以链式调用,其本质上是因为返回了一个新的**Promise**实例,而这些**Promise**实例现在的状态是什么或者将来会变成什么状态,很多人心里可能都没个底。我自己也意识到了这一点,于是我通过一些代码试验,发现了一些共性。如果您对这块内容还没有把握,不妨看看。\n\n阅读本文前,您应该对**Promise**有一些基本认识,比如:\n\n- `Promise`有`pending`, `fulfilled`, `rejected`三种状态,其决议函数`resolve()`能将`Promise`实例的状态由`pending`转为`fulfilled`,其决议函数`reject()`能将`Promise`实例的状态由`pending`转为`rejected`。\n- `Promise`实例的状态一旦转变,不可再逆转。\n\n本文会从一些测验代码入手,看看`Promise`的几个原型方法在处理`Promise`状态时的一些细节,最后对它们进行总结归纳,加深理解!\n\n# 先考虑then的行为\n\n`then`的语法形式如下:\n\n```javascript\np.then(onFulfilled[, onRejected]);\n```\n\n`onFulfilled`可以接受一个`value`参数,作为`Promise`状态决议为`fulfilled`的结果,`onRejected`可以接受一个`reason`参数,作为`Promise`状态决议为`rejected`的原因。\n\n- 如果`onFulfilled`或`onRejected`不返回值,那么`.then`返回的`Promise`实例的状态会变成`fulfilled`,但是伴随`fulfilled`的`value`会是`undefined`。\n\n```javascript\nnew Promise((resolve, reject) => {\n resolve(1)\n}).then(value => {\n console.log(\'resolution occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n- 如果`onFulfilled`或`onRejected`返回一个值`x`,那么`.then`返回的`Promise`实例的状态会变成`fulfilled`,并且伴随`fulfilled`的`value`会是`x`。注意,一个非`Promise`的普通值在被返回时会被`Promise.resolve(x)`包装成为一个状态为`fulfilled`的`Promise`实例。\n\n```javascript\nnew Promise((resolve, reject) => {\n reject(2)\n}).then(value => {\n console.log(\'resolution occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n return \'a new value\'\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n- 如果`onFulfilled`或`onRejected`中抛出一个异常,那么`.then`返回的`Promise`实例的状态会变成`rejected`,并且伴随`rejected`的`reason`是刚才抛出的异常的错误对象`e`。\n\n```javascript\nnew Promise((resolve, reject) => {\n resolve(1)\n}).then(value => {\n console.log(\'resolution occurred, and the value is: \', value)\n throw new Error(\'some error occurred.\')\n}, reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n- 如果`onFulfilled`或`onRejected`返回一个`Promise`实例`p2`,那么不管`p2`的状态是什么,`.then`返回的新`Promise`实例`p1`的状态会取决于`p2`。如果`p2`现在或将来是`fulfilled`,那么`p1`的状态也随之变成`fulfilled`,并且伴随`fulfilled`的`value`也与`p2`进行`resolve(value)`决议时传递的`value`相同;\n\n```javascript\nnew Promise((resolve, reject) => {\n resolve(1)\n}).then(value => {\n console.log(\'resolution occurred, and the value is: \', value)\n return Promise.resolve(\'a fulfilled promise\')\n}, reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n这个逻辑同样适用于`rejected`的场景。也就是说,如果`p2`的状态现在或将来是`rejected`,那么`p1`的状态也随之变成`rejected`,而`reason`也来源于`p1`进行`reject(reason)`决议时传递的`reason`。\n\n```javascript\nnew Promise((resolve, reject) => {\n reject(1)\n}).then(value => {\n console.log(\'resolution occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n return new Promise((resolve, reject) => {\n setTimeout(() => {\n reject(\'a promise rejected after 3 seconds.\')\n }, 3000)\n })\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n# 再考虑catch的行为\n\ncatch的语法形式如下:\n\n```javascript\np.catch(onRejected);\n```\n\n`.catch`只会处理`rejected`的情况,并且也会返回一个新的`Promise`实例。\n\n`.catch(onRejected)`与`then(undefined, onRejected)`在表现上是一致的。\n\n> 事实上,catch(onRejected)从内部调用了then(undefined, onRejected)。\n\n- 如果`.catch(onRejected)`的`onRejected`回调中返回了一个状态为`rejected`的`Promise`实例,那么`.catch`返回的`Promise`实例的状态也将变成`rejected`。\n\n```javascript\nnew Promise((resolve, reject) => {\n reject(1)\n}).catch(reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n return Promise.reject(\'rejected\')\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n- 如果`.catch(onRejected)`的`onRejected`回调中抛出了异常,那么`.catch`返回的`Promise`实例的状态也将变成`rejected`。\n\n```javascript\nnew Promise((resolve, reject) => {\n reject(1)\n}).catch(reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n throw 2\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n- 其他情况下,`.catch`返回的`Promise`实例的状态将是`fulfilled`。\n\n# 最后看看finally\n\n不管一个`Promise`的状态是`fulfilled`还是`rejected`,传递到`finally`方法的回调函数`onFinally`都会被执行。我们可以把一些公共行为放在`onFinally`执行,比如把`loading`状态置为`false`。\n\n注意,`onFinally`不会接受任何参数,因为它从设计上并不关心`Promise`实例的状态是什么。\n\n```javascript\np.finally(function() {\n // settled (fulfilled or rejected)\n});\n```\n\n`finally`方法也会返回一个新的`Promise`实例,这个新的`Promise`实例的状态也取决于`onFinally`的返回值是什么,以及`onFinally`中是否抛出异常。\n\n你可以通过修改以下代码中的注释部分来验证,不同的返回值对于`finally`返回的`Promise`实例的状态的影响。\n\n```javascript\nnew Promise((resolve, reject) => {\n reject(1)\n}).then(value => {\n console.log(\'resolution occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection occurred, and the reason is: \', reason)\n return Promise.resolve(2);\n // return Promise.reject(3)\n}).finally(() => {\n // return Promise.resolve(4)\n // return Promise.reject(5)\n throw new Error(\'an error\')\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n}, reason => {\n console.log(\'rejection of the returned promise occurred, and the reason is: \', reason)\n})\n```\n\n# then, catch, finally小结\n\n综合以上来看,不管是`.then(onFulfilled, onRejected)`,还是`.catch(onRejected)`,或者是`.finally(onFinally)`,它们返回的`Promise`实例的状态都取决于回调函数是否抛出异常,以及返回值是什么。\n\n- 如果回调函数的返回值是一个状态为`rejected`的`Promise`实例,那么`.then`, `.catch`或`.finally`返回的`Promise`实例的状态就是`rejected`。\n\n- 如果回调函数的返回值是一个还未决议的`Promise`实例`p2`,那么`.then`, `.catch`或`.finally`返回的`Promise`实例`p1`的状态取决于`p2`的决议结果。\n\n- 如果回调函数中抛出了异常,那么`.then`, `.catch`或`.finally`返回的`Promise`实例的状态就是`rejected`,并且`reason`是所抛出异常的对象`e`。\n\n- 其他情况下,`.then`, `.catch`或`.finally`返回的`Promise`实例的状态将是`fulfilled`。\n\n# 如何理解then中抛出异常后会触发随后的catch\n\n由于`.then`会返回一个新的`Promise`实例,而在`.then`回调中抛出了异常,导致这个新`Promise`的状态变成了`rejected`,而`.catch`正是用于处理这个新的`Promise`实例的`rejected`场景的。\n\n```javascript\nnew Promise((resolve, reject) => {\n resolve(1)\n}).then(value => {\n console.log(\'resolution of the returned promise occurred, and the value is: \', value)\n var a = b; // 未定义b\n}).catch(reason => {\n console.log(\'caught the error occured in the callback of then method, and the reason is: \', reason)\n})\n```\n\n最关键一点就是要理解:**每次`.then`, `.catch`, `.finally`都产生一个新的Promise**实例。\n\n# Promise和jQuery的链式调用区别在哪?\n\n上文也提到了,`.then`, `.catch`, `.finally`都产生一个新的Promise实例,所以这种链式调用的对象实例已经发生了变化。可以理解为:\n\n```javascript\nPromise.prototype.then = function() {\n // balabala\n return new Promise((resolve, reject) => {\n // if balabala\n // else if balabala\n // else balabala\n });\n}\n```\n\n而jQuery链式调用是基于同一个jQuery实例的,可以简单表述为:\n\n```javascript\njQuery.fn.css = function() {\n // balabala\n return this;\n}\n```\n\n# 感谢阅读\n\n本文主要是参考了**MDN**和《你不知道的JavaScript(下卷)》上关于Promise的知识点,简单分析了`.then`, `.catch`, `.finally`中回调函数的不同行为对于三者返回的Promise实例的影响,希望对大家有所帮助。\n\n- 收藏吃灰不如现在就开始学习,奥利给!\n- 如果您觉得本文有所帮助,请留下您的点赞关注支持一波,谢谢!\n- 快关注公众号**程序员白彬**,与笔者一起交流学习吧!\n', '2020-09-28 14:19:54', '2024-08-04 17:23:51', 1, 41, 0, 'Promise的状态走向何方?', 'https://qncdn.wbjiang.cn/Promise.jpg', 0, 0);
-INSERT INTO `article` VALUES (224, '我以为我很懂Promise,直到我开始实现Promise/A+规范', '我一度以为自己很懂Promise,直到前段时间尝试去实现Promise/A+规范时,才发现自己对Promise的理解还过于浅薄。在我按照Promise/A+规范去写具体代码实现的过程中,我经历了从“很懂”到“陌生”,再到“领会”的过山车式的认知转变,对Promise有了更深刻的认识!\n\nTL;DR:鉴于很多人不想看长文,这里直接给出我写的Promise/A+规范的Javascript实现。\n\n- [github仓库:promises-aplus-robin](https://github.com/cumt-robin/promises-aplus-robin)(顺手点个star就更好了)\n- [源码](https://github.com/cumt-robin/promises-aplus-robin/blob/main/promises-aplus-robin.js)\n- [源码注释版](https://github.com/cumt-robin/promises-aplus-robin/blob/main/promises-aplus-robin-annotated.js)\n\npromises-tests测试用例是全部通过的。\n\n![](https://qncdn.wbjiang.cn/promiseaplus.gif)\n\n# Promise源于现实世界\n\nPromise直译过来就是**承诺**,最新的红宝书已经将其翻译为**期约**。当然,这都不重要,程序员之间只要一个眼神就懂了。\n\n![你懂的](https://qncdn.wbjiang.cn/006APoFYly1g3wl7l06rug307s07snnx.gif)\n\n## 许下承诺\n\n作为打工人,我们不可避免地会接到各种饼,比如口头吹捧的饼、升值加薪的饼、股权激励的饼......\n\n有些饼马上就兑现了,比如口头褒奖,因为它本身没有给企业带来什么成本;有些饼却关乎企业实际利益,它们可能未来可期,也可能猴年马月,或是无疾而终,又或者直接宣告画饼失败。\n\n画饼这个动作,于Javascript而言,就是创建一个Promise实例:\n\n```\nconst bing = new Promise((resolve, reject) => {\n // 祝各位的饼都能圆满成功\n if (\'画饼成功\') {\n resolve(\'大家happy\')\n } else {\n reject(\'有难同当\')\n }\n})\n```\n\nPromise跟这些饼很像,分为三种状态:\n\n- pending: 饼已画好,坐等实现。\n- fulfilled: 饼真的实现了,走上人生巅峰。\n- rejected: 不好意思,画饼失败,emmm...\n\n## 订阅承诺\n\n有人画饼,自然有人接饼。所谓“接饼”,就是对于这张饼的可能性做下设想。如果饼真的实现了,鄙人将别墅靠海;如果饼失败了,本打工仔以泪洗面。\n\n![](https://qncdn.wbjiang.cn/62528dc5gy1g5hn3u2my3j20rs0rs7aa.jpg)\n\n转换成Promise中的概念,这是一种订阅的模式,成功和失败的情况我们都要订阅,并作出反应。订阅是通过`then`,`catch`等方法实现的。\n\n```\n// 通过then方法进行订阅\nbing.then(\n // 对画饼成功的情况作出反应\n success => {\n console.log(\'别墅靠海\')\n },\n // 对画饼失败的情况作出反应\n fail => {\n console.log(\'以泪洗面...\')\n }\n)\n```\n\n## 链式传播\n\n众所周知,老板可以给高层或领导们画饼,而领导们拿着老板画的饼,也必须给底下员工继续画饼,让打工人们鸡血不停,这样大家的饼才都有可能兑现。\n\n这种自上而下发饼的行为与Promise的链式调用在思路上不谋而合。\n\n```\nbossBing.then(\n success => {\n // leader接过boss的饼,继续往下面发饼\n return leaderBing\n }\n).then(\n success => {\n console.log(\'leader画的饼真的实现了,别墅靠海\')\n },\n fail => {\n console.log(\'leader画的饼炸了,以泪洗面...\')\n }\n)\n```\n\n总体来说,Promise与现实世界的承诺还是挺相似的。\n\n![](https://qncdn.wbjiang.cn/tatanhaha.gif)\n\n而Promise在具体实现上还有很多细节,比如异步处理的细节,Resolution算法,等等,这些在后面都会讲到。下面我会从自己**对Promise的第一印象**讲起,继而过渡到对**宏任务与微任务**的认识,最终揭开**Promise/A+规范**的神秘面纱。\n\n# 初识Promise\n\n还记得最早接触Promise的时候,我感觉能把ajax过程封装起来就挺“厉害”了。那个时候对Promise的印象大概就是:**优雅的异步封装,不再需要写高耦合的callback**。\n\n这里临时手撸一个简单的ajax封装作为示例说明:\n\n```javascript\nfunction isObject(val) {\n return Object.prototype.toString.call(val) === \'[object Object]\';\n}\n\nfunction serialize(params) {\n let result = \'\';\n if (isObject(params)) {\n Object.keys(params).forEach((key) => {\n let val = encodeURIComponent(params[key]);\n result += `${key}=${val}&`;\n });\n }\n return result;\n}\n\nconst defaultHeaders = {\n \"Content-Type\": \"application/x-www-form-urlencoded\"\n}\n\n// ajax简单封装\nfunction request(options) {\n return new Promise((resolve, reject) => {\n const { method, url, params, headers } = options\n const xhr = new XMLHttpRequest();\n if (method === \'GET\' || method === \'DELETE\') {\n // GET和DELETE一般用querystring传参\n const requestURL = url + \'?\' + serialize(params)\n xhr.open(method, requestURL, true);\n } else {\n xhr.open(method, url, true);\n }\n // 设置请求头\n const mergedHeaders = Object.assign({}, defaultHeaders, headers)\n Object.keys(mergedHeaders).forEach(key => {\n xhr.setRequestHeader(key, mergedHeaders[key]);\n })\n // 状态监听\n xhr.onreadystatechange = function () {\n if (xhr.readyState === 4) {\n if (xhr.status === 200) {\n resolve(xhr.response)\n } else {\n reject(xhr.status)\n }\n }\n }\n xhr.onerror = function(e) {\n reject(e)\n }\n // 处理body数据,发送请求\n const data = method === \'POST\' || method === \'PUT\' ? serialize(params) : null\n xhr.send(data);\n })\n}\n\nconst options = {\n method: \'GET\',\n url: \'/user/page\',\n params: {\n pageNo: 1,\n pageSize: 10\n }\n}\n// 通过Promise的形式调用接口\nrequest(options).then(res => {\n // 请求成功\n}, fail => {\n // 请求失败\n})\n```\n\n以上代码封装了ajax的主要过程,而其他很多细节和各种场景覆盖就不是几十行代码能说完的。不过我们可以看到,Promise封装的核心就是:\n\n- 封装一个函数,将包含异步过程的代码包裹在构造Promise的executor中,所封装的函数最后需要return这个Promise实例。\n- Promise有三种状态,Pending, Fulfilled, Rejected。而`resolve()`, `reject()`是状态转移的触发器。\n- 确定状态转移的条件,在本例中,我们认为ajax响应且状态码为200时,请求成功(执行`resolve()`),否则请求失败(执行`reject()`)。\n\nps: 实际业务中,除了判断HTTP状态码,我们还会另外判断**内部错误码**(业务系统中前后端约定的状态code)。\n\n实际上现在有了axios这类的解决方案,我们也不会轻易选择自行封装ajax,**不鼓励重复造这种基础且重要的轮子**,更别说有些场景我们往往难以考虑周全。当然,在时间允许的情况下,可以学习其源码实现。\n\n# 宏任务与微任务\n\n要理解Promise/A+规范,必须先溯本求源,Promise与微任务息息相关,所以我们有必要先对宏任务和微任务有个基本认识。\n\n在很长一段时间里,我都没有太多去关注**宏任务(Task)与微任务(Microtask)**。甚至有一段时间,我觉得`setTimeout(fn, 0)`在操作动态生成的DOM元素时非常好用,然而并不知道其背后的原理,实质上这跟**Task**联系紧密。\n\n```javascript\nvar button = document.createElement(\'button\');\nbutton.innerText = \'新增输入框\'\ndocument.body.append(button)\n\nbutton.onmousedown = function() {\n var input = document.createElement(\'input\');\n document.body.appendChild(input);\n setTimeout(function() {\n input.focus();\n }, 0)\n}\n```\n\n如果不使用`setTimeout 0`,`focus()`会没有效果。\n\n那么,什么是宏任务和微任务呢?我们慢慢来揭开答案。\n\n现代浏览器采用**多进程架构**,这一点可以参考[Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part1)。而和我们前端关系最紧密的就是其中的**Renderer Process**,Javascript便是运行在Renderer Process的**Main Thread**中。\n\n![](https://qncdn.wbjiang.cn/%E6%B5%8F%E8%A7%88%E5%99%A8%E5%A4%9A%E8%BF%9B%E7%A8%8B.png)\n\n> Renderer: Controls anything inside of the tab where a website is displayed.\n\n渲染进程控制了展示在Tab页中的网页的一切事情。可以理解为渲染进程就是专门为具体的某个网页服务的。\n\n我们知道,Javascript可以直接与界面交互。假想一下,如果Javascript采用多线程策略,各个线程都能操作DOM,那最终的界面呈现到底以谁为准呢?这显然是存在矛盾的。因此,Javascript选择使用单线程模型的一个重要原因就是:**为了保证用户界面的强一致性**。\n\n为了保证界面交互的连贯性和平滑度,Main Thread中,Javascript的执行和页面的渲染会交替执行(出于性能考虑,某些情况下,浏览器判断不需要执行界面渲染,会略过渲染的步骤)。目前大多数设备的屏幕刷新率为60次/秒,1帧大约是16.67ms,在这1帧的周期内,既要完成Javascript的执行,还要完成界面的渲染(if necessary),利用人眼的残影效应,让用户觉得界面交互是非常流畅的。\n\n> 用一张图看看1帧的基本过程,引用自https://aerotwist.com/blog/the-anatomy-of-a-frame/\n\n![解剖1帧](https://qncdn.wbjiang.cn/anatomy-of-a-frame.svg)\n\nPS:requestIdleCallback是空闲回调,在1帧的末尾,如果还有时间富余,就会调用requestIdleCallback。注意不要在requestIdleCallback中修改DOM,或者读取布局信息导致触发**Forced Synchronized Layout**,否则会引发性能和体验问题。具体见[Using requestIdleCallback](https://developers.google.com/web/updates/2015/08/using-requestidlecallback?hl=en#using_requestidlecallback_to_make_dom_changes)。\n\n我们知道,一个网页中的Render Process只有一个Main Thread,本质上来说,Javascript的任务在执行阶段都是按顺序执行,但是JS引擎在解析Javascript代码时,会把代码分为同步任务和异步任务。同步任务直接进入Main Thread执行;异步任务进入任务队列,并关联着一个异步回调。\n\n在一个web app中,我们会写一些Javascript代码或者引用一些脚本,用作应用的初始化工作。在这些初始代码中,会按照顺序执行其中的同步代码。而在这些同步代码执行的过程中,会陆陆续续监听一些事件或者注册一些异步API(网络相关,IO相关,等等...)的回调,这些事件处理程序和回调就是异步任务,异步任务会进入任务队列,并且在接下来的**Event Loop**中被处理。\n\n异步任务又分为**Task**和**Microtask**,各自有单独的数据结构和内存来维护。\n\n用一个简单的例子来感受下:\n\n```javascript\nvar a = 1;\nconsole.log(\'a:\', a)\nvar b = 2;\nconsole.log(\'b:\', b)\nsetTimeout(function task1(){\n console.log(\'task1:\', 5)\n Promise.resolve(6).then(function microtask2(res){\n console.log(\'microtask2:\', res)\n })\n}, 0)\nPromise.resolve(4).then(function microtask1(res){\n console.log(\'microtask1:\', res)\n})\nvar c = 3;\nconsole.log(\'c:\', c)\n```\n\n以上代码执行后,依次在控制台输出:\n\n```javascript\na: 1\nb: 2\nc: 3\nmicrotask1: 4\ntask1: 5\nmicrotask2: 6\n```\n\n仔细一看也没什么难的,但是这背后发生的细节,还是有必要探究下。我们不妨先问自己几个问题,一起来看下吧。\n\n## Task和Microtask都有哪些?\n\n- Tasks:\n - `setTimeout`\n - `setInterval`\n - `MessageChannel`\n - I/0(文件,网络)相关API\n - DOM事件监听:浏览器环境\n - `setImmediate`:Node环境,IE好像也支持(见caniuse数据)\n- Microtasks:\n - `requestAnimationFrame`:浏览器环境\n - `MutationObserver`:浏览器环境\n - `Promise.prototype.then`, `Promise.prototype.catch`, `Promise.prototype.finally`\n - `process.nextTick`:Node环境\n - `queueMicrotask`\n\n## requestAnimationFrame是不是微任务?\n\n`requestAnimationFrame`简称`rAF`,经常被我们用来做动画效果,因为其回调函数执行频率与浏览器屏幕刷新频率保持一致,也就是我们通常说的**它能实现60FPS的效果**。在`rAF`被大范围应用前,我们经常使用`setTimeout`来处理动画。但是`setTimeout`在主线程繁忙时,不一定能及时地被调度,从而出现卡顿现象。\n\n那么`rAF`属于宏任务或者微任务吗?其实很多网站都没有给出定义,包括MDN上也描述得非常简单。\n\n我们不妨自己问问自己,`rAF`是宏任务吗?我想了一下,显然不是,`rAF`可以用来代替定时器动画,怎么能和定时器任务一样被Event Loop调度呢?\n\n我又问了问自己,`rAF`是微任务吗?`rAF`的调用时机是在下一次浏览器重绘之前,这看起来和微任务的调用时机差不多,曾让我一度认为`rAF`是微任务,而实际上`rAF`也不是微任务。为什么这么说呢?请运行下这段代码。\n\n```javascript\nfunction recursionRaf() {\n requestAnimationFrame(() => {\n console.log(\'raf回调\')\n recursionRaf()\n })\n}\nrecursionRaf();\n```\n\n你会发现,在无限递归的情况下,`rAF`回调正常执行,浏览器也可正常交互,没有出现阻塞的现象。\n\n![递归rAF并没有阻塞](https://qncdn.wbjiang.cn/raf%E4%B8%8D%E9%98%BB%E5%A1%9E.gif)\n\n而如果`rAF`是微任务的话,则不会有这种待遇。不信你可以翻到后面一节内容「**如果Microtask执行时又创建了Microtask,怎么处理?**」。\n\n所以,`rAF`的任务级别是很高的,拥有单独的队列维护。在浏览器1帧的周期内,`rAF`与Javascript执行,浏览器重绘是同一个Level的。(其实,大家在前面那张「**解剖1帧**」的图中也能看出来了。)\n\n## Task和Microtask各有1个队列?\n\n最初,我认为既然浏览器区分了Task和Microtask,那就只要各自安排一个队列存储任务即可。事实上,Task根据task source的不同,安排了独立的队列。比如Dom事件属于Task,但是Dom事件有很多种类型,为了方便user agent细分Task并精细化地安排各种不同类型Task的处理优先级,甚至做一些优化工作,必须有一个task source来区分。同理,Microtask也有自己的microtask task source。\n\n具体解释见HTML标准中的一段话:\n\n> Essentially, **task sources** *are used within standards to separate logically-different types of tasks, which a user agent might wish to distinguish between.* Task queues *are used by user agents to coalesce task sources within a given event loop。\n\n## Task和Microtask的消费机制是怎样的?\n\n> An event loop has one or more task queues. A task queue is **a set of tasks**.\n\njavascript是**事件驱动**的,所以Event Loop是**异步任务调度**的核心。虽然我们一直说**任务队列**,但是Tasks在数据结构上不是队列(Queue),而是**集合(Set)**。在每一轮Event Loop中,会取出第一个**runnable**的Task(第一个可执行的Task,并不一定是顺序上的第一个Task)进入Main Thread执行,然后再检查Microtask队列并执行队列中所有Microtask。\n\n说再多,都不如一张图直观,请看!\n\n![event loop](https://qncdn.wbjiang.cn/eventloop.jpg)\n\n## Task和Microtask什么时候进入相应队列?\n\n回过头来看,我们一直在提这个概念“**异步任务进入队列**”,那么就有个疑问,Task和Microtask到底是什么时候进入相应的队列?我们重新来捋捋。异步任务有**注册**,**进队列**,**回调被执行**这三个关键行为。注册很好理解,代表这个任务被创建了;而回调被执行则代表着这个任务已经被主线程捞起并执行了。但是,在**进队列**这一行为上,宏任务和微任务的表现是不一样的。\n\n### 宏任务进队列\n\n对于Task而言,任务注册时就会进入队列,只是任务的状态还不是**runnable**,不具备被Event Loop捞起的条件。\n\n我们先用Dom事件为例举个例子。\n\n```javascript\ndocument.body.addEventListener(\'click\', function(e) {\n console.log(\'被点击了\', e)\n})\n```\n\n当`addEventListener`这行代码被执行时,任务就注册了,代表有一个用户点击事件相关的Task进入任务队列。那么这个宏任务什么时候才变成**runnable**呢?当然是用户点击发生并且信号传递到浏览器Render Process的Main Thread后,此时宏任务变成**runnable**状态,才可以被**Event Loop**捞起,进入**Main Thread**执行。\n\n这里再举个例子,顺便解释下为什么`setTimeout 0`会有延迟。\n\n```\nsetTimeout(function() {\n console.log(\'我是setTimeout注册的宏任务\')\n}, 0)\n```\n\n执行`setTimeout`这行代码时,相应的宏任务就被注册了,并且Main Thread会告知定时器线程,“你定时0毫秒后给我一个消息”。定时器线程收到消息,发现只要等待0毫秒,立马就给Main Thread一个消息,“我这边已经过了0毫秒了”。Main Thread收到这个回复消息后,就把相应宏任务的状态置为runnable,这个宏任务就可以被Event Loop捞起了。\n\n可以看到,经过这样一个线程间通信的过程,即便是延时0毫秒的定时器,其回调也并不是在真正意义上的0毫秒之后执行,因为通信过程就需要耗费时间。网上有个观点说`setTimeout 0`的响应时间最少是4ms,其实也是有依据的,不过也是有条件的。\n\n> HTML Living Standard: **If nesting level is greater than 5, and timeout is less than 4**, then set timeout to 4.\n\n对于这种说法,我觉得自己有个概念就行,不同浏览器在实现规范的细节上肯定不一样,具体通信过程也不详,是不是4ms也不好说,关键是你有没有搞清楚这背后经历了什么。\n\n### 微任务进队列\n\n前面我们提到一个观点,**执行完一个Task后,如果Microtask队列不为空,会把Microtask队列中所有的Microtask都取出来执行**。我认为,Microtask不是在注册时就进入Microtask队列,因为Event Loop处理Microtask队列时,并不会判断Microtask的状态。反过来想,如果Microtask在注册时就进入Microtask队列,就会存在Microtask还未变为**runnable**状态就被执行的情况,这显然是不合理的。我的观点是,Microtask在变为**runnable**状态时才进入Microtask队列。\n\n那么我们来分析下Microtask什么时候变成**runnable**状态,首先来看看Promise。\n\n```javascript\nvar promise1 = new Promise((resolve, reject) => {\n resolve(1);\n})\npromise1.then(res => {\n console.log(\'promise1微任务被执行了\')\n})\n```\n\n读者们,我的第一个问题是,Promise的微任务什么时候被注册?`new Promise`的时候?还是什么时候?不妨来猜一猜!\n\n![](https://qncdn.wbjiang.cn/%E4%BD%A0%E7%8C%9C%E6%88%91%E7%8C%9C%E4%B8%8D%E7%8C%9C.jpg)\n\n答案是`.then`被执行的时候。(当然,还有`.catch`的情况,这里只是就这个例子说)。\n\n那么Promise微任务的状态什么时候变成**runnable**呢?相信不少读者已经有了头绪了,没错,就是**Promise状态发生转移**的时候,在本例中也就是`resolve(1)`被执行的时候,**Promise状态由pending转移为fulfilled**。在`resolve(1)`执行后,这个Promise微任务就进入Microtask队列了,并且将在本次Event Loop中被执行。\n\n基于这个例子,我们再来加深下难度。\n\n```javascript\nvar promise1 = new Promise((resolve, reject) => {\n setTimeout(() => {\n resolve(1);\n }, 0);\n});\npromise1.then(res => {\n console.log(\'promise1微任务被执行了\');\n});\n```\n\n在这个例子中,Promise微任务的**注册**和**进队列**并不在同一次Event Loop。怎么说呢?在第一个Event Loop中,通过`.then`注册了微任务,但是我们可以发现,`new Promise`时,执行了一个`setTimeout`,这是相当于注册了一个宏任务。而`resolve(1)`必须在宏任务被执行时才会执行。很明显,两者中间隔了**至少**一次Event Loop。\n\n如果能分析Promise微任务的过程,你自然就知道怎么分析ObserverMutation微任务的过程了,这里不再赘述。\n\n## 如果Microtask执行时又创建了Microtask,怎么处理?\n\n我们知道,一次Event Loop最多只执行一个runnable的Task,但是会执行Microtask队列中的所有Microtask。如果在执行Microtask时,又创建了新的Microtask,这个新的Microtask是在下次Event Loop中被执行吗?答案是否定的。微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前Event Loop结束之前执行完所有的微任务。请注意不要递归地创建微任务,否则会陷入死循环。\n\n下面就是一个糟糕的示例。\n\n```javascript\n// bad case\nfunction recursionMicrotask() {\n Promise.resolve().then(() => {\n recursionMicrotask()\n })\n}\nrecursionMicrotask();\n```\n\n请不要轻易尝试,否则页面会卡死哦!(因为Microtask占着Main Thread不释放,浏览器渲染都没办法进行了)\n\n![](https://qncdn.wbjiang.cn/%E5%95%8A.gif)\n\n## 为什么要区分Task和Microtask?\n\n这是一个非常重要的问题。为什么不在执行完Task后,直接进行浏览器渲染这一步骤,而要再加上执行Microtask这一步呢?其实在前面的问题中已经解答过了。一次Event Loop只会消费一个宏任务,而微任务队列在被消费时有“**继续上车**”的机制,这就让开发者有了更多的想象力,对代码的控制力会更强。\n\n# 做几道题热热身?\n\n在冲击Promise/A+规范前,不妨先用几个习题来测试下自己对Promise的理解程度。\n\n## 基本操作\n\n```javascript\nfunction mutationCallback(mutationRecords, observer) {\n console.log(\'mt1\')\n}\n\nconst observer = new MutationObserver(mutationCallback)\nobserver.observe(document.body, { attributes: true })\n\nPromise.resolve().then(() => {\n console.log(\'mt2\')\n setTimeout(() => {\n console.log(\'t1\')\n }, 0)\n document.body.setAttribute(\'test\', \"a\")\n}).then(() => {\n console.log(\'mt3\')\n})\n\nsetTimeout(() => {\n console.log(\'t2\')\n}, 0)\n```\n\n这道题就不分析了,答案:**mt2 mt1 mt3 t2 t1**\n\n## 浏览器不讲武德?\n\n```javascript\nPromise.resolve().then(() => {\n console.log(0);\n return Promise.resolve(4);\n}).then((res) => {\n console.log(res)\n})\n\nPromise.resolve().then(() => {\n console.log(1);\n}).then(() => {\n console.log(2);\n}).then(() => {\n console.log(3);\n}).then(() => {\n console.log(5);\n}).then(() =>{\n console.log(6);\n})\n```\n\n这道题据说是字节内部流出的一道题,说实话我刚看到的时候也是一头雾水。经过我在Chrome测试,得到的答案确实很有规律,就是:**0 1 2 3 4 5 6**。\n\n先输出0,再输出1,我还能理解,为什么输出2和3后又突然跳到4呢,浏览器你不讲武德啊!\n\nemm...我被戴上了痛苦面具!\n\n![](https://qncdn.wbjiang.cn/%E7%97%9B%E8%8B%A6%E9%9D%A2%E5%85%B7.png)\n\n那么这背后的执行顺序到底是怎样的呢?仔细分析下,你会发现还是有迹可循的。\n\n老规矩,第一个问题,这道题的代码执行过程中,产生了多少个微任务?可能很多人认为是7个,但实际上应该是8个。\n\n| 编号 | 注册时机 | 异步回调 |\n| ---- | ------------------------------------------------------------ | ------------------------------------------------------- |\n| mt1 | .then() | console.log(0);return Promise.resolve(4); |\n| mt2 | .then(res) | console.log(res) |\n| mt3 | .then() | console.log(1); |\n| mt4 | .then() | console.log(2); |\n| mt5 | .then() | console.log(3); |\n| mt6 | .then() | console.log(5); |\n| mt7 | .then() | console.log(6); |\n| mt8 | `return Promise.resolve(4)`执行并且execution context stack清空后,隐式注册 | 隐式回调(未体现在代码中),目的是让mt2变成runnable状态 |\n\n- 同步任务执行,注册mt1~mt7七个微任务,此时execution context stack为空,并且mt1和mt3的状态变为runnable。JS引擎安排mt1和mt3进入Microtask队列(通过**HostEnqueuePromiseJob**实现)。\n- **Perform a microtask checkpoint**,由于mt1和mt3是在同一次JS call中变为runnable的,所以mt1和mt3的回调先后进入execution context stack执行。\n- mt1回调进入execution context stack执行,**输出0**,返回`Promise.resolve(4)`。mt1出队列。由于mt1回调返回的是一个状态为fulfilled的Promise,所以之后JS引擎会安排一个job(job是ecma中的概念,等同于微任务的概念,这里先给它编号mt8),其回调目的是让mt2的状态变为fulfilled(**前提是当前execution context stack is empty**)。所以紧接着还是先执行mt3的回调。\n- mt3回调进入execution context stack执行,**输出1**,mt4变为runnable状态,execution context stack is empty,mt3出队列。\n- 由于此时mt4已经是runnable状态,JS引擎安排mt4进队列,接着JS引擎会安排mt8进队列。\n- 接着,mt4回调进入execution context stack执行,**输出2**,mt5变为runnable,mt4出队列。JS引擎安排mt5进入Microtask队列。\n- mt8回调执行,目的是让mt2变成runnable状态,mt8出队列。mt2进队列。\n- mt5回调执行,**输出3**,mt6变为runnable,mt5出队列。mt6进队列。\n- mt2回调执行,**输出4**,mt2出队列。\n- mt6回调执行,**输出5**,mt7变为runnable,mt6出队列。mt7进队列。\n- mt7回调执行,**输出6**,mt7出队列。执行完毕!总体来看,输出结果依次为:**0 1 2 3 4 5 6**。\n\n对这块执行过程尚有疑问的朋友,可以先往下看看Promise/A+规范和ECMAScript262规范中关于Promise的约定,再回过头来思考,也欢迎留言与我交流!\n\n经过我在Edge浏览器测试,结果是:**0 1 2 4 3 5 6**。可以看到,不同浏览器在实现Promise的主流程上是吻合的,但是在一些细枝末节上还有不一致的地方。实际应用中,我们只要注意规避这种问题即可。\n\n# 实现Promise/A+\n\n热身完毕,接下来就是直面大boss [Promise/A+规范](https://promisesaplus.com/)。Promise/A+规范列举了大大小小三十余条细则,一眼看过去还是挺晕的。\n\n![Promise/A+](https://qncdn.wbjiang.cn/promiseaplus.png)\n\n仔细阅读多遍规范之后,我有了一个基本认识,要实现Promise/A+规范,关键是要理清其中几个核心点。\n\n## 关系链路\n\n本来写了大几千字有点觉得疲倦了,于是想着最后这部分就用文字讲解快速收尾,但是最后这节写到一半时,我觉得我写不下去了,纯文字的东西太干了,干得没法吸收,这对那些对Promise掌握程度不够的读者来说是相当不友好的。所以,我觉得还是先用一张图来描述一下Promise的关系链路。\n\n首先,Promise它是一个对象,而Promise/A+规范则是围绕着Promise的原型方法`.then()`展开的。\n\n- `.then()`的特殊性在于,它会返回一个新的Promise实例,在这种连续调用`.then()`的情况下,就会串起一个Promise链,这与原型链又有一些相似之处。“恬不知耻”地再推荐一篇[「思维导图学前端 」6k字一文搞懂Javascript对象,原型,继承](https://juejin.cn/post/6844904194097299463 \"「思维导图学前端 」6k字一文搞懂Javascript对象,原型,继承\"),哈哈哈。\n- 另一个灵活的地方在于,`p1.then(onFulfilled, onRejected)`返回的新Promise实例p2,其状态转移的发生是在p1的状态转移发生之后(这里的**之后**指的是异步的之后)。并且,p2的状态转移为Fulfilled还是Rejected,这一点取决于`onFulfilled`或`onRejected`的返回值,这里有一个较为复杂的分析过程,也就是后面所述的Promise Resolution Procedure算法。\n\n我这里画了一个简单的时序图,画图水平很差,只是为了让读者们先有个基本印象。\n\n![](https://qncdn.wbjiang.cn/promise%E6%97%B6%E5%BA%8F1.png)\n\n其中还有很多细节是没提到的(因为细节真的太多了,全部画出来就相当复杂,具体过程请看我文末附的源码)。\n\n## nextTick\n\n看了前面内容,相信大家都有一个概念,微任务是一个异步任务,而我们要实现Promise的整套异步机制,必然要具备模拟微任务异步回调的能力。在规范中也提到了这么一条信息:\n\n> This can be implemented with **either a “macro-task”** mechanism such as setTimeout or setImmediate, **or with a “micro-task”** mechanism such as **MutationObserver** or **process.nextTick**. \n\n我这里选择的是用微任务来实现异步回调,如果用宏任务来实现异步回调,那么在Promise微任务队列执行过程中就可能会穿插宏任务,这就不太符合微任务队列的调度逻辑了。这里还对Node环境和浏览器环境做了兼容,Node环境中可以使用`process.nextTick`回调来模拟微任务的执行,而在浏览器环境中我们可以选择`MutationObserver`。\n\n```javascript\nfunction nextTick(callback) {\n if (typeof process !== \'undefined\' && typeof process.nextTick === \'function\') {\n process.nextTick(callback)\n } else {\n const observer = new MutationObserver(callback)\n const textNode = document.createTextNode(\'1\')\n observer.observe(textNode, {\n characterData: true\n })\n textNode.data = \'2\'\n }\n}\n```\n\n## 状态转移\n\n- Promise实例一共有三种状态,分别是Pending, Fulfilled, Rejected,初始状态是Pending。\n\n ```javascript\n const PROMISE_STATES = {\n PENDING: \'pending\',\n FULFILLED: \'fulfilled\',\n REJECTED: \'rejected\'\n }\n \n class MyPromise {\n constructor(executor) {\n this.state = PROMISE_STATES.PENDING;\n }\n // ...其他代码\n }\n ```\n\n- 一旦Promise的状态发生转移,就不可再转移为其他状态。\n\n ```javascript\n /**\n * 封装Promise状态转移的过程\n * @param {MyPromise} promise 发生状态转移的Promise实例\n * @param {*} targetState 目标状态\n * @param {*} value 伴随状态转移的值,可能是fulfilled的值,也可能是rejected的原因\n */\n function transition(promise, targetState, value) {\n if (promise.state === PROMISE_STATES.PENDING && targetState !== PROMISE_STATES.PENDING) {\n // 2.1: state只能由pending转为其他态,状态转移后,state和value的值不再变化\n Object.defineProperty(promise, \'state\', {\n configurable: false,\n writable: false,\n enumerable: true,\n value: targetState\n })\n // ...其他代码\n }\n }\n ```\n\n- 触发状态转移是靠调用`resolve()`或`reject()`实现的。当`resolve()`被调用时,当前Promise也不一定会立即变为Fulfilled状态,因为传入`resolve(value)`方法的value有可能也是一个Promise,这个时候,当前Promise必须追踪传入的这个Promise的状态,整个确定Promise状态的过程是通过**Promise Resolution Procedure算法**实现的,具体细节封装到了下面代码中的`resolvePromiseWithValue`函数中。当`reject()`被调用时,当前Promise的状态就是确定的,一定是Rejected,此时可以通过`transition`函数(封装了状态转移的细节)将Promise的状态进行转移,并执行后续动作。\n\n ```javascript\n // resolve的执行,是一个触发信号,基于此进行下一步的操作\n function resolve(value) {\n resolvePromiseWithValue(this, value)\n }\n // reject的执行,是状态可以变为Rejected的信号\n function reject(reason) {\n transition(this, PROMISE_STATES.REJECTED, reason)\n }\n \n class MyPromise {\n constructor(executor) {\n this.state = PROMISE_STATES.PENDING;\n this.fulfillQueue = [];\n this.rejectQueue = [];\n // 构造Promise实例后,立刻调用executor\n executor(resolve.bind(this), reject.bind(this))\n }\n }\n ```\n\n## 链式追踪\n\n假设现在有一个Promise实例,我们称之为p1。由于`promise1.then(onFulfilled, onRejected)`会返回一个新的Promise(我们称之为p2),与此同时,也会注册一个微任务mt1,这个新的p2会追踪其关联的p1的状态变化。\n\n当p1的状态发生转移时,微任务mt1回调会在接下来被执行,如果状态是Fulfilled,则`onFulfilled`会被执行,否则`onRejected`会被执行。微任务mt1回调执行的结果将作为决定p2状态的依据。以下是Fulfilled情况下的部分关键代码,其中promise指的是p1,而chainedPromise指的是p2。\n\n```javascript\n// 回调应异步执行,所以用到了nextTick\nnextTick(() => {\n // then可能会被调用多次,所以异步回调应该用数组来维护\n promise.fulfillQueue.forEach(({ handler, chainedPromise }) => {\n try {\n if (typeof handler === \'function\') {\n const adoptedValue = handler(value)\n // 异步回调返回的值将决定衍生的Promise的状态\n resolvePromiseWithValue(chainedPromise, adoptedValue)\n } else {\n // 存在调用了then,但是没传回调作为参数的可能,此时衍生的Promise的状态直接采纳其关联的Promise的状态。\n transition(chainedPromise, PROMISE_STATES.FULFILLED, promise.value)\n }\n } catch (error) {\n // 如果回调抛出了异常,此时直接将衍生的Promise的状态转移为rejected,并用异常error作为reason\n transition(chainedPromise, PROMISE_STATES.REJECTED, error)\n }\n })\n // 最后清空该Promise关联的回调队列\n promise.fulfillQueue = [];\n})\n```\n\n## Promise Resolution Procedure算法\n\nPromise Resolution Procedure算法是一种抽象的执行过程,它的语法形式是`[[Resolve]](promise, x)`,接受的参数是一个Promise实例和一个值x,通过值x的可能性,来决定这个Promise实例的状态走向。如果直接硬看规范,会有点吃力,这里直接说人话解释一些细节。\n\n### 2.3.1\n\n如果promise和值x引用同一个对象,应该直接将promise的状态置为Rejected,并且用一个TypeError作为reject的原因。\n\n> If `promise` and `x` refer to the same object, reject `promise` with a `TypeError` as the reason.\n\n【说人话】举个例子,老板说只要今年业绩超过10亿,业绩就超过10亿。这显然是个病句,你不能拿预期本身作为条件。正确的玩法是,老板说只要今年业绩超过10亿,就发1000万奖金(嘿嘿,这种事期待一下就好了)。\n\n代码实现:\n\n```javascript\nif (promise === x) {\n // 2.3.1 由于Promise采纳状态的机制,这里必须进行全等判断,防止出现死循环\n transition(promise, PROMISE_STATES.REJECTED, new TypeError(\'promise and x cannot refer to a same object.\'))\n}\n```\n\n### 2.3.2\n\n如果x是一个Promise实例,promise应该采纳x的状态。\n\n> 2.3.2 If `x` is a promise, adopt its state [3.4]:\n>\n> 2.3.2.1 If `x` is pending, `promise` must remain pending until `x` is fulfilled or rejected.\n> \n> 2.3.2.2 If/when `x` is fulfilled, fulfill `promise` with the same value.\n> \n> 2.3.2.3 If/when `x` is rejected, reject `promise` with the same reason.\n\n【说人话】小王问领导:“今年会发年终奖吗?发多少?”领导听了心里想,“这个事我之前也在打听,不过还没定下来,**得看老板的意思**。”,于是领导对小王说:“会发的,不过要等消息!”。\n\n注意,这个时候,领导对小王许下了承诺,但是这个承诺p2的状态还是pending,需要看老板给的承诺p1的状态。\n\n- **可能性1**:过了几天,老板对领导说:“今年业务做得可以,年终奖发1000万”。这里相当于p1已经是fulfilled状态了,value是1000万。领导拿了这个准信了,自然可以跟小王兑现承诺p2了,于是对小王说:“年终奖可以下来了,是1000万!”。这时,承诺p2的状态就是fulfilled了,value也是1000万。小王这个时候就“**别墅靠海**”了。\n\n![](https://qncdn.wbjiang.cn/20160316109270_gYDBhJ.jpg)\n\n- **可能性2**:过了几天,老板有点发愁,对领导说:“今年业绩不太行啊,年终奖就不发了吧,明年,咱们明年多发点。”显然,这里p1就是rejected了,领导一看这情况不对啊,但也没办法,只能对小王说:“小王啊,今年公司情况特殊,年终奖就不发了。”这p2也随之rejected了,小王内心有点炸裂......\n\n![](https://qncdn.wbjiang.cn/%E4%BD%A0%E7%9F%A5%E9%81%93%E6%88%91%E4%BB%8A%E5%B9%B4%E6%80%8E%E4%B9%88%E8%BF%87%E7%9A%84%E5%90%97.gif)\n\n注意,Promise/A+规范2.3.2小节这里有两个大的方向,一个是x的状态未定,一个是x的状态已定。在代码实现上,这里有个技巧,对于状态未定的情况,必须用订阅的方式来实现,而.then就是订阅的绝佳途径。\n\n```javascript\nelse if (isPromise(x)) {\n // 2.3.2 如果x是一个Promise实例,则追踪并采纳其状态\n if (x.state !== PROMISE_STATES.PENDING) {\n // 假设x的状态已经发生转移,则直接采纳其状态\n transition(promise, x.state, x.state === PROMISE_STATES.FULFILLED ? x.value : x.reason)\n } else {\n // 假设x的状态还是pending,则只需等待x状态确定后再进行promise的状态转移\n // 而x的状态转移结果是不定的,所以两种情况我们都需要进行订阅\n // 这里用一个.then很巧妙地完成了订阅动作\n x.then(value => {\n // x状态转移为fulfilled,由于callback传过来的value是不确定的类型,所以需要继续应用Promise Resolution Procedure算法\n resolvePromiseWithValue(promise, value, thenableValues)\n }, reason => {\n // x状态转移为rejected\n transition(promise, PROMISE_STATES.REJECTED, reason)\n })\n }\n}\n```\n\n多的细节咱这篇文章就不一一分析了,写着写着快1万字了,就先结束掉吧,感兴趣的读者可以直接打开源码看(往下看)。\n\n这是跑测试用例的效果图,可以看到,872个case是全部通过的。\n\n![](https://qncdn.wbjiang.cn/promiseaplus.gif)\n\n## 完整代码\n\n这里直接给出我写的Promise/A+规范的Javascript实现,供大家参考。后面如果有时间,会考虑详细分析下。\n\n- [github仓库:promises-aplus-robin](https://github.com/cumt-robin/promises-aplus-robin)(顺手点个star就更好了)\n- [源码](https://github.com/cumt-robin/promises-aplus-robin/blob/main/promises-aplus-robin.js)\n- [源码注释版](https://github.com/cumt-robin/promises-aplus-robin/blob/main/promises-aplus-robin-annotated.js)\n\n## 缺陷\n\n我这个版本的Promise/A+规范实现,不具备检测execution context stack为空的能力,所以在细节上会有一点问题(execution context stack还未清空就插入了微任务),无法适配上面那道「**浏览器不讲武德?**」的题目所述场景。\n\n## 方法论\n\n不管是手写实现Promise/A+规范,还是实现其他Native Code,其本质上绕不开以下几点:\n\n- 准确理解Native Code实现的能力,就像你理解一个需求要实现哪些功能点一样,并确定实现上的优先级。\n- 针对每个功能点或者功能描述,逐一用代码实现,优先打通主干流程。\n- 设计足够丰富的测试用例,回归测试,不断迭代,保证场景的覆盖率,最终打造一段优质的代码。\n\n# 总结\n\n看到结尾,相信大家也累了,感谢各位读者的阅读!希望本文对宏任务和微任务的解读能给各位读者带来一点启发。Promise/A+规范总体来说还是比较晦涩难懂的,这对新手来说是不太友好的,因此我建议有一定程度的Promise实际使用经验后再深入学习Promise/A+规范。通过学习和理解Promise/A+规范的实现机制,你会更懂Promise的一些内部细节,对于设计一些复杂的异步过程会有极大的帮助,再不济也能提升你的异步调试和排错能力。\n\n这里还有一些规范和文章可以参考:\n\n- [Promises/A+规范](https://promisesaplus.com/)\n- [Event Loop Processing Model](https://html.spec.whatwg.org/#event-loop-processing-model)\n- [tasks-microtasks-queues-and-schedules](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/)\n- [Jobs and Host Operations to Enqueue Jobs](https://tc39.es/ecma262/#sec-jobs)', '2021-03-08 09:19:36', '2024-11-13 16:30:00', 1, 92, 0, '结合Promise A+规范和Event Loop深度解读Promise,附Promise A+规范实现源码。', 'https://qncdn.wbjiang.cn/pap.png', 0, 0);
-INSERT INTO `article` VALUES (225, '一个透传技巧,治好了我的重度代码洁癖', '# 背景介绍\n\n**透传**是一个通讯层面的概念,指的是在通讯中不管传输的业务内容如何,只负责将传输的内容由源地址传输到目的地址,而不对业务数据内容做任何改变。\n\n其实**透传**这个概念,我最早是从上面一个领导那里听到的,由于他是电气工程师出身,而硬件通讯这块用到透传还是挺多的。\n\n![](https://qncdn.wbjiang.cn/danpianji.jpg)\n\n当我听到**透传**这个词后,我感觉有那么一点熟悉感,仔细想想后我发现,其实我们前端也一直在使用透传,特别是在做基础封装时。\n\n# 透传在前端的应用\n\n今天就用一个**Vue基础组件封装**的过程为例,来简单聊聊什么是透传。\n\n相信不少前端er做项目都会用到组件库,是ElementUI还是Ant Design,这都不重要。然后我们又希望在第三方组件库的基础上再做一点点定制。\n\n举个例子,`el-button`有个属性是`size`,用于控制按钮组件的尺寸。\n\n| 属性 | 说明 | 类型 | 可选值 | 默认值 |\n| --- | --- | --- | --- | --- |\n| size | 尺寸 | String | medium / small / mini | - |\n\n![](https://qncdn.wbjiang.cn/elbutton_size.png)\n\n可以看到,默认size是比较大的。然而我们设计师基于组件库出自己的设计方案时,其实选择的默认按钮尺寸可能恰好对应`ElButton`的**medium**尺寸,或者是其他值。这样一来,如果我不对`el-button`做封装,每个使用`el-button`的地方都要多写一个属性`size`,类似于这样:\n\n```html\n// pageA.vue\n按钮1 \n按钮2 \n\n// pageB.vue\n按钮3 \n按钮4 \n```\n\n很明显,每使用一次`el-button`,我就要写一个`size`属性,好烦啊!\n\n![](https://qncdn.wbjiang.cn/%E5%A5%BD%E7%83%A6%E5%95%8A.gif)\n\n是的,确实很烦,那么怎么解决呢?答案是提供一个编程接口,去改变组件的默认值。有这方面考虑的组件设计者一般会提供一个设置默认值的接口,比如`xxx.setDefault(options)`。那么ElementUI和Ant Design有没有提供这样的能力呢?据我观察好像是没有,其实主要是因为Vue没有一个方便的途径去修改`prop`的`default`属性。但是没有方便的途径并不代表没有途径...\n\n![](https://qncdn.wbjiang.cn/%E9%82%A3%E4%B8%8D%E6%98%AF%E6%88%91%E8%AF%B4%E7%9A%84.jpg)\n\n由于本文的主题是**透传**,所以就不说那个途径(或者说方法)了,有点跑偏了。\n\n网友小王说:“好,那就硬上,封装一个组件!”\n\n好的,马上安排!基本思路是封装一个自定义组件,组件里面再调用`el-button`,并且强行给`el-button`安排上默认属性`size=\"medium\"`。\n\n```vue\n\n \n \n \n \n\n\n```\n\n聪明的读者一看就发现了,这个组件问题很大,除了size属性,`ElButton`的其他属性和事件怎么处理完全没有提到!\n\n小王说:“没事,您需要什么?我给安排上!”\n\n于是,这个组件最后就慢慢变成了:\n\n```vue\n\n \n \n \n \n\n\n```\n\n看起来有点糟心,这组件甚至会更冗余,更复杂,因为我这里只加了3个prop和1个event。对于稍微复杂一点的组件来说,prop加上event一共几十个是随随便便的吧!你适配得过来吗?而且,不少人还有代码洁癖吧,这简直受不了!\n\n![](https://qncdn.wbjiang.cn/%E4%B8%8D%E6%95%B2%E4%BA%86.jpg)\n\n淡定淡定!这当然是有办法解决的。强如框架的设计者尤小右自然早已想到了这个场景,所以你应该在Vue官网文档中关注到[inheritAttrs](https://cn.vuejs.org/v2/api/#inheritAttrs)。\n\n如何理解`inheritAttrs`(默认值为`true`)这个选项呢?我们知道,一个组件如果要接受父组件传来的属性,是需要先在`props`里面预定义好的。比如前面的例子,我在`MyButton`预定义了3个属性,分别是`size`, `type`, `disabled`,意思是`MyButton`这里只接受3个`prop`。\n\n那么假设父组件传了4个或者更多`prop`过来呢,会怎么样?看下面这个例子:\n\n```vue\n\n 测试 \n \n```\n\n实际上,`round`和`autofocus`都不是`MyButton`组件支持的`prop`,所以反映到`HTML`上是这么一个效果:\n\n![](https://qncdn.wbjiang.cn/%E5%A4%9A%E7%9A%84prop.png)\n\n作为使用者,我们应该是希望`round`和`native-type=\"submit\"`能够传到`el-button`,产生应有的效果。然而,`round`和`native-type=\"submit\"`仅仅是挂在了根元素的`attribute`上,并没有真正起到应有的作用!\n\nPS:举个例子,`round`属性作用到`el-button`能让`button`带一个`is-round`的`class`,从而产生圆角效果!\n\n也就是说,`inheritAttrs`的作用是:使那些没有在`props`中定义的属性,直接以`attribute`的形式作用在组件的根元素上!\n\n那么`round`和`native-type=\"submit\"`如何透传下去呢?\n\n首先,不能让那些未被props标识的属性直接落到根元素上,所以需要设置`inheritAttrs`为`false`。\n\n然后,要获取到那些未被props标识的属性,并直接绑定到`el-button`。恰好,Vue提供了[attrs](https://cn.vuejs.org/v2/api/#vm-attrs)用于获取这些属性,而`v-bind`本身就能绑定一个对象,这是容易被我们忽略的!\n\n处理完属性透传,接下来我们还要处理事件,类似于`$attrs`,`$listeners`也能把父组件中对子组件的事件监听全部拿到,这样我们就能用一个`v-on`把这些来自于父组件的事件监听传递到下一级组件。\n\n看图可能会更好理解!\n\n![](https://qncdn.wbjiang.cn/%E9%80%8F%E4%BC%A0%E5%9B%BE%E8%A7%A3watermark.png)\n\n相当于`MyButton`是一个**不赚差价的中间商**,直接透传消息!直观上看,组件代码量有一个明显的减少,更重要的是扩展性和可维护性变得更强!\n\n```vue\n\n \n \n \n \n\n\n```\n\n对于调用者来说,使用体验是完全没有被影响的,他的感觉就好像仍然在直接使用`el-button`,属性传递和事件监听的使用体验都没有任何变化!\n\n```vue\n\n \n \n```\n\n# 总结\n\n结合`inheritAttrs`, `v-bind`以及`v-on`,我们就实现了一个支持透传的基础组件!本文是以`Button`组件为例,做的关于透传的入门介绍。实际上,透传的应用范围远远不止`Button`组件,利用透传的技巧,我们能做更多漂亮的事情!现在,你的代码洁癖还好吗?\n\n![](https://qncdn.wbjiang.cn/%E9%80%8F%E4%BC%A0%E7%9C%9F%E9%A6%99.gif)\n', '2021-03-28 19:45:49', '2024-09-03 10:08:51', 1, 76, 0, '写优雅的代码需要慢慢修炼,一个小技巧奉上', 'https://qncdn.wbjiang.cn/danpianji.jpg', 0, 0);
-INSERT INTO `article` VALUES (226, '花半天时间,轻松打造前端CI/CD工作流', '**CI/CD** 是 Continuous Intergration/Continuous Deploy 的简称,翻译过来就是**持续集成/持续部署**。CD 也会被解释为**持续交付**(Continuous Delivery),但是对于软件工程师而言,最直接接触的应该是持续部署。\n\n我刚开始工作时,就有接触过CI的概念,那个时候主要是团队 QA(质量保证)使用 **hudson** 对工程进行质量扫描,跑一些基础的自动化测试。当时印象最深的一幕就是 QA 对我说:”你的代码静态告警了,赶紧改一下...“。\n\n现在一想,我不禁感到诧异,”咦?我们当时没有用 ESLint 吗?记不清楚了...“于是我翻了下 ESLint 的更新记录,发现那时候 ESLint 的大版本号才刚到3,VSCode 的 ESLint 插件也还是比较早期的版本,可能还没普及开吧。\n\n后面我也慢慢地听到了 Jenkins, Travis CI 这样一些名词,但是由于太菜,我一个都不会用。\n\n![](https://qncdn.wbjiang.cn/%E8%8F%9C%E7%8B%97.gif)\n\n而且我发现,我对 CI/CD 并没有什么兴趣,为什么呢?因为我还没有使用它的动机。\n\n# 构建/部署那些事\n\n构建/部署说的简单点,就是先利用 webpack 或者 gulp 这类的工具把工程打包,然后把打包得到的文件放在服务器上某个托管静态资源的 Web 容器里,像 Java 就可以放在 Tomcat,不过现在流行用 Nginx 托管静态资源。有了 Web 容器,前端打包的那些文件(比如index.html, main.js等等)就可以被访问到了,这个相信大家都懂。\n\n16年~18年时,我还不负责打包部署这些事(另一方面也是因为前端根本没权限碰服务器啊,emmm...),所以我压根没关注打包部署这些事情。\n\n18年到19年时,我开始负责打包部署了。当时完全没这方面经验,Linux 命令都是靠着一边百度一边敲。不过我清楚地记得,之前在测试组那间办公室看他们用的是**xshell**和**xftp**,把这俩工具搞来用后,我觉得部署真是简单,我只要跑个脚本,安静地等 webpack 和 gulp 的工作流结束后,把文件通过 xftp 传到服务器就行,**只要注意不要操作出错就行了**(显然,人为操作就容易出错,这也是个隐患)。由于构建部署的频率不高,项目数量也不是很多,这一年我基本应付得过来。\n\n直到去年,我手底下有差不多5个项目,接近10个前端工程。在这种日常部署节奏下,我觉得 xshell+xftp 也救不了我,虽然这些项目不是天天都发版上线,但是测试环境还是经常发的,每天光部署这事我就够烦躁,写代码经常被打断,而且也非常浪费时间。\n\n![](https://qncdn.wbjiang.cn/%E8%BF%99%E8%B0%81%E9%A1%B6%E5%BE%97%E4%BD%8F.gif)\n\n我想着要寻求些改变了,但我还是没考虑 CI/CD 这事,因为我觉得我好像还是不太懂 CI/CD。于是我考虑先用 **shell 脚本**来做构建/部署的事情,所以后来就有了这么两篇探索性的文章:\n\n- [自动化部署的一小步,前端搬砖的一大步](https://juejin.cn/post/6844904049582538760#heading-0 \"自动化部署的一小步,前端搬砖的一大步\")\n- [前端自动化部署的深度实践](https://juejin.cn/post/6844904056498946055 \"前端自动化部署的深度实践\")\n\n靠着这一波脚本的探索,我基本上也是过渡到**半自动化**的阶段了,这种焦虑的状况基本上得到了一些缓解。但是,我发现我的电脑还是扛不住,风扇急速旋转的声音能让我自闭。。。毕竟一边跑本地开发环境,一边还可能同时跑1~2个工程的构建/部署脚本,再加上电脑运行的其他软件,这发热量你懂的!\n\n所以,构建/部署这活不应该由我的电脑来承担,它太累了。\n\n![](https://qncdn.wbjiang.cn/%E6%88%91%E6%89%BF%E5%8F%97.gif)\n\n而且,我也不想手动触发部署脚本了,太累了,是时候让代码学会自己部署了。**也就是这个时候,我对 CI/CD 就有了诉求**。\n\n由于我们的代码是托管在自建的 gitlab 服务器上,所以 CI/CD 这块我直接选择了用 gitlab 自带的 CI/CD 能力。工作之余,我差不多花了两天时间去熟悉[gitlab CI/CD的文档](https://docs.gitlab.com/ee/ci/quick_start/ \"gitlab CI/CD的文档\")。\n\n然后我按照文档先把环境搭建好,接着一遍遍地调试`.gitlab-ci.yml`配置文件,我记得第一次成功跑完一个 Pipeline 前,我一共失败了大概11次,这个过程挺折磨人,有时候你就是不知道到底哪里配错了。\n\n![](https://qncdn.wbjiang.cn/cicd%E8%AF%95%E9%94%99.png)\n\n不过调通这个流程后,你就会觉得这整个试错的过程都是值得的。Nice!\n\n![](https://qncdn.wbjiang.cn/cicd%E7%AC%AC%E4%B8%80%E6%AC%A1%E6%88%90%E5%8A%9F.png)\n\n# CI/CD到底干了啥?\n\n其实我前面也提到了,一个版本发布的过程,主要就是分为以下几个步骤:\n\n- **代码合并**:测试环境或生产环境都有独立的分支,等所有待发版的代码都合并到对应分支后,就可以考虑发版了。\n- **打包**:或者叫构建。以生产环境部署为例,我们切到生产环境分支并 pull 最新代码后,就可以开始打包步骤了。这一步主要是通过一些 bundler 完成的,比如 webpack。而打包命令嘛,一般都是定义在`package.json`的`scripts`中了,我这儿定义的命令是`build:prod`,所以只要运行`npm run build:prod`就行了。\n- **部署**:把打包得到的文件放在 web 容器中,而 web 容器通常在 Linux 服务器上,这涉及到远程传输文件,这个时候我们一般要借助 shell 脚本或者 xftp。\n\n而 CI/CD 做的事情就是:**用自动化技术接管流程**。\n\n# 监控Mutation\n\n我的诉求是:**当代码合并到某个分支后,gitlab能自动帮我执行完打包和部署这两个步骤。**\n\n所以,首先就必须有代码变动的监控能力。这个确实有,如果你有关注过[git hook](https://git-scm.com/book/zh/v2/%E8%87%AA%E5%AE%9A%E4%B9%89-Git-Git-%E9%92%A9%E5%AD%90 \"git hook\"),就知道这是可以实现的。\n\n而且,绝大部分代码托管平台都提供了 webhooks,能监控不少事件,比如 push 和 merge。\n\n![](https://qncdn.wbjiang.cn/webhooks.png)\n\n这也就是说,即便不使用代码托管平台提供的 CI/CD 能力,开发者也有能力实现自己的 CI/CD 机制。\n\nps:当然,除了 CI/CD,做短信/邮件通知也是可行的,只要你敢去尝试,基于平台开放的能力,我们能做很多事情。自研 CI/CD 的事情我们就不去搞了,人家造的轮子已经6翻了,直接拿来用。\n\n回归主题,只要我监控到代码变动了,服务器端自动执行构建/部署脚本即可。\n\n# Gitlab CI/CD是怎么工作的\n\n软件服务于生活,也源于生活。Gitlab CI/CD 设计了很多概念,其中我觉得最有意思的是:**Pipeline 和 Runner**。\n\n## Pipeline\n\n**Pipeline是CI/CD的最上层组件**,它翻译过来是管道,其实你可以将之理解为**流水线**,每一个符合`.gitlab-ci.yml`触发规则的 CI/CD 任务都会产生一个 Pipeline。这个概念就有点像工厂中的车间流水线了,我们知道车间中有很多条流水线,不同的流水线可能会处理同一类型的生产任务,也可能处理不同类型的生产任务。当一条流水线空闲的时候,就有可能会被用来安排执行其他的生产任务。而 Gitlab 的 Pipeline 虽然没有空闲的概念,一个 Pipeline 执行结束后也不会被复用,但是会将资源让出来给其他的 Pipeline,所以和车间流水线也有异曲同工之妙。\n\n![](https://qncdn.wbjiang.cn/%E6%B5%81%E6%B0%B4%E7%BA%BF.jpg)\n\n## Runner\n\n有了流水线,还必须有辛勤的工人进行生产作业,**Runner**在 Gitlab Pipeline 中就扮演着工人角色,根据我们下达的指令进行作业。\n\n### Runner的类型\n\n在 Gitlab 中,Runner 有很多种,分为**Shared Runner**, **Group Runner**, **Specific Runner**。\n\n- Shared Runner 可以理解为机动人员,他可能会在工厂的各个流水线机动作业,随时支援!在整个 Gitlab 应用中,Shared Runner 可以服务于各个 Project。\n\n![](https://qncdn.wbjiang.cn/%E7%9B%B4%E5%8D%87%E6%9C%BA%E6%88%98%E6%96%97%E4%BA%BA%E5%91%98.jpg)\n\n- Group Runner 就比较好理解了,他只在这个组上班,别的组他是不会去的。在 Gitlab 中,我们是可以建立不同的 Group 的,比如前端一个 Group,后端一个 Group,甚至前端里面还可以分 N 个 Group。所以,Group Runner 只服务于指定的 Group。\n\n![](https://qncdn.wbjiang.cn/%E7%A8%8B%E5%BA%8F%E5%91%98%E4%BB%AC.jpg)\n\n- Specific Runner 就更牛逼了,它只服务于指定的项目,也就是 Project 级别,别的项目咱都不去。\n\n![](https://qncdn.wbjiang.cn/%E6%8B%86%E5%BC%B9%E4%B8%93%E5%AE%B6.webp)\n\n### 注册Runner\n\n工人是要持证上岗的,同样地,Runner 有一个注册的过程,就相当于在工厂中入职登记的意思。具体见[Registering runners](https://docs.gitlab.com/runner/register/ \"Registering runners\")。只有合法注册的 Runner,才有资格执行 Pipeline。不过,Gitlab 好像没给 Runner 发工资啊!\n\n![](https://qncdn.wbjiang.cn/gitlab%E4%B8%8D%E5%8F%91%E5%B7%A5%E8%B5%84%E8%A2%AB%E6%8A%93.jpg)\n\n## .gitlab-ci.yml配置\n\n流水线和工人都安排好之后,就必须**制定车间生产规章制度**了。一条流水线到底怎么干活,总要有个规矩吧,你说呢?\n\n没错,`.gitlab-ci.yml`文件就是来制定规则的!其实我要求的 CI/CD 流程并不复杂,只要帮我把构建和部署两步搞定就行了。下面以一个简化的生产环境构建部署流程为例说明:\n\n```yml\nworkflow:\n rules:\n - if: \'$CI_COMMIT_REF_NAME == \"master\"\'\n\nstages:\n - build\n - deploy\n\nbuild_prod:\n stage: build\n cache:\n key: build_prod\n paths:\n - node_modules/\n script:\n - yarn install\n - yarn build:prod\n artifacts:\n paths:\n - dist\n \ndeploy_prod:\n stage: deploy\n script:\n - scp -r $CI_PROJECT_DIR username@host:/usr/share/nginx/html\n```\n\n首先,我希望只在 master 分支进行构建/部署作业,这个可以通过`workflow.rules`下的`if`条件约束完成。\n\n然后,我希望把整个过程分为两个阶段执行,第一个阶段是`build`,用于执行构建任务;第二个阶段是`deploy`,用于执行部署任务。这可以通过`stages`来完成定义。\n\n接着,我定义了两个`job`,第一个`job`是`build_prod`,属于`build`阶段;第二个`job`是`deploy_prod`,属于`deploy`阶段。\n\n在`buiild_prod`这个`job`中,主要是运行了`yarn install`和`yarn build:prod`两个脚本,打包生成的文件资产会根据`artifacts`的配置保存下来,供后面的`job`使用。\n\n在`deploy_prod`这个`job`中,主要是通过`scp`命令向 linux 服务器上的 nginx 目录下传输文件。\n\n这个简单的 Pipeline 配置示例其实应用的是 Basic Pipeline Architecture,只不过示例中每个 stage 只定义了一个 job。\n\n![](https://qncdn.wbjiang.cn/basic_pipeline.png)\n\n## Gitlab CI/CD Variables\n\nGitlab 通过 **Variables** 为 CI/CD 提供了更多配置化的能力,方便我们快速取得一些关键信息,用来做流程决策。上述示例中的`$CI_COMMIT_REF_NAME`和`$CI_PROJECT_DIR`就是 Gitlab 的预定义变量。\n\n![](https://qncdn.wbjiang.cn/%E9%A2%84%E5%AE%9A%E4%B9%89%E5%8F%98%E9%87%8F.png)\n\n除了预定义变量,我们也可以自行定义一些环境变量,比如服务器 ip,用户名等等,这样就免去了在配置文件中明文列出私密信息的风险;另一方面也方便后期快速调整配置,避免直接修改`.gitlab-ci.yml`。\n\n![](https://qncdn.wbjiang.cn/cicd%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F.png)\n\n## 授信问题\n\n在不同主机间通过`scp`传输文件需要建立信任关系,在 CI/CD 中最好选择免密方式,其基本原理就是把 **ssh公钥** 交给对方。而这一点我在[自动化部署的一小步,前端搬砖的一大步](https://juejin.cn/post/6844904049582538760#heading-7 \"自动化部署的一小步,前端搬砖的一大步\")这篇文章中也提到了,这里就不再赘述。\n\n## Runner独立部署\n\n由于我是将 Runner 直接部署到了 Gitlab 代码服务器上,而我司配的这台代码服务器的配置本身就不高,用来跑高 CPU 占用的构建部署 Pipeline 还是有点吃力的,有时候 Pipeline 跑起来甚至直接把 Gitlab 的 Web 服务搞崩了。\n\n队友问我:”怎么 Gitlab 白屏打不开了?“\n\n![](https://qncdn.wbjiang.cn/%E6%88%91%E4%B9%9F%E5%BE%88%E6%97%A0%E5%A5%88%E5%95%8A.gif)\n\n没过多久,领导那边给我发了一台 Linux 服务器,专门给前端搞日常工作用的。bingo,我就顺手把 Runner 独立部署到新机器上了,这样就不会影响队友了,而且每次发版时间直接从 8min 缩短到 2min 以内,简直 Nice!\n\n![](https://qncdn.wbjiang.cn/nice.gif)\n\n# CI/CD带来的收益\n\n直观来看,我的重复劳动被去除了大部分,多出来的这部分时间我可以用来干更多有意义的事情,或者摸鱼它不香吗?而且,每天不用手动发版,心情也是倍儿棒!\n\n此外,由于 CI/CD 采用自动化作业方式,只要脚本写对了,几乎不会出错,出生产事故的几率也就大大降低了。\n\n# 小结\n\n本文从笔者的一些亲身经历出发,回忆了笔者在构建/部署过程中遇到的痛点,并围绕一个最基础的Gitlab CI/CD案例,讲述了笔者使用 CI/CD 来解决这些痛点的过程。虽然本文的主角是Gitlab CI/CD,但它和其他代码托管平台的CI/CD在思路上是类似的,掌握了一个,触类旁通也就不难。并且,利用 Pipeline 这类工具,我们还可以做更多事情,比如**持续集成+自动化测试**。这就考验大家的想象力了,剩下的就交给聪明的读者啦!', '2021-04-21 20:30:52', '2024-08-15 13:22:42', 1, 124, 0, '讲讲我的部署亲身经历', 'https://qncdn.wbjiang.cn/cicdworkflow.png', 0, 0);
-INSERT INTO `article` VALUES (227, 'npm init @vitejs/app的背后,仅是npm CLI的冰山一角', '结尾的话说在前面。\n\n> 我有时候会得出这样的结论:**原来那些我不常用的命令或工具,都是为了解决大佬们遇到的问题而存在的!**\n\n我们每天都和**npm**打交道,但是不少人对**npm**的掌握程度还停留在一个比较浅的层面(当然这也包括我)。就比如说一个用 vite 创建 app 的命令`npm init @vitejs/app`,很多人就懵了,“`npm init`不是用来创建`package.json`文件的吗?”\n\n同样还有`npx create-react-app my-app`这样的命令,懵吗?\n\n的确,这些命令背后还有一些我们很少关注的逻辑,虽然不难,但是我们却没有系统去了解过。\n\n考虑到这些,最近我有系统地去学习**npm**,主要的学习方式是利用一些空余时间,结合我之前的**npm**使用经验,从**npm**官方文档入手去排查一些知识盲点和疑惑。顺着官方文档一块块看下来,同时对不清楚的知识点进行资料查阅和验证之后,虽然还有那么一小部分知识点我几乎没用过,但是我的确对**npm**有了更多的认识。最后我也是以思维导图的形式,把自己的一些学习所得简单记录下来。\n\n![思维导图](https://files.mdnice.com/user/63/18a10f9a-9acd-4c05-a42b-cb49bbc116ed.png)\n\n\n经过这几天的学习,我发现我学习**npm**的两个大方向是`npm CLI`和`package.json`。\n\n今天我想先聊聊`npm CLI`,**CLI**就是Command Line Interface,也就是我们说的命令行接口。`npm`提供了非常多的`CLI`,具体可以参考[npm CLI commands](https://docs.npmjs.com/cli/v7/commands)。\n\n```\nUsage: npm \n\nwhere is one of:\n access, adduser, audit, bin, bugs, c, cache, ci, cit,\n clean-install, clean-install-test, completion, config,\n create, ddp, dedupe, deprecate, dist-tag, docs, doctor,\n edit, explore, fund, get, help, help-search, hook, i, init,\n install, install-ci-test, install-test, it, link, list, ln,\n login, logout, ls, org, outdated, owner, pack, ping, prefix,\n profile, prune, publish, rb, rebuild, repo, restart, root,\n run, run-script, s, se, search, set, shrinkwrap, star,\n stars, start, stop, t, team, test, token, tst, un,\n uninstall, unpublish, unstar, up, update, v, version, view,\n whoami\n\nnpm -h quick help on \nnpm -l display full usage info\nnpm help search for help on \nnpm help npm involved overview\n\nSpecify configs in the ini-formatted file:\n C:\\Users\\Tusi\\.npmrc\nor on the command line via: npm --key value\nConfig info can be viewed via: npm help config\n```\n\n命令太多,就不全部解释一遍了。我筛选出了一些基础的,同时也是我见得比较多的一些命令来简单介绍下。\n\n![](https://files.mdnice.com/user/63/dfb0d80a-980e-4128-aa60-fbc1a0bb82a6.jpg)\n\n# npm CLI常见命令\n\n## npm help\n\n不懂就问,`npm help`是个好命令。就像我用`git --help`一样,对于有些比较模糊的命令,我都会用**help**来查一下。\n\n## npm init\n\n在我们初始化一个 npm 包,或者说创建 package.json 文件,就需要用到`npm init`。\n\n## npm init xxx\n\n虽然之前在创建vue或者react应用时,我都用到了`npm init xxx`,但我都没怎么关注`npm init xxx`背后发生了什么。\n\n比如`npm init @vitejs/app`,只知道官网说它是用来创建应用的,但很少会去想到其背后是调用了`npx @vitejs/create-app`,其实就是在执行一个`create-app`脚本。\n\n这也就是说,如果你想让别人通过`npm init xxx`命令调用你的包,就必须提供一个`create-xxx`脚本。\n\n## npx\n\nnpx 用来运行本地或远程npm包的一个命令。比如前面提到的`npx @vitejs/create-app`。\n\n如果 npx 请求的包(比如`@vitejs/create-app`)没有出现在本地项目的依赖中,npm 就会把`@vitejs/create-app`安装到全局的 npm cache 目录下。\n\n接着会执行`create-app`脚本,而这个脚本需要定义在`package.json`的`bin`配置项下。\n\n> `npm init xxx`和`npx create-xxx`也是一般`CLI`工具的常用套路。\n\n![](https://files.mdnice.com/user/63/7e799927-b566-4def-9107-f814bcb633c2.jpg)\n\n## npm config\n\n`npm config`是用来管理配置文件的,我们平时用的最多的是设置`npm`的源。\n\n```\nnpm config set registry https://registry.npm.taobao.org \n```\n\n利用`npm config list`,我们可以列出所有的配置项;利用`get`, `set`, `delete`可以执行查询,设置,删除配置项的操作。\n\n## npm install / uninstall\n\nnpm install 不指定包时,会将 package.json 列出的依赖安装到 node_modules 中,如果指定包名,则安装指定的包。主要注意:\n\n- -g是全局安装;\n\n- 如果指定了 --production ,或者 NODE_ENV 是 production,就不会安装 devDependencies 中的依赖。\n\n- `--save` 等价于 -S,安装的依赖包信息保存到 package.json中的 dependencies,这些依赖(比如vue, react)如果有进入 bundler (比如 webpack )的 Dependency Graph(依赖关系图),会被打包到项目的构建结果中;`npm install vue`会默认执行`-S`的行为,但是建议显示给出`-S`,给人的感觉会比较清晰。\n\n- `--save-dev` 等价于 -D,安装的依赖包信息保存到 devDependencies 中,这些依赖一般是开发环境的工具,比如 eslint, webpack, babel之类的,这些依赖一般不会被打包工具处理到构建结果中。\n\n**但是**,如果你使用`npm install -D vue`安装了`vue`,并且在项目中引用了`vue`依赖,那么 webpack 的 Dependency Graph 中也会有`vue`,最终`vue`也会体现到构建结果中;\n\n看到这里,是不是又懵逼了?不管是`npm install -S vue`还是`npm install -D vue`,如果项目中引用了`vue`,都会把`vue`打包进构建结果,那么`-S`和`-D`有什么区别?\n\n![](https://files.mdnice.com/user/63/6f216542-2271-4cf8-a046-5de39e3859fa.gif)\n\n注意了,webpack 不关心一个依赖是`dependencies`还是`devDependencies`,只要进入 webpack 的 Dependency Graph,就会打包到结果中。\n\n所以我们不要被构建工具迷了眼,`-S`和`-D`影响的是`npm install`,而且影响的也是有限的场景。\n\n如果别人 install 你的包`package-a`,他会顺便安装`package-a`中的`dependencies`,而不会去安装`package-a`中的`devDependencies`。\n\n**分两方面来看**:\n\n- **第一种情况:生产依赖误入开发依赖**。\n\n假设你的包`package-a`通过`package.json`的`module`字段提供了一个ESM入口。\n\n```\n\"module\": \"module-entry.js\"\n```\n\n在`module-entry.js`里面又依赖了一个包,假设是`lodash-es`吧。\n\n```javascript\n// module-entry.js\nimport { cloneDeep } from \"lodash-es\"\n```\n\n但是,你没注意你是通过`npm install -D lodash-es`安装的,你在本地调试`package-a`时,没有任何问题。于是,你发布了这个`package-a`,同事小王安装了`package-a`却发现使用时报错了(因为他不会自动安装`package-a`的`devDependencies`)。\n\n- **第二种情况:开发依赖误入生产依赖**。\n\n开发环境的依赖进入了生产环境,会导致构建时多了无意义的开发依赖,打包结果变大,这常发生于开发库或组件时。\n\n```javascript\nimport VueAwesomeProgress from \"./index.vue\";\n\n// 开发组件时,不必要的vue引入;\n// 导致最终build的文件变大。\nimport Vue from \"vue\"\nconsole.log(Vue)\n\nVueAwesomeProgress.install = function(Vue) {\n Vue.component(VueAwesomeProgress.name, VueAwesomeProgress);\n};\n\nif (typeof window !== \'undefined\' && window.Vue) {\n window.Vue.use(VueAwesomeProgress)\n}\n\nexport default VueAwesomeProgress\n```\n\n实质上,我们在开发一个Vue组件时,仅仅需要把`vue`作为`devDependencies`即可。\n\n## npm start\n\n`npm start`是一个语义化的命令。通常我们会在 scripts 中自定义 start 脚本,比如:\n\n```json\n\"start\": \"npm run dev\"\n```\n\n如果没有指定自定义的 start 脚本,`npm start`默认会执行:\n\n```\nnode server.js\n```\n\n## npm run\n\n`npm run`用来运行我们定义的`scripts`,命令后直接跟脚本名称就行。在`npm run`时,我们可以调用一些特殊路径下的可执行文件或脚本,这些路径包括环境变量PATH定义的路径,也包括当前项目`node_modules`中的`./bin`。\n\n> In addition to the shell\'s pre-existing PATH, npm run adds node_modules/.bin to the PATH provided to scripts.\n\n## npm version\n\n这个命令也是值得掌握的,从语义上看,`npm version`会修改`package.json`中的`version`字段,用来管理包的版本号。\n\n你可以试着运行:\n\n```shell\nnpm version major/minor/patch -m \"reason for upgrade\"\n```\n\n**major/minor/patch**三选一,分别代表**主版本/次版本/补丁版本**。当然也可以传其他的版本参数,具体参考[npm-version](https://docs.npmjs.com/cli/v7/commands/npm-version)。\n\n通常,我们还会定义一个自定义的 version 脚本,配合`conventional-changelog`用来自动生成`CHANGELOG.md`。\n\n```json\n{\n \"scripts\": {\n \"version\": \"conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md\"\n }\n}\n```\n\n## 发包相关\n\n### npm login/ npm adduser\n\n发布一个 npm 包的流程并不复杂。首先你必须通过命令行登录 npm,这用到了`npm adduser`,别名是`npm login`。\n\n### 确保你的代理正确\n\n有时候,考虑到国内环境,我们安装依赖时,会设置 npm 的源为淘宝镜像。但是在发布 npm 包之前,必须把源切回到 npm。\n\n```shell\nnpm config set registry http://registry.npmjs.org/\n```\n\n### npm publish\n\n发布一个npm包,发布的界限是以 version 判断的,不能发布相同的 version。即便你只是改了一个`README`,也必须修改 version 才能重新 `npm publish`。\n\n### npm unpublish\n\n与发包对应的就是**移除已发布的包**。你可以选择移除整个已发布的包,也可以针对性地下架某个版本。\n\n## npm pack\n\n将package打包成 tgz 格式。举个例子,在`vue-awesome-progress`目录下,运行 npm pack 将得到一个 vue-awesome-progress-1.9.1.tgz,其中 1.9.1 是取自 version 字段。\n\n然后通过 npm install vue-awesome-progress-1.9.1.tgz 会在当前目录的 node_moudles 目录下安装 vue-awesome-progress包及其相关依赖。\n\n## npm link\n\n`npm link`用于创建一个符号链接,类似于 Linux 软链接(`ln -s`)的效果。\n\n首先需要在待创建 link 的包目录(比如vue-awesome-progress)下运行 npm link,这会在 npm 全局文件夹下创建一个 symlink。\n\n`npm prefix -g`指向 npm 全局文件夹,我这里的值是:\n\n```shell\nPS C:\\Users\\Tusi> npm prefix -g\nC:\\Users\\Tusi\\AppData\\Roaming\\npm\n```\n\nnpm link后,`C:\\Users\\Tusi\\AppData\\Roaming\\npm\\node_modules\\`中就有一个`vue-awesome-progress`的目录了,其实是一个快捷方式。\n\n同时,如果 vue-awesome-progress 中有配置 bin 文件,也会被 link 到全局。\n\n要用到 vue-awesome-progress 的地方可以通过`npm link vue-awesome-progress`安装它,也会安装到 node_modules 下,不过是一个**全量的 vue-awesome-progress**,而非`npm publish`后的 vue-awesome-progress。\n\n个人感觉,npm link 适合在本地对两个及以上的包做调试用,这样就不用每次调试问题时,还要重新 npm run build, npm publish,省去了很多事。\n\n# 写在结尾\n\n当我们习惯了一个工具的常用功能时,很少会去想它背后发生了什么,甚至更少会去思考它还有没有其他能力。但是,当你有一定使用经验后,再去深入了解它,你会感叹:“卧槽!原来这个命令是用来解决这个问题的,大佬们果然还是考虑得全面!”\n\n就像我开头说的那样,一个人如果想在技术领域进阶,一定要给自己提出足够多的问题,带着问题去深入。至于如何带着问题深入,我觉得最好是做一个自己的产品,可以是项目,也可以是组件,或者是library,甚至是framework。遇到困惑时,如果你发现大佬们给了解决方案,你会惊喜;如果没有,**你来成为大佬!**', '2021-04-21 20:32:25', '2024-10-04 20:54:34', 1, 60, 0, '系统学习npm,从npm CLI开始', 'https://qncdn.wbjiang.cn/npm_cli.jpg', 0, 0);
-INSERT INTO `article` VALUES (228, '这还是我最熟悉的package.json吗?', '# 前言\n\n在上一篇[npm init @vitejs/app的背后,仅是npm CLI的冰山一角](https://juejin.cn/post/6950817077670182943)中,有提到我复习**npm**主要是从两个大方向来入手,所以这篇继续来讲讲`package.json`这部分知识,经过这轮复习,也发现了自己的很多不足,之前把常用的命令和配置玩熟了,却没关心**npm**已经有了更多新的玩法,而这些玩法却实实在在地在解决别人的问题。\n\nnpm 的配置还是挺多的,具体可以参考[package.json官方文档](https://docs.npmjs.com/cli/v7/configuring-npm/package-json)。通读了文档之后,我略过了一些基础的配置项,总结了一些我认为比较有用的配置项。\n\n![](https://qncdn.wbjiang.cn/packagejson%E6%80%9D%E7%BB%B4%E5%AF%BC%E5%9B%BE.png)\n\n# 常用配置项\n\n## files\n\n`files`定义了哪些文件应该被包括在 npm install 后的 node_modules中。\n\n当然,有些文件是自动暴露出来的,不管你是不是配置了`files`,比如:\n\n- package.json\n- README / CHANGELOG / LICENSE\n- ...\n\n很多库都定义了 files,避免一些不必要的文件暴露到 node_modules 中。\n\nvite 中是这样配置的:\n\n```json\n{\n \"files\": [ \"bin\", \"dist\", \"client.d.ts\" ]\n}\n```\n\n我之前就不知道这个配置,导致我发布的一个 npm 组件 [vue-awesome-progress](https://cumt-robin.github.io/vue-awesome-progress/) 就暴露了源码部分,虽然这也没啥影响,本来就是开源的。但是这也增加了别人的资源下载量,也是一种浪费。所以,专业点的搞法还是加上`files`配置吧。\n\n## bin\n\nbin 列出了可执行文件,表示你这个包要对外提供哪些脚本。\n\n在这个包被 install 安装时,如果是全局安装 -g,bin 列出的可执行文件会被添加到 PATH 变量(全局可执行);如果是局部安装,则会进入到 node_modules/.bin/ 目录下。\n\nbin 在一些 CLI 工具中用得很频繁,比如 Vue CLI。\n\n在开发 npm 包时,要求发布的可执行脚本要以`#!/usr/bin/env node`开头,这是为什么呢?\n\n我查了一下,原来是为了用于指明该脚本文件要使用 node 来执行。\n\n## main, browser, module\n\n这三个配置对我们的影响还是挺大的。\n\n- `main`字段决定了别人`require(\'xxx\')`时,引用的是哪个模块对象。在不设置`main`字段时,默认值是`index.js`。\n- 如果你开发的包是用于浏览器端的,那么用`browser`指定入口文件是最佳的选择。\n- `module`则代表你开发的包支持`ESM`,并指定了一个`ESM`入口。\n\n具体这三个字段怎么用,还是挺有学问的,这里推荐一篇文章[package.json中你还不清楚的browser,module,main 字段优先级](https://www.cnblogs.com/qianxiaox/p/14041717.html),讲得挺细。\n\n**长图警告!**\n\n![](https://qncdn.wbjiang.cn/%E6%A8%A1%E5%9D%97%E5%8A%A0%E8%BD%BD%E9%A1%BA%E5%BA%8F.jpg)\n\n## scripts\n\n`scripts`也基本上每天都用了,但是它的钩子脚本你用过吗?如果没有用过,可以试试,在组织脚本流程时非常好用!\n\n- **pre**:在一个script执行前执行,比如prebuild,可以在打包前做一些准备工作。\n- **post**:在一个script执行后执行,比如postbuild,可以在打包后做一些收尾工作。\n\n## config\n\n通过`config`配置的参数`xxx`,可以在脚本中通过`npm_package_config_xxx` 的形式引用,比如`port`。\n\n```json\n{\n \"config\": {\n \"port\": \"8080\"\n }\n}\n```\n\n## 依赖相关\n\n### dependencies\n\n`dependencies`可以理解为生产依赖,通过`npm install --save`安装的依赖包都会进入到`dependencies`中。\n\n### devDependencies\n\n`devDependencies`可以理解为开发环境依赖,通常是一些工具类的包,比如 webpack, babel等。 通过`npm install --save-dev`安装的依赖包都会进入到`devDependencies`中。\n\n但是,在结合一些构建工具使用时,我们往往会有困惑。比如我安装了一个包到`devDependencies`中,但是不小心在项目中引用了它,最后也被 webpack 打包到构建结果中了。这是怎么回事呢?\n\n建议结合[上篇文章npm install这一节](https://juejin.cn/post/6950817077670182943#heading-6)一起看。\n\n### peerDependencies\n\n> 我是**package-a**,你装我,你就必须装我的**peerDependencies**。\n\n让“调包侠”将**package-a的依赖**提升到自己的`node_modules`中,这样可以在“调包侠”和**package-a**都需要同一个依赖(比如vue)时,避免重复安装。这常见于开发组件或者库。\n\n注意,一个 npm 包的开发者如果声明了`peerDependencies`,开发环境下在该包目录`npm install`也不会在`node_modules`中安装这些依赖,所以往往还需要借助`devDependencies`。\n\n举个例子,我开发一个组件,不想发布到 npm 时包含了 vue 的代码,这就需要外部提供 vue ,所以我把 vue 定义在 peerDependencies 也无可厚非。但是,在开发组件时,一般还需要本地开发环境跑一个 demo 试试效果,这时候是依赖 vue 的,所以还需要在 devDependencies 中安装 vue 。我看了下 vue-router 就是这么做的,所以我在开发自己的组件时也学会了这招。\n\n### bundledDependencies\n\n`bundledDependencies`跟上面的依赖都不太一样,配置上不是键值对的形式,而是一个数组。\n\n```json\n{\n \"bundledDependencies\": [\n \"vue\",\n \"vue-router\"\n ]\n}\n```\n\n在运行`npm pack`时,会将对应依赖打包到`tgz`文件中。用得不多,不知道具体的细节,主要还是直接用`npm install`安装 tgz 包的场景比较少,有个概念就行。\n\n### optionalDependencies\n\n`optionalDependencies`用于配置可选的依赖,即使配了这个,代码里也要做好判断(保护),否则运行报错就不好玩了。\n\n```javascript\ntry {\n var foo = require(\'foo\')\n var fooVersion = require(\'foo/package.json\').version\n} catch (er) {\n foo = null\n}\n```\n\n# 题外话\n\n仔细读过`package.json`文档后,整体上还是解决了我的不少困惑,对我开发 npm 组件也提供了不少帮助。如果您想了解更多细节和实战,不妨打开我这个项目[vue-awesome-progress](https://cumt-robin.github.io/vue-awesome-progress/)看看,希望对您有所帮助!', '2021-04-22 14:46:30', '2024-09-03 07:27:27', 1, 137, 0, '在上一篇npm init @vitejs/app的背后,仅是npm CLI的冰山一角中,有提到我复习npm主要是从两个大方向来入手,所以这篇继续来讲讲package.json这部分知识', 'https://qncdn.wbjiang.cn/npm%E5%B0%81%E9%9D%A2.png', 0, 0);
-INSERT INTO `article` VALUES (229, '你还在为node-sass烦恼吗?快试试官方推荐的dart-sass', '众所周知,node-sass 是一个非常棒的工具,是前端工程师组织 CSS 的一个神兵利器。然而,用过的朋友都知道,node-sass 是让人既爱又恨!你爱它,因为它赋能了 CSS 工程化;你恨它,因为有时候你搞不懂它为什么又出差错了。我最近就在生产环境新踩了两次 node-sass 的坑,这让我下定决心放弃 node-sass。\n\n# 什么是node-sass?\n\n虽然 node-sass 是一个熟悉的老朋友了,但是还是有必要介绍一下。\n\n> Node-sass is a library that **provides binding for Node.js to LibSass**, the C++ version of the popular stylesheet preprocessor, Sass.\n>\n> It allows you to natively **compile .scss files to css at incredible speed and automatically via a connect middleware**.\n\n从上面的介绍可以知道,node-sass 是一个 nodejs 环境下提供的一个 Bridge,它提供了调用 LibSass 的能力(而 LibSass 是一个 C++ 实现的样式预处理器)。\n\n> ps: 可以看到,node-sass 并不完全是 javascript 实现的,而是借助了 C++ 的能力,毕竟编译型语言还是速度快啊。\n\n# Round1:安装 node-sass\n\n刚进入前端领域的朋友,可能都问过这么一个问题:**为什么我的 node-sass安装失败了?**\n\n在网上搜索这个问题,你会找到答案,其中一个是使用 cnpm,但我用过感觉怪怪的,最早是使用 Angular4 时,执行`ng eject`发生了很多错误。\n\n后面就一直用的设置 npm 淘宝镜像源的方式处理这个问题,同时这也是解决`npm install`下载卡顿或失败的一个技巧,毕竟有些包被墙了。\n\n```shell\nnpm config set registry https://registry.npm.taobao.org\n```\n\n但解决了这个问题,也不是说就万事大吉了...\n\n# Round2:node-sass和node版本不兼容\n\n一般来说,个人电脑的 NodeJS 环境安装好了后,很久都不会想着去升级。\n\n不过我前段时间去研究 Vite 的时候,发现我的 NodeJS 版本已经不满足条件了。\n\n> Compatibility Note\n> \n> Vite requires Node.js version >=12.0.0.\n\n于是乎,我就升级了 NodeJS 版本。\n\n但是,当我运行一些旧项目的时候,我发现,项目报错了。\n\n```\nModule build failed (from ./node_modules/sass-loader/dist/cjs.js):\nError: Node Sass does not yet support your current environment: Windows 64-bit with Unsupported runtime (83)\nFor more information on which environments are supported please see:\nhttps://github.com/sass/node-sass/releases/tag/v4.13.0\n```\n\n粗略一看,报错信息说的是 NodeSass 不支持当前运行时环境,我猜这肯定是跟 NodeJS 版本不匹配了。我首先检查了下我的 NodeJS 版本。\n\n```shell\nnove -v\nv14.16.0\n```\n\n嗯,是新版本没错了。\n\n于是就去 github 上查了下 node-sass,发现确实还是这么一回事,node-sass@4.13.0 版本真的不支持 node@14,惨!\n\n![](https://qncdn.wbjiang.cn/nodesass4-13%E4%B8%8D%E6%94%AF%E6%8C%81node14.png)\n\n其实,我只要把 NodeJS 版本降低到 13,问题也能得以解决。\n\n但我觉得这还是有问题的。新项目要求高版本 NodeJS,而旧项目需要低版本 NodeJS,我本地只有一套 Node 环境,这样就出现了矛盾点,**看来开发环境也比较需要容器化**。\n\n虽然这个问题也不能完全算是 node-sass 的锅,但谁叫它不支持 node@14 呢?用着还是不爽!\n\n# Round3:node-sass: Command failed\n\n这是我上个月在生产环境跑 CI/CD 时遇到的一个问题。\n\n```\nerror /builds/coollu-r-d/coollu-fe/xkgj_web/node_modules/node-sass: Command failed.\n```\n\n后面还跟了一堆错误信息。\n\n![](https://qncdn.wbjiang.cn/nodesass%E6%8A%A5%E9%94%99.png)\n\n即便我已经是在 Docker 容器里执行 build 任务了,也就是说没有上面那个和 Node 版本不兼容的问题,但还是遇到了一次又一次的报错,这谁能顶得住呢?\n\n![](https://qncdn.wbjiang.cn/%E8%BF%99%E8%B0%81%E9%A1%B6%E5%BE%97%E4%BD%8F.gif)\n\n# 使用Dart Sass\n\nDart Sass 是 Sass 官网力推的工具,它包括了基于 Dart VM 的命令行工具,以及基于 Node 的纯 Javascript 实现。前者说的 Dart VM 就是现在很火的 Flutter 选择的编程语言 Dart 的虚拟机;而后者的出现是为了能快速与 Node 环境下现有的工作流集成,比如 webpack,gulp等。Dart Sass的命令行工具是比 Javascript Library性能更好的,但是为了快速对接 webpack 等工具,我们目前一般通过`npm install --save-dev sass`直接使用 sass 的 Javascript Library。\n\n改用 Dart Sass 后,不管是安装还是兼容高版本 Node 这块,都没有什么问题,总的来说,使用体验还是非常棒!\n\n> Dart Sass 是我们对它的习惯称呼,最早它在 npm 上的确是以 dart-sass 的名字发布的,不过现在它已经更名为 sass 了。\n\n![](https://qncdn.wbjiang.cn/dartsass%E6%9B%B4%E5%90%8D.png)\n\n# 换Dart Sass后,我要做些什么\n\n众所周知,在 Vue 项目中,scoped 样式是会通过一个哈希化的属性选择器进行隔离的(比如`[data-v-67c6b990]`),如果希望做样式穿透,在`Vue@2`中会用到`/deep/`深度选择器。\n\n> 注意,`/deep/`本身是作为一个 CSS 的提案(好像是用于解决 web components 的样式穿透问题,用 Angular 的时候简单了解过),后面又被废弃了,而 Vue 的 `/deep/`跟 CSS 的`/deep/`不是同一个概念!考虑到用户容易误解 Vue 的`/deep/`和 CSS 被废弃的`/deep/`提案是一个东西,也就会误认为 `/deep/`是一个不可用的特性,Vue 也出了 RFC 针对这块做调整,后面也就有了`::v-deep`。\n\n使用 Dart Sass 后,可能会在运行开发环境时遇到不支持`/deep/`的问题,需要改用`::v-deep`,简写就是`:deep(selector)`,比如:\n\n```css\n:deep(.foo) {\n position: relative;\n}\n```\n\n# 欢迎交流\n\n你还遇到过哪些 node-sass 的问题呢?不妨留言交流一下!', '2021-05-27 09:09:34', '2024-09-03 01:04:11', 1, 285, 0, 'node-sass 是一个非常棒的工具,是前端工程师组织 CSS 的一个神兵利器。然而,用过的朋友都知道,node-sass 是让人既爱又恨!你爱它,因为它赋能了 CSS 工程化;你恨它,因为...', 'https://qncdn.wbjiang.cn/css%E9%A2%9C%E8%89%B2%E6%A0%BC%E5%AD%90.png', 0, 0);
-INSERT INTO `article` VALUES (231, '摸索前端管理2年,这份研发流程帮到我不少', '> 在现在的公司工作也有2年多了,时间过得真快!2年的时间里,前端从单兵作战发展到现在的10人规模。如果要我说,这个过程里什么最重要?我想应该是一份**接地气的研发流程**。\n\n说到研发流程,大部分人肯定首推某某某大厂的研发流程。诚然,大厂的研发流程的确完善并且细致,然而实际上并不一定适用于其他公司或团队,比如QA、单元测试、自动化测试这些环节,我想很多公司都不会有。所以,盲目地套用别的公司或者团队的研发流程,是可能水土不服的,但是却可以给我们提供一个参考意见,去弥补自身的不足。\n\n研发流程一定不是凭空出现的,它必须紧密贴合实际的项目过程。我很重视这块,在我还是“光杆司令”的时候,我就在筹备着。我当时的想法是,等我这个组进人的时候,我一定不是手把手告诉他做项目的每一步该怎么做,而是用标准化的文档把整个大致过程记录下来,另外也是想要告诉我未来的同事,我这个团队是有思考和沉淀的,值得大家一起成长!\n\n研发流程一定不是完美的,但它一定是与时俱进的。我回顾了一下这份文档,前前后后修改了不下100次。\n\n![](https://qncdn.wbjiang.cn/%E6%B5%81%E7%A8%8B%E4%BF%AE%E6%94%B9100%E4%BD%99%E6%AC%A1.png)\n\n接下来针对前端研发流程这块分享一下我们的实践,希望给有需要的朋友一点帮助!\n\n# 整体流程\n\n团队整体的一个研发流程大致如下:\n\n![](https://qncdn.wbjiang.cn/%E7%A0%94%E5%8F%91%E6%95%B4%E4%BD%93%E6%B5%81%E7%A8%8B.png)\n\n这块可能大部分公司都是大同小异,没什么好细说的。实际上,可能每个环节是否执行到位也是需要打个问号的。\n\n![](https://qncdn.wbjiang.cn/%E5%93%88%E5%93%88%E5%93%88.gif)\n\n# 前端研发流程\n\n## 系统镜像\n\n为了方便新入职同事快速进入状态,我们制作了统一的前端开发系统镜像,避免了出现电脑装机和各种环境配置出现的问题,产生一些不必要沟通而浪费时间。\n\n> 最早的时候,还是我出的一个简单的文档,告诉新同事要装什么什么软件这样子,但是发现问题还是挺多,用了镜像后,省心不少。\n\n## 前端研发流程图\n\n![](https://qncdn.wbjiang.cn/%E5%89%8D%E7%AB%AF%E7%A0%94%E5%8F%91%E6%B5%81%E7%A8%8B%E5%9B%BE.png)\n\n这张图描述了前端日常开发的主要过程,可以花个1到2分钟好好看看。\n\n## 研发资源\n\n- 原型设计:`axure`\n- 视觉设计:蓝湖,`iconfont`\n- api文档:`swagger`\n- 敏捷协作:`TAPD`,研发日常的任务,需求,缺陷等工作都集中在[TAPD](https://www.tapd.cn/my_worktable)平台上。\n- 源码仓库:自建`Gitlab`\n- 团队文档:语雀\n\n## 处理IconFont图标\n\n在图标管理这块,我们使用的是 iconfont,包括字体图标和矢量图标都有用到,并且封装了对应的组件`icon-font`和`icon-svg`,目前主要以使用`icon-svg`为主,`icon-font`主要面向部分项目。\n\n![](https://qncdn.wbjiang.cn/%E5%9B%BE%E6%A0%87%E8%B0%83%E6%95%B4.png)\n\n**在 iconfont 上调整好图标后,需要重新生成链接**;\n\n- 对于字体图标组件 icon-font,生成在线的 font class 链接,替换掉项目中 index.html 中的对应链接才会生效。\n\n![](https://qncdn.wbjiang.cn/icon-font%E5%A4%84%E7%90%86.png)\n\n- 对于矢量图标组件 icon-svg,生成在线的 symbol 链接,替换掉 icon-svg 组件中引用的 js 链接才会生效。\n\n![](https://qncdn.wbjiang.cn/icon-svg%E5%A4%84%E7%90%86.png)\n\n## issue驱动\n\n虽然`git log`已经提供了记录查询的能力,但是还不够便捷,直观。\n\n我们采用`issue`驱动开发,所有的代码改动都应该先在`gitlab`创建`issue`,包括但不限于需求,缺陷,自测试,优化类改动。\n\n> commit是可以自动关联和关闭issue的,这样一来,我打开每一个issue,就知道这个issue关联了哪些代码提交记录,查问题非常直观!另外,这也是众多开源项目常见的协作方式。\n\n![](https://qncdn.wbjiang.cn/issue%E4%B8%8Ecommit%E7%9A%84%E5%85%B3%E8%81%94.png)\n\n对于需求、缺陷类型的开发任务,应在创建`issue`时附上`TPAD`相关链接,方便跳转查询。\n\n**issue应该保证原子性,一个issue只做一件事。**\n\n## 开发流程\n\n### VSCode扩展\n\n首先,安装必要的 VSCode 扩展,结合项目中配置的 Lint/Formatting 能力,达到一个高效开发的状态。\n\n![](https://qncdn.wbjiang.cn/eslint%E6%89%A9%E5%B1%95.png)\n\n![](https://qncdn.wbjiang.cn/prettier%E6%89%A9%E5%B1%95.png)\n\n![](https://qncdn.wbjiang.cn/stylelint%E6%89%A9%E5%B1%95.png)\n\n![](https://qncdn.wbjiang.cn/vetur%E6%89%A9%E5%B1%95.png)\n\n### 全局依赖\n\n```javascript\n// 保证 yarn 的正常使用\nnpm install -g yarn\n// 用于规范 commit 提交\nnpm install -g commitizen\nnpm install -g conventional-changelog-cli\n```\n\n### npm/yarn代理\n\nnpm 自带的源有时候速度太慢,或者有时候根本下载不了某个包,容易导致 install 失败或停顿,请统一使用 taobao 代理。\n\n```shell\nnpm config set registry https://registry.npm.taobao.org\n\n// 部分项目使用yarn\nyarn config set registry https://registry.npm.taobao.org\n```\n\n### Nginx代理配置\n\n我们在开发者本地使用 Nginx 作为中间代理,加一层本地 Nginx 代理的目的是:\n\n- 统一项目访问的服务端口,防止误提交项目配置文件,避免不必要的代码冲突。\n- 便于切换不同环境时,实现秒级切换,不用重启 devServer。后面这里可以优化下,据我观察,ant-design-pro 实现了不重启 devServer 更新 proxy,有空研究下!\n- 如果比较反感,不强制增加本地 Nginx 这一层,可以自行指定后端 gateway 地址,但是注意不要提交 vue.config.js 文件,避免引起冲突!\n\n1. 下载windows稳定版本Nginx,链接是[http://nginx.org/en/download.html](http://nginx.org/en/download.html)\n\n![](https://qncdn.wbjiang.cn/nginx%E9%80%89%E6%8B%A9%E7%A8%B3%E5%AE%9A%E7%89%88.png)\n\n2. 修改nginx/conf/nginx.conf\n\n> 一个基本的代理配置如下:\n\n```nginx\nworker_processes 1;\n\nevents {\n worker_connections 1024;\n}\n\nhttp {\n include mime.types;\n default_type application/octet-stream;\n sendfile on;\n keepalive_timeout 65;\n #gzip on;\n server {\n listen 8090;\n server_name 127.0.0.1;\n\n location / {\n proxy_pass http://xxx.xxx.tech;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n # proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n }\n }\n}\n```\n\nnginx 统一监听 127.0.0.1:port,代理请求到后端 gateway。在需要切换环境时只需要修改 proxy_pass 这一项即可。\n\n3. 前端工程默认配置 proxy 的 target 为 `http://127.0.0.1:port`,配合Nginx使用时,不要修改此项。\n\n4. 常见命令\n\n```\n#linux中\n#开机启动\nsystemctl enable nginx\n#启动\nsystemctl start nginx\n#重启\nnginx -s reload\n\n#windows中\n#启动\n直接打开nginx.exe\n#重启\n修改配置文件后重启,可以选择cmd打开根目录,然后nginx.exe -s reload\n#其他\n如果重启有问题,一般是点击了多次nginx.exe,这时候可能启动了多个nginx,算是个windows版本的bug吧,\n可以直接打开任务管理器找到所有nginx进程,然后结束掉。最后直接打开nginx.exe启动。\n```\n\n关于这一环节,我之前还写过一篇[「前端必看」这篇Nginx反向代理技巧,助你准时下班陪女神](https://juejin.cn/post/6846687599508078600),想要详细了解的朋友可以打开看看。\n\n### 项目依赖安装\n\n目前有的项目使用`npm`管理依赖,有的使用的是`yarn`。进入项目时,观察`lock`文件,如果项目根目录有`yarn.lock`,则说明使用`yarn`;如果项目有`package-lock.json`,说明使用`npm`。\n\n安装依赖时根据情况执行以下命令:\n\n- npm\n```\nnpm install\n```\n\n- yarn\n```\nyarn\n```\n\n### 需求/缺陷分配\n\n**TL**负责任务分配,将`TPAD`上的需求进行分配,指定开发责任人。\n\n> TAPD的需求分发到个人后,都要求开发人员拆分出前端子需求(不限于1个),根据自身能力和需求复杂度评估出**开发时间规模**,根据自己的排期填写预估**起始时间**和**结束时间**,项目经理会追这个事情,责任到个人。如果有逾期风险,提前向 TL 或者自己的导师反馈原因,不要拖到最后一天才暴露问题。\n\n按道理,开发时间规模应该是大家做需求评审时定下来的,但是考虑到团队的一个实际情况,这块权限是下放到开发者自行评估,但是 TL 需要起到一个监督的作用。这里大家可以结合实际情况考量。\n\n### 创建issue\n\n原则上由开发者自行创建,创建需求和 bug 类 issue 时,应附上 tapd 链接,方便查询关联事项!issue 也是分支命名的一个依据。下面我会接着说。\n\n> 有些时候,TL 或者项目 Owner 会安排一些非 TAPD 管理的组内任务,会直接以 issue 形式指给开发人员,开发人员接到 issue 后,不必再重复创建 issue,只要基于已创建的 issue 创建本地分支开发就行。\n\n### 分支权限控制\n\n在我们团队中,约定了三个重要分支,分别是:\n\n- develop:开发分支\n- release:测试分支\n- master:生产分支\n\n这三个分支被设置为 Protected Branches,通常都关联了对应环境的 CI/CD 配置。在权限控制上一般设置为:**Maintainer 拥有 Merge 权限,所有人都没有 push 权限**。\n\n实际操作时,一个基本的 Merge 方向是:\n\n![](https://qncdn.wbjiang.cn/%E5%9F%BA%E6%9C%AC%E7%9A%84merge%E6%96%B9%E5%90%91.png)\n\n根据实际情况,也可以引入预发布分支之类的分支。\n\n如果对分支权限不做控制,大家可以随意 push,就意味着潜在的灾难随时可能发生。所以这一点是非常值得关注的!\n\n### 创建分支\n\n我之前也试过分支语义化命名,但是也发现了要用有限的单词描绘出复杂的含义永远是个伪命题。我们可能会在做一个新功能时,会把相关分支命名为`feature/xxx`,而后面有优化类需求时,又会新建一个`feature/xxx-optimization`之类的分支。然而,往往一个功能会有一次又一次的优化、变更或 bug,采取这样的命名策略永远会让自己直面灵魂拷问!\n\n并且在追溯问题时,这种分支命名方式往往让人心力交瘁!\n\n那么如何命名能解决这样的问题呢?我采用了下面这种策略!\n\n`issue`本身有一个编号,或者叫`ID`,这种唯一标识让我们命名分支变得简单。假定一个`issue`的编号是`1`,那么我们在本地创建分支时,只需要将分支命名为`issue/1`即可,根据这个编号,我就能查到这个分支处理的是哪个`issue`,而打开 Gitlab 的`issue`界面,我就能知道这个`issue`与什么需求或缺陷有关,而且也能直观看到与这个`issue`关联的代码 commit 记录。这不仅给开发者带来了方便,也让管理者变得更轻松!\n\n好,下面说实际操作。以开发新特性为例:\n\n- 本地切换到`develop`分支,拉取最新`develop`分支代码\n\n```\ngit checkout develop\ngit pull\n```\n\n- 基于`develop`分支创建新的特性分支,用于开发新特性,目前统一命名为`issue/xxx`,其中`xxx`是你在`gitlab`上创建的`issue`号,如下所示:\n\n```\ngit checkout -b issue/1\n```\n\n也就是说,`issue/1`分支用于解决`gitlab`上的`issue 1`提到的问题。\n\n> 有的项目比较简单,或者还在初期阶段,这种情况下,不会设置 develop 分支,而是基于 master 分支快速开发。如果是这种情况,在上面的操作中,可以直接把 master 理解为 develop,依葫芦画瓢即可。\n\n### 提交代码\n\n**特性/缺陷分支应该保证原子性,一个分支只解决一个问题(指的是一个issue提及的问题),否则原则上不允许合入其他分支。这对敏捷迭代有关键意义!**\n\n开发完毕后,应提交到远程仓库同名分支。\n\n```javascript\ngit add .\ngit cz // 进行cz交互式命名行提交\ngit push origin HEAD // 提交到远程仓库同名分支\n```\n\n#### 提交部分代码\n\n如果一次**commit**希望提交部分文件(而不是全部修改的文件),不要用 `git add .`,可以结合 GUI 进行选择(比如VSCode自带的Git面板),进入**staged**状态的文件,就是你希望提交的。\n \n> ➕ 是进入 staged,➖ 是移出 staged,**Staged Changes** 就是你希望在这次 commit 的内容。\n> \n> 提交部分代码时,注意保管好自己未提交的代码,未入库就有丢失的可能,这一点要明确!\n\n![](https://qncdn.wbjiang.cn/%E4%BB%A3%E7%A0%81%E9%83%A8%E5%88%86%E6%8F%90%E4%BA%A4.png)\n\n#### git cz流程\n\n`git cz`是`commitizen`提供的能力,这块我之前简单写过一段介绍,具体见[规范commit message](https://juejin.cn/post/6844904056498946055#heading-6)。使用`git cz`的主要目的就是规范代码提交。\n\n1. 选择提交的类型,`feat`代表需求,`fix`代表修复缺陷,`docs`代表文档类变动,`style`是代码风格层面的(不是指样式...),`refactor`指的是代码重构,`perf`则是优化相关的(包括性能/体验等),`test`是单元测试之类的,`build`是构建工具相关的,`ci`是持续集成相关的,`chore`的解释各异,按`commitizen`的解释就是非`src`或`test`的其他改动,`revert`代表代码回退...\n\n2. 请准确选择改动类型!\n\n![](https://qncdn.wbjiang.cn/git-cz.png)\n\n3. 影响范围\n\n【按情况填写即可,如果不是过于抠细节,大部分时候可以不填】\n\n```\nWhat is the scope of this change (e.g. component or file name): (press enter to skip)\n```\n\n4. 改动描述\n\n【必填】本次代码改动的描述信息,可摘取issue的标题,或者是tapd需求或缺陷的标题,也可以自行总结。\n\n```\nWrite a short, imperative tense description of the change (max 94 chars):\n登录功能开发\n```\n\n5. 详细描述\n\n【按情况填写,如果不是过于抠细节,大部分时候可以不填】提供详细描述\n\n```\nProvide a longer description of the change: (press enter to skip)\n```\n\n6. 是否有重大变更?\n\n【一般是回车或者输入N跳过】一般来说,只有架构层面的变更才会填入y\n\n```\nAre there any breaking changes? (y/N)\n```\n\n7. 是否影响issue?\n\n【一般来说,一次commit都应该有与之关联的issue,输入y,用来关闭issue,这个是很常见的】\n\n```\nDoes this change affect any open issues? (y/N)\n```\n\n【一般可以跳过】如果关联的issue已经关闭,可以针对本次commit\n做一个信息补充。\n\n```\nIf issues are closed, the commit requires a body. Please enter a longer description of the commit itself:\n (-)\n```\n\n8. 关闭issue?\n\n```\n? Add issue references (e.g. \"fix #123\", \"re #123\".):\n```\n\n假设要关闭 issue#1,则输入:\n\n```\nfix #1\n```\n\n如果要关闭多个 issue,则输入:\n\n```\nfix #1 #2 #3\n```\n\n9. 提交至远程同名分支\n\n```\ngit push origin HEAD // 提交到远程仓库同名分支\n```\n\n#### commit 检查\n\n配合 husky 和 git hook 来实现 commit 检查。\n\n1. 防止提交不符合规范的代码。\n2. 防止`commit message`不规范。\n\n这里需要借助以下依赖:\n\n- husky\n- commitlint\n- commitizen\n- conventional-changelog-cli\n- lint-staged\n\n### 发起Merge Request\n\n`git push`到远程同名分支后,并不代表你的代码进入了主分支,你接着还需要走代码合并流程。\n\n由开发者在`gitlab`自行发起`Merge Request`,请求将代码合入`develop`分支。\n\n**TL或者有Merge权限的人**负责进行**Code Review**,审核通过后,方可合入代码。\n\n### 本地获取最新develop分支\n\n代码合入后,就可以基于`develop`分支做其他的功能开发了。\n\n```\ngit checkout develop\ngit pull\n// 进行其他的特性开发,或bug修复\n```\n\n### 继续未完成的需求\n\n如果提交代码**并被Merge**后,发现本需求并未开发完毕,此时不可再另外创建 issue,应该基于同一个 issue 继续修改;以 issue 号为 1 举例说明。\n\n首先需要在 gitlab 上 reopen issue#1。然后本地进行分支操作。\n\n```shell\ngit checkout develop\ngit pull\ngit branch -d issue/1\ngit checkout -b issue/1\n```\n\n继续开发...\n\n### 开发到中途想拉别人代码\n\n如果自己正在 issue/1 分支开发,而其他同事提交了代码,并且已经合并到 develop 分支,此时你想用他这部分代码。需要执行以下步骤。\n\n```shell\ngit checkout develop // 如果这一步如果无法执行,见下一节\ngit pull\ngit checkout issue/1\ngit merge develop\n```\n\n### 想切出去合入别人提交的最新代码却发现git checkout develop报错\n\n这种情况是因为你的 issue 分支代码与最新的 develop 分支相比,已经落后或者产生了冲突。\n\n此时如果仍希望把别人提交的最新代码合入到自己的 issue 分支继续开发,需要执行以下步骤,利用`git stash`做一个临时储存。\n\n```shell\n// 假设现在位于 issue/1 分支\ngit stash\ngit checkout develop\ngit pull\ngit checkout issue/1\ngit merge develop\ngit stash pop\n```\n\n此时一般会出现冲突,VSCode git 区域会有提示感叹号或者 C(代表 conflict)。\n\n需要解决掉冲突再继续开发,开发完毕后按正常流程提交代码。\n\n### 其他\n\n以上是以新特性开发为例进行说明。\n\n其实对于修复缺陷而言,整个操作流程也基本相同。\n\n如果是测试环境和生产环境都有的bug,通常先修复测试环境验证效果后,视情况通过 cherry-pick 进行生产环境紧急发版。紧急情况下,可优先修复生产环境。\n\n## issue和merge request规范\n\n### issue规范\n\n创建 issue 时,应清楚该 issue 用于处理什么事务。\n\n1. 如果明确 issue 处理的是 TAPD 上的需求或缺陷,必须在描述中插入 TAPD 链接,方便自己或同事查阅跟踪。选择的 Label 应该是需求或 Bug。\n\n![](https://qncdn.wbjiang.cn/%E6%9D%A5%E6%BA%90tapd%E7%9A%84issue.png)\n\n2. 如果不是来源于TAPD,则在标题中简要说明主题,在描述中说明具体事项(可选),根据实际情况选择合适的Label。\n\n![](https://qncdn.wbjiang.cn/%E9%9D%9Etapd%E6%9D%A5%E6%BA%90%E7%9A%84issue.png)\n\n### Merge Request 规范\n\n1. 提交 Merge Request 时,应明确目标分支。比如需求类的,我们是基于 develop 分支创建 issue/xxx 分支,所以提交到远程同名分支后,需要请求合入到 develop 分支。\n\n2. 对于 bug,首先明确 bug 发生的环境。而对于**生产环境bug**,我们是基于 master 分支创建 issue/xxx 分支,解决完之后,是请求合入到 master 分支;**测试环境bug**的处理方式可类比;如果一个bug**在开发环境,测试环境和生产环境都有出现**,应基于develop分支新建分支,bug 处理完毕后先发起 MR 合入到 develop 分支,再通过 cherry-pick 等方式合入到 release 或者 master 分支。\n\n> 可能有人会觉得生产环境 bug 相关的代码直接合入到 master 分支不妥,这个我觉得可以看具体情况分析,如果团队的风险把控程度严一点,可以考虑安排一个预发布分支,对应一个预发布环境,尽可能模拟生产环境。\n\n3. Assignee选择审核人,一般是Mentor或其他同事(交叉评审)。\n\n![](https://qncdn.wbjiang.cn/%E4%BB%A3%E7%A0%81review%E6%8C%87%E6%B4%BE.png)\n\n4. 同时也附上Labels,表明你这次提交解决了哪些类型的问题。如同时涉及多类,则进行多选。比如,我同时解决了 bug 和需求,那我就会勾选两项,但是不推荐这样做,因为我们建议一个 issue 只做一件事,所以一次 Merge Request 也意味着只解决一个问题。\n\n5. 如果涉及发版分支(比如release和master),建议勾选下环境选项(如**测试环境**,**生产环境**),给人一目了然的感觉。\n\n![](https://qncdn.wbjiang.cn/mr%E9%80%89%E6%8B%A9label.png)\n\n### Merge Request 不通过\n\n有 Code Review 就可能存在审核不通过的情况,各种情况都有,比如业务组织方式不恰当,不符合规范,未完成功能,等等。\n\nCode Review 不通过时,我会以 Comment 的形式直接在问题代码附近进行标注。这些 Review 意见会以邮件的形式通知到责任人。\n\n![](https://qncdn.wbjiang.cn/%E4%BB%A3%E7%A0%81review.png)\n\n针对 Code Review 不通过的情况,不用另外创建 issue,直接切到 issue 对应的分支继续修改代码并 push 就可以了,push 到 remote 后会自动反映到 MR 中,自己在 MR 的 Changes 中也能看得到你最终修改了哪些内容。\n\n![](https://qncdn.wbjiang.cn/mr%E5%A4%9A%E6%AC%A1commit.png)\n\n当所有 Review 意见都被 Resolved 后,这个 Merge Request 才可以通过。\n\n# 发版流程\n\n由于我们团队采用了 CI/CD,所以发版流程与相关分支的代码合并是紧密关联的,项目 Owner 负责跟进发版事宜。\n\n按照我们目前的流程,发版的触发动作就是执行 Merge Request,接着对应的 Pipeline 就会自动执行,根据 CI/CD 的环境配置,部署到对应的服务器上。\n\n![](https://qncdn.wbjiang.cn/pipeline%E5%88%97%E8%A1%A8.png)\n\n![](https://qncdn.wbjiang.cn/pipeline%E7%9A%84job%E8%AF%A6%E6%83%85.png)\n\n正常情况下发版的一个 Merge 方向是:\n\n测试环境发版:develop -> release\n\n生产环境发版:release -> master\n\n## 正常发版\n\n正常就是一个迭代进行一次生产环境发版。生产环境发版当天,要求相关责任人在场支撑!\n\n测试环境的发版可能会频繁一点,一般来说,至少一天一个版本,方便快速进行回归测试。\n\n## 非全量发版\n\n有时候会遇到这么一个情况,生产环境准备要发版了,但是突然发现测试环境某个功能存在缺陷,不能执行全量发版。但是你不可能说我就不发了吧,所以还是要把剩下的可用功能发上去。\n\n那么此时我们就不能执行 release -> master 这样一个 Merge 流程了,只能挑选出可以上线的代码,这个时候就用到 cherry-pick 了。我们可以找到可用功能的代码 Merge 到 develop 分支的记录,通过 cherry-pick 挑选合入到 master 分支,实现一个非全量上线的效果。\n\n这也是我前面强调一个 issue 只做一件事的原因,而且一次 commit 尽可能要做完一件事,方便我们进行特殊情况下的 cherry-pick。如果很多代码掺和到一起,一旦部分功能不可用,发版上线就成了一件非常痛苦的事情。\n\n## 发版成功判定依据\n\n目前已经配置了 Pipeline 邮件提醒,发版成功后,会有邮件提醒到项目组全员!\n\n注意分支和环境的对应关系,就能知道哪个环境发布了新版本!\n\n后面考虑对接一下企业微信对话机器人,会更加直观一点。\n\n# TAPD管理\n\n代码提交的流程已经在上文中描述清楚了,另外还要做的事情是`TAPD`的状态流转。\n\n## 开发优先级\n\n1. 首先遵从产品规划层面的优先级排序。\n\n2. 在此基础上拆解出前端子需求,各个子需求的优先级应视实际情况(比如后端接口完成情况)调整。\n\n3. 按高中低三个等级来看,应保证子需求分配到个人后,个人工作台只有1-3个高优先级任务,也就是可能并行开发的1-3个需求,其余需求视情况应纳入中或低优先级。待高优先级子需求开发完毕转测试后,应通知项目 Owner,Owner 再根据实际情况调整其余任务的优先级,保证高优先级的任务的最小规模,使得开发人员专注于处理高优先级任务。\n\n## 开发排期\n\n1. 迭代层面会有一个大致的排期出来,但仍不足以作为开发排期。\n\n2. 拆解子需求后,处理人应第一时间评估出开发预估耗时,方便进行迭代需求排期,按照优先级、预估耗时以及自己的任务排期情况,排好每个需求的开发起止时间。\n\n3. 如无团队协作的意外情况发生,应保证在每个需求的开发结束时间之前完成需求转测,否则应回溯原因。\n\n4. 当出现多个项目之间并行迭代发生优先级冲突时,应由项目经理组织协调,根据实际情况调整排期和优先级。\n\n## 开发中\n\n准备开发需求前,应先将`TAPD`上的相关需求的状态改为**开发中**\n\n解决缺陷前,应先将`TAPD`上的相关缺陷的状态改为**接受/处理**\n\n## 已完成\n\n开发完毕后,开发者应充分自测,如果转测试不通过,需要承担相应责任。\n\nCI/CD 自动发版完成后,开发者会收到邮件通知,此时方可在`TPAD`中进行状态流转:\n\n- 相关需求的状态改为**转测试**\n- 相关缺陷的状态改为**已解决**\n\n# 小结\n\n以上就是我在前端研发流程上的一点亲身实践,希望能给有需要的朋友带来一点帮助。总的来说,将流程形成文档之后,还是节省了我很多时间,有些重复的问题我就不必一一回答了。当然,上述文档也只是描述了研发过程中主要环节的过程,起到一个辅助的作用,还有很多细节也是需要在日常多做沟通交流的。\n\n我始终认为有效的沟通和反馈是一个团队的首要任务,只有这一块做好了,才能劲往一处使,高效出成果!', '2021-08-18 23:23:08', '2024-10-09 07:23:30', 1, 533, 0, '10人小团队,靠着它运作...', 'https://qncdn.wbjiang.cn/team_ground.jpg', 0, 0);
-INSERT INTO `article` VALUES (232, 'Vue3+TS+Node打造个人博客(总览篇)', '从 Vue3 正式发布到现在,也快过去一年了(写这行文字的时候是2021年09月08日,拖延症...)。\n\n![](https://qncdn.wbjiang.cn/vue3%E5%8F%91%E5%B8%83.png)\n\n但是就我最近招聘面试的一些经历来看,很多 Vue 技术栈的候选人依然还没有使用过 Vue3。\n\n关于他们没有选择使用 Vue3 这个事情,我觉得也是可以理解的。一方面,Vue3 直接放弃了 IE11。虽然 IE 的用户数量在持续下降,但是想让老板们直接放弃 IE11 还是有一些困难。\n\n![](https://qncdn.wbjiang.cn/vue3%E6%94%BE%E5%BC%83ie11.png)\n\n另外就是,做项目这种事情,有时候人们的选择就是能用就行,升级 Vue3 可能并不能给项目带来太多效益。对于一些历史悠久的项目,甚至还要考虑 Vue3 新生态是否完善的问题,是不是能够支撑自己完美过渡。\n\n诚然,拥抱新技术还存在着这么一些障碍,是否选择新技术需要综合去考量。但是从做技术的角度出发,我们还是要保持一个 Open 的心态,敢于去接受新鲜事物,即便短期不能直接用在工作中,也可以自己私下感受下新框架给我们带来了什么新的体验。\n\n早些时候,我为了更深入地了解前后端完整链路,特意自己实现了个人博客。早期效果如下:\n\n![](https://qncdn.wbjiang.cn/%E6%97%A7%E7%89%88%E5%8D%9A%E5%AE%A2.png)\n\n之后一方面是觉得博客做得太难看,另一方面是想尝试在 Vue 项目中实践 TS,于是2020年我就立了一个 Flag 做重构,当时技术选型是 vue-class-component + vue-property-decorator,类组件 + 装饰器模式。做了一段时间,感觉开发体验也不是很好,慢慢就放弃了,等着 Vue3 的发布。\n\n最近也是借着一些业余时间完成了自己的一个Flag,虽然延期了很久,但总算是有了结果。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E5%BD%95%E5%B1%8F2.gif)\n\n首先分享下在线链接:[Tusi博客](https://blog.wbjiang.cn/)\n\n# 整体架构\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E6%95%B4%E4%BD%93%E6%9E%B6%E6%9E%84.png)\n\n从技术选型来看,我还是选择了一些比较接地气的框架和技术。\n\n其实对于博客这种 SEO 要求高的网站,优选的方案还是 SSR,但我还是选择了 CSR 方案(毕竟是个人项目,怎么舒服怎么来),后续时间充裕的情况下再考虑下 SEO 优化。\n\nWeb 端这块,我是直接选择了 [Vue3](https://v3.cn.vuejs.org/) + [TS](https://www.typescriptlang.org/docs/) 作为一个开发骨架。作为一个代码洁癖选手,我还是非常倾向于使用 TS 的。\n\nUI 方面,我选择了 AntDesign 为主、ElementPlus 为辅的这样一个组合,这两个 UI 框架都是非常优秀的,但二者都有一些对方没实现的能力,所以我综合使用了二者。读者们也不用担心性能问题,按需加载情况下,用两套 UI 框架也没有什么压力,这一点我也是思考过的。\n\n视觉效果这块,基本上属于我自己发挥想象了,凭着自己感觉做的一个整体效果。然后我也是采用了 Mobile First 的理念,优先完成移动端视觉效果,结合 Media Query 去做一些其他屏幕的适配。\n\n在客户端这块,除了 Web 端,我早期还是做了小程序的,这个可以在微信直接搜索到,名字也是**Tusi博客**。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E5%B0%8F%E7%A8%8B%E5%BA%8F%E6%95%88%E6%9E%9C%E5%9B%BE.jpg)\n\n回过头来看,如果是这种跨端的系统,我应该优选 uniapp 这类的方案,不过这个小程序我做得比较早,那会儿我几乎还不会小程序开发,也是属于一个边学习边开发的状态。\n\n后端这块,也是开发得比较早,那会儿可选的 Node 框架也不多,所以我选择了比较流行的 [Express](https://www.expressjs.com.cn/),这确实是一个易上手并且好用的框架,Express 不会给你灌输太多的设计模式,对于初次接触后端的朋友来说,是一个非常友好的选择。\n\n数据库我选择的是关系型数据库 MySQL,接入层当然首选 Nginx 啦。\n\n# 云服务器配置\n\n我买的云服务器配置是:1核 2GB 1Mbps\n\n这对于前期负载不是很高的个人项目来说,是足够的。反正现在都支持弹性伸缩,不够就加,问题不大。\n\n# 部署方式\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E9%83%A8%E7%BD%B2%E6%96%B9%E5%BC%8F.png)\n\n整体上采用了自动化部署的策略。Node 这块是基于PM2去做进程守护和自动化部署.\n\n![](https://qncdn.wbjiang.cn/pm2%E9%83%A8%E7%BD%B2%E6%95%88%E6%9E%9C%E5%9B%BE.png)\n\n前端则是基于 Github Actions 实现的CI/CD。\n\n![](https://qncdn.wbjiang.cn/github_actions%E6%95%88%E6%9E%9C.png)\n\n我购买的云服务器配置很普通,图片资源放自己服务器上是不现实的,所以我的图片资源是放在七牛云存储。\n\n# 未完待续\n\n本文主要是对[Tusi博客](https://blog.wbjiang.cn/)做了一个总体的介绍,让大家先有个整体的印象。整个博客应用确实是比较简单,但也算是一个前后端完整的系统,应该能给朋友们带来一点帮助或思路。接下来,我将分几篇文章详细讲讲我是怎么实现这些功能的,敬请期待!\n\n# 代码,拿来吧你\n\n> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n# 系列文章\n\n**Vue3+TS+Node打造个人博客**系列文章如下,持续更新,欢迎阅读!点赞关注不迷路!😍\n\n- [Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)\n- [Vue3+TS+Node打造个人博客(数据库设计)](https://juejin.cn/post/7070001585199251487)\n- [Vue3+TS+Node打造个人博客(后端架构)](https://juejin.cn/post/7072903323128594462)\n- [Vue3+TS+Node打造个人博客(前端架构)](https://juejin.cn/post/7245674094493057082)\n- [前端如何学会全栈分页开发?源码和思路都在这了](https://juejin.cn/post/7301242196888387593)\n- [一键到顶和侧边弹射效果制作,复习巩固“切图仔”基本技能](https://juejin.cn/post/7307065042004410377)\n- [一篇博客如何来到用户面前,分享前端也能看懂的文章详情页全栈设计](https://juejin.cn/spost/7363454217191325733)\n- [评论系统的全栈设计思路,学会自己也能快速上手搭建](https://juejin.cn/spost/7366972839779975218)\n- [别背八股文了,WebSocket 是什么,我劝你花几分钟让面试官惊艳!](https://juejin.cn/post/7371379258122371123)\n- [花1块钱让你的网站支持 ChatGPT](https://juejin.cn/post/7176539666210881592)\n- [前端轻松拿捏!最简全栈登录认证和权限设计](https://juejin.cn/post/7371716394302767142)\n- [前端上手全栈自动化部署,让你看起来像个“高手”](https://juejin.cn/post/7373488886461431860)\n- [小程序博客搭建分享,纯微信小程序原生实现](https://juejin.cn/post/7374410972070920228)\n- [前端不懂 Docker ?先用它换掉常规的 Vue 项目部署方式](https://juejin.cn/post/7378079761817354277)\n- [五分钟学会 Docker Registry 搭建私有镜像仓库](https://juejin.cn/post/7379803343450062888)\n- [在 CI/CD 中怎么使用 Docker 部署前端项目?](https://juejin.cn/post/7380545407729090587)\n', '2022-02-11 15:59:56', '2024-11-13 16:38:10', 1, 1923, 0, '分享一下Vue3+TS+Node打造个人博客系列文章,想了解全栈的不要错过。', 'https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7%E9%A6%96%E5%9B%BE/%E5%8D%9A%E5%AE%A2_%E6%80%BB%E8%A7%88%E7%AF%87_1.png', 0, 0);
-INSERT INTO `article` VALUES (233, 'Vue3+TS+Node打造个人博客(数据库设计)', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n一个博客系统应该有什么功能,相信大家都是非常熟悉的,其核心无非是**文章**、**分类**、**创作**。而像**标签**、**评论**、**留言**、**交流**、**后台管理**这些功能,都是锦上添花。\n\n要实现这些功能,最关键的是先梳理各个功能之间的关系。提到关系,自然就会联想到关系型数据库。\n\n![](https://qncdn.wbjiang.cn/mysql%E9%80%9A%E7%94%A8.jpeg)\n\n在设计数据库前,需要先理清实体和实体之间的联系,这里会用到 E-R 图或者 UML 之类的建模语言来做一个概要设计。\n\n但是从我这种非专业的数据库用户的视角来看,我觉得可以不拘泥于形式,不必局限于 E-R 图或者 UML,你也可以选择用思维导图这类的图形化表述工具。因为这只是一个概要设计阶段。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E8%A1%A8%E7%AE%80%E5%8D%95%E8%AE%BE%E8%AE%A1.png)\n\n如上图所示,针对我的个人博客,我做了简单的实体和实体关系设计。\n\n# 多对多关系\n\n其中文章表`article`是核心,考虑到一篇文章可能关联多个分类`category`或标签`tag`,一个分类或标签下也会有多篇文章,所以我这里设计的都是**多对多关系**,用到了关系表。\n\n关系表不会涉及很多字段。\n\n![](https://qncdn.wbjiang.cn/%E5%85%B3%E7%B3%BB%E8%A1%A8%E5%AD%97%E6%AE%B5.png)\n\n主要是关系表中设计的两个外键起到关键作用。\n\n![](https://qncdn.wbjiang.cn/%E5%85%B3%E7%B3%BB%E8%A1%A8%E7%9A%84%E8%AE%BE%E8%AE%A1.png)\n\n根据这么一张关系表,就能完成多对多的关联关系。\n\n# 一对多关系\n\n文章下有评论,一篇文章可以有多条评论,文章`article`和评论`comment`的关系就是一对多的,这个是很好理解的。\n\n对于这种**一对多**关系,我的设计是在评论表中用一个外键`article_id`来实现关联。\n\n![](https://qncdn.wbjiang.cn/1%E5%AF%B9%E5%A4%9A%E5%A4%96%E9%94%AE%E8%AE%BE%E8%AE%A1.png)\n\n要查询某文章下的评论时,就可以依据条件`article_id`筛选出对应的评论数据。\n\n```\nSELECT * FROM comments WHERE article_id = 229;\n```\n\n![](https://qncdn.wbjiang.cn/%E6%A0%B9%E6%8D%AE%E6%96%87%E7%AB%A0id%E6%9F%A5%E8%AF%A2%E8%AF%84%E8%AE%BA.png)\n\n# 子级关系\n\n同样地,一条评论下也会有很多回复,针对回复,我是单独设计了`reply`表。`comment`和`reply`也是一对多的关系,`reply`表中有`comment_id`外键关联到`comment`表。\n\n除了对评论做回复,还可以针对某一条回复做回复,类似于这样:\n\n![](https://qncdn.wbjiang.cn/%E5%9B%9E%E5%A4%8D%E5%9B%9E%E5%A4%8D.png)\n\n而这种子级关系,就需要一个`parent_id`来做记录,根据`parent_id`串起来的关系,在业务侧我们就可以得到一棵回复树。\n\n# 状态字段\n\n很多业务都离不开状态的维护,比如数据的逻辑删除,文章的公开/私密处理,评论/回复的审核机制,这些都需要一些标志位来描述状态,同时提供一些业务接口来维护状态。\n\n![](https://qncdn.wbjiang.cn/%E8%A1%A8%E7%8A%B6%E6%80%81%E5%AD%97%E6%AE%B5%E7%BB%B4%E6%8A%A4.png)\n\n# 小结\n\n本文是**Vue3+TS+Node打造个人博客(数据库设计篇)**,主要介绍了我在为博客系统设计数据库时的一些主要思路和关注点,接下来将针对一些具体的业务实现来进行更详细的剖析,敬请期待!\n\n# 系列文章\n\n**Vue3+TS+Node打造个人博客**系列文章持续更新,欢迎阅读!点赞关注不迷路!😍\n\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)\n', '2022-03-01 12:34:55', '2024-11-11 09:10:04', 1, 1043, 0, '一个博客系统应该有什么功能,相信大家都是非常熟悉的,其核心无非是文章、分类、创作。而像标签、评论、留言、交流、后台管理这些功能,都是锦上添花。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E5%85%A8%E7%B3%BB%E5%88%97_%E6%95%B0%E6%8D%AE%E5%BA%93.png', 0, 0);
-INSERT INTO `article` VALUES (234, 'Vue3+TS+Node打造个人博客(后端架构)', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n[Express](https://www.expressjs.com.cn/) 是基于 Node.js 平台,快速、开放、极简的 Web 开发框架。目前已经更新到 5.x 版本。\n\n我的博客后端其实开发得比较早,19年年底基本上已经完成了主体功能的开发,当时用的是 Express 4.x 版本。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E5%90%8E%E7%AB%AF%E6%8F%90%E4%BA%A4%E8%AE%B0%E5%BD%95.png)\n\n在使用 Express 搭建后端服务时,主要关注的几个点是:\n\n- 路由中间件和控制器\n- SQL处理\n- 响应返回体数据结构\n- 错误码\n- Web安全\n- 环境变量/配置\n\n# 路由和控制器\n\n路由基本上是按模块或功能去划分的。\n\n首先是按模块去划分一级路由,各个模块的子功能相当于是用二级路由处理。\n\n简单举个例子,`/article`路由开头的是文章模块,`/article/add`用于新增文章功能。\n\n控制器的概念其实是从其他语言中借鉴而来的,Express 并没有明确说什么是控制器,但在我看来,路由中间件的处理模块/函数就是控制器的概念。\n\n下面是本项目使用到的一些控制器。\n\n![](https://qncdn.wbjiang.cn/%E8%B7%AF%E7%94%B1%E4%B8%AD%E9%97%B4%E4%BB%B6.svg)\n\n```javascript\nconst BaseController = require(\'../controllers/base\');\nconst ValidatorController = require(\'../controllers/validator\');\nconst UserController = require(\'../controllers/user\');\nconst BannerController = require(\'../controllers/banner\');\nconst ArticleController = require(\'../controllers/article\');\nconst TagController = require(\'../controllers/tag\');\nconst CategoryController = require(\'../controllers/category\');\nconst CommentController = require(\'../controllers/comment\');\nconst ReplyController = require(\'../controllers/reply\');\n\nmodule.exports = function(app) {\n app.use(BaseController);\n app.use(\'/validator\', ValidatorController);\n app.use(\'/user\', UserController);\n app.use(\'/banner\', BannerController);\n app.use(\'/article\', ArticleController);\n app.use(\'/tag\', TagController);\n app.use(\'/category\', CategoryController);\n app.use(\'/comment\', CommentController);\n app.use(\'/reply\', ReplyController);\n};\n```\n\n## BaseController\n\n其中,`BaseController`是用作第一道关卡,对所有的请求做一个基本的校验和拦截。\n\n其实主要是对一些敏感接口(比如后台维护类的)做一个权限校验。\n\n权限控制这块,我设计得还是比较简单粗暴的,因为我在数据库表中目前只预留了一个用户`Tusi`,关联的角色也是唯一用到的`admin`。毕竟目前还没考虑开放用户注册这类的能力,有一个管理用户基本上也够用了。\n\n所以我的设计是:**只要在我登录成功后的有效期内,就有权限操作敏感接口,否则就无权操作!**\n\n`BaseController`大体工作流程如下:\n\n![](https://qncdn.wbjiang.cn/%E8%BA%AB%E4%BB%BD%E6%A0%A1%E9%AA%8C%E9%80%BB%E8%BE%91.png)\n\n`BaseController`的主体代码结构大概如下:\n\n```javascript\nrouter.use(function(req, res, next) {\n // authMap 维护了敏感接口列表\n const authority = authMap.get(req.path);\n // 首先检查是不是敏感接口\n if (authority) {\n // 需要检验身份的接口\n if (req.cookies.token) {\n // 取到 token 去做校验\n dbUtils.getConnection(function (connection) {\n req.connection = connection;\n // 这里会直接查库验明身份\n connection.query(indexSQL.GetCurrentUser, [req.cookies.token], function (error, results, fileds) {\n // 身份校验通过,才继续,否则返回错误码\n })\n })\n } else {\n return res.send({\n ...errcode.AUTH.UNAUTHORIZED\n });\n }\n } else {\n // 不是敏感接口,不校验身份\n if (req.method == \'OPTIONS\') {\n // OPTIONS 类型请求不能去连数据库,否则会导致数据库连接过多崩了\n next();\n } else {\n // 从mysql连接池取得connection\n dbUtils.getConnection(function (connection) {\n req.connection = connection;\n next();\n }, function (err) {\n return res.send({\n ...errcode.DB.CONNECT_EXCEPTION\n });\n })\n }\n }\n}\n```\n\n如注释所述,`BaseController`主要是针对敏感接口做一个身份检查,防止系统数据被一些不怀好意的 HTTP 请求给黑了。\n\n### 20220218更新\n\n按照上面的逻辑实现功能并上线后,服务运行一段时间(可能是3~5天)后,能观察到服务请求会变成无法正常响应的状态。\n\n![](https://qncdn.wbjiang.cn/%E9%97%AE%E9%A2%98%E8%AE%B0%E5%BD%95/%E5%8D%9A%E5%AE%A2%E6%8E%A5%E5%8F%A3%E8%BF%90%E8%A1%8C%E4%B8%80%E6%AE%B5%E6%97%B6%E9%97%B4%E5%90%8E%E6%97%A0%E5%93%8D%E5%BA%94.png)\n\n其实我能感觉到可能是**mysql**连接池未合理释放导致的。\n\n但是由于我一开始采取的方案是:在**BaseController**给`req`挂载`connection`,并在具体的业务控制器执行完`sql`查询语句后再自行释放`connection`,这个基本使用过程我在后面一节也说到了。\n\n如果要完全改掉这种调用方式,代码改动还是挺大的,所以我一直拖着没改,发现问题了就通过 PM2 重启服务也能接着用。最近还是咬咬牙全部重构了,具体见[refactor: 重构sql调用部分](https://github.com/cumt-robin/express-blog-backend/commit/41628e98b2e1f2fee14289fdb8d13fe1bc0501e3)。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/sql%E8%B0%83%E7%94%A8%E9%87%8D%E6%9E%84.png)\n\n## 业务Controller\n\n前端会分模块,后端自然也会。业务模块会有很多,比如文章,分类,标签,等等。这些都可以分成不同的`Controller`处理。\n\n**业务Controller**的大体结构如下,一个子路由就对应一个功能:\n\n```javascript\n/**\n * @param {Number} count 查询数量\n * @description 根据传入的count获取阅读排行top N的文章\n */\nrouter.get(\'/top_read\', function (req, res, next) {\n // 业务代码\n}\n\n/**\n * @param {Number} pageNo 页码数\n * @param {Number} pageSize 一页数量\n * @description 分页查询文章\n */\nrouter.get(\'/page\', function (req, res, next) {\n // 业务代码\n}\n\n/**\n * @param {Number} id 当前文章的id\n * @description 查询上一篇和下一篇文章的id\n */\nrouter.get(\'/neighbors\', function (req, res, next) {\n // 业务代码\n}\n```\n\n# SQL处理\n\nSQL 这块,我没有直接用 ORM 工具。因为我觉得自己的 SQL 基础并不是很好,还需要自己多写 SQL 语句练习一下,所以我只用了一个**mysql**的库。\n\n![](https://qncdn.wbjiang.cn/%E8%A3%B8%E5%86%99SQL.png)\n\n安装**mysql**依赖:\n\n```\nnpm install --save mysql\n```\n\n简单使用时,可以直接创建连接,然后执行 SQL 语句:\n\n```javascript\nvar mysql = require(\'mysql\');\nvar connection = mysql.createConnection({\n host : \'localhost\',\n user : \'me\',\n password : \'secret\',\n database : \'my_db\'\n});\n \nconnection.connect();\n \nconnection.query(\'SELECT 1 + 1 AS solution\', function (error, results, fields) {\n if (error) throw error;\n console.log(\'The solution is: \', results[0].solution);\n});\n \nconnection.end();\n```\n\n实际上,更推荐使用连接池,可以避免重复向 MySQL 申请连接,实现了连接的重用,在响应速度上也会更快!\n\n```javascript\nvar mysql = require(\'mysql\');\nvar pool = mysql.createPool(...);\n \npool.getConnection(function(err, connection) {\n if (err) throw err; // not connected!\n \n // Use the connection\n connection.query(\'SELECT something FROM sometable\', function (error, results, fields) {\n // When done with the connection, release it.\n connection.release();\n \n // Handle error after the release.\n if (error) throw error;\n \n // Don\'t use the connection here, it has been returned to the pool.\n });\n});\n```\n\n实际操作时,我是在**BaseController**中执行了`pool.getConnection`,然后把`connection`对象挂载到`req`对象上,后续的路由中间件就可以直接从`req`对象中取得`connection`,可以少嵌套一层回调,也避免了每处业务代码都写这部分重复的`getConnection`代码。\n\n**BaseController**的关键代码:\n\n```javascript\n// 从mysql连接池取得connection\ndbService.getConnection(function (connection) {\n req.connection = connection;\n next();\n}, function (err) {\n return res.send({\n ...errcode.DB.CONNECT_EXCEPTION\n });\n})\n```\n\n业务处直接从`req`获取到`connection`对象:\n\n```javascript\nrouter.get(\'/page\', function (req, res, next) {\n const connection = req.connection;\n const pageNo = Number(req.query.pageNo || 1);\n const pageSize = Number(req.query.pageSize || 10);\n connection.query(indexSQL.GetPagedArticle, [(pageNo - 1) * pageSize, pageSize], function (error, results, fileds) {\n connection.release();\n // 其他业务代码\n })\n```\n\nSQL 语句主要是以字符串的形式编写,通过`?`作为一个参数槽位,接收一些动态的值。\n\n比如一个逻辑删除的语句,我们会这样写:\n\n```javascript\n// 逻辑删除/恢复\nUpdateArticleDeleted: \'UPDATE article SET deleted = ? WHERE id = ?\',\n```\n\n第一个`?`是留给字段`deleted`的值,第二个`?`便是传具体的`id`值。\n\n而参数传值是通过`connection.query`的第二个参数携带的。\n\n注意,这个参数是一个数组,数组中的值会按照从左到右的顺序依次替换掉 SQL 字符串中的`?`,变成一个真实的可执行的 SQL 语句。\n\n```javascript\nconnection.query(indexSQL.UpdateArticleDeleted, [params.deleted, params.id], function (error, results, fileds) {})\n```\n\n`connection.query`执行回调后切记调用`connection.release`释放连接。\n\n另外要注意的一个就是 MySQL 的事务处理。对事务而言,初步要关注的是这三个 API!具体的使用场景我在后面的具体应用会再提到,这里就不展开了!\n\n```\n// 开始事务,对应 MySQL begin 语句\nconnection.beginTransaction();\n\n// 事务提交,对应 MySQL commit 语句\nconnection.commit();\n\n// 事务回滚,对应 MySQL rollback 语句\nconnection.rollback();\n```\n\n## 20220218更新\n\n为了保留在这个项目中我使用`mysql`思路的一个转变过程,前面的 mysql 调用过程,我还是按照最初的想法展开介绍的,关键的也就是这么几点。\n\n1. BaseController 统一获取 mysql pool 的 connection 对象,并挂载到 req 对象上,供后面的业务使用。\n2. 业务 Controller 与 mysql 交互时,只需要从 req 对象中取得 connection,通过 connection.query 去执行 sql 语句。\n3. 业务 Controller 执行完 sql 语句后,主动 release 释放掉 connection。\n4. 事务场景中,事务处理完毕后,统一 release 释放掉 connection,而不是每个 query 都自行释放 connection。\n\n这样的设计,虽然省去了在具体业务 Controller 执行`getConnection`(少一层回调写法),但是在`connection.release()`的把控上还存在漏洞,一旦业务调用方忘记调用`release()`,就有可能造成服务不可用。而且有的业务不需要与 mysql 交互,也必须要记得 `release()`,虽然可以用一些配置字段去规避,也并不能从根本上解决问题!\n\n所以我的修改方案是:\n\n1. 总体的原则是**高内聚,低耦合**。\n2. 封装 mysql 的查询过程,把 getConnection, query, release 等几个关键行为都放在封装的代码中控制,对外只暴露一些封装好的方法,这样就不用担心调用方忘记某些关键操作(比如`release()`)。\n3. 关键 API Promise 化,这样在一些复杂的异步过程中可以做到事半功倍,特别是涉及事务处理的时候!\n\n核心代码见[db.js](https://github.com/cumt-robin/express-blog-backend/blob/main/utils/db.js)\n\n# 响应返回体\n\n响应返回体的数据结构是需要前后端进行约定的,只有约定好规范,双方才能紧密有序地配合起来。通常来说,会涉及到错误码,信息,数据等字段。\n\n其中错误码`code`,信息`message`两个字段应该是通用的。数据部分`data`则随业务的需要,可能会有多种情况,比如数组结构,对象结构,或者是普通数据类型。\n\n```javascript\n{\n code: \"0\",\n message: \"查询成功\",\n data: {\n id: 1,\n name: \'xxx\'\n }\n}\n```\n\n# 错误码\n\n**错误码**是后端规范中必不可少的部分。错误码的设计是为了快速定位问题,也为一些业务监控系统提供了分析和统计依据。\n\n每个程序员会有自己的一些编码风格,在错误码这块,我是通过语义化的属性名去定位到错误码的。通常,一个错误码会配对一条错误信息,也就是下面的`msg`字段。\n\n```javascript\nmodule.exports = {\n DB: {\n CONNECT_EXCEPTION: {\n code: \"-1\",\n msg: \"数据库连接异常\"\n }\n },\n AUTH: {\n UNAUTHORIZED: {\n code: \"000001\",\n msg: \"对不起,您还未获得授权\"\n },\n AUTHORIZE_EXPIRED: {\n code: \"000002\",\n msg: \"授权已过期\"\n },\n FORBIDDEN: {\n code: \"000003\",\n msg: \"抱歉,您没有权限访问该内容\"\n }\n },\n}\n```\n\n错误码的设计还有一个好处,就是方便做**映射**。\n\n什么意思呢?后端返回错误码`-1`,并且通过`msg`字段告诉前端错误信息是**数据库连接异常**。但是,前端到底要不要反馈用户这么直接粗暴的信息呢?我想,有时候是不需要的,而是**通过一条委婉的提示来安抚一下用户情绪**。\n\n比如,\n\n![](https://qncdn.wbjiang.cn/%E4%BC%98%E9%9B%85%E7%9A%84%E9%94%99%E8%AF%AF%E6%8F%90%E7%A4%BA.png)\n\n所以,有了错误码,前端就可以收放自如,在错误提示上有更多发挥的余地,而不是直白地把后端反馈的错误信息直接暴露给用户。\n\n简单的一个映射可以是:\n\n```javascript\n// ERR_MSG\n{\n \"-1\": \"系统开了个小差,请稍后重试!\",\n}\n```\n\n那么`message`的展示逻辑就可以是:\n\n```javascript\nmessage.error(ERR_MSG[res.code])\n```\n\n# Web安全\n\n主要是考虑几个方面,XSS,CSRF,响应头。\n\nXSS,指的是 Cross-Site-Scripting 跨站脚本攻击。出现 XSS 漏洞的主要场景是用户输入,比如评论,富文本等信息,如果不加以校验,就可能会被植入恶意代码,造成数据和财产损失!\n\n针对 XSS 的校验不能光靠客户端,服务端也必须进行校验。我这里用的是`xss@1.0.9`。\n\n```\nnpm install --save xss\n```\n\n`xss`默认会处理掉常见的 XSS 风险,使用起来也非常简单。比如,在新增评论的接口处,我们可以对参数这样处理:\n\n```javascript\nconst xss = require(\"xss\");\nrouter.post(\'/add\', function (req, res, next) {\n const params = Object.assign(req.body, {\n create_time: new Date(),\n });\n // XSS防护\n if (params.content) {\n params.content = xss(params.content)\n }\n}\n```\n\n虽然我目前还没有用富文本承载评论内容,但是还是先预备一下,万一哪天想用富文本了呢!\n\n至于 CSRF(跨站请求伪造)攻击,常见的漏洞来源就是基于 Cookie 的身份验证,因为 Cookie 会在发 HTTP 请求的时候自动带上,这样一来攻击者就有了可乘之机,通过脚本注入,或者一些引诱点击,让你不知不觉就上了套,发出了意料之外的请求。\n\n不过,浏览器也是在不断完善 Cookie 安全这块,比如 Chrome 80 版本默认启用的 SameSite=Lax,也防范了很多 CSRF 的攻击场景。\n\n为了安全起见,在 Set-Cookie 时,最好带上这些属性。\n\n```\nSet-Cookie: token=74afes7a8; HttpOnly; Secure; SameSite=Lax;\n```\n\n为了防止 CSRF 攻击,还可以采用 csrf-token 方式,或者采用 JWT 认证,共同点都是避开基于 Cookie 的身份/口令认证方式。\n\n另外,设置一些必要的响应头对于 Web 安全也至关重要!\n\nExpress 推荐我们直接用上`helmet`。\n\n> Helmet 通过设置各种 HTTP 请求头,提升 Express 应用的安全性。它不是 Web 安全的银弹,但的确有所帮助!\n\n安装`helmet`:\n\n```\nnpm install --save helmet\n```\n\n使用起来也很简单,因为它就是一个中间件。\n\n```javascript\napp.use(helmet());\n```\n\n![](https://qncdn.wbjiang.cn/helmet%E9%BB%98%E8%AE%A4%E5%B8%A6%E4%B8%8A%E4%B8%80%E4%BA%9B%E5%93%8D%E5%BA%94%E5%A4%B4.png)\n\n# 环境变量/配置\n\n由于后端配置文件中一般会出现一些私密性的配置,比如数据库配置,服务器配置,这些都不适合在开源项目中直接出现。所以,在[本项目](https://github.com/cumt-robin/express-blog-backend)中,我只给出了`example`示例,大家按照说明给出自己的配置文件即可。\n\n- 通用配置:config/env.example.js\n- 开发环境配置:config/dev.env.example.js\n- 生产环境配置:config/prod.env.example.js\n- PM2 deploy 配置:deploy.config.example.js\n\n数据库、邮箱配置,以及其他的参数配置,建议是给开发环境和生产环境单独配置,避免本地开发时直接影响到生产环境。\n\n所以,我们需要设置环境标识,并且根据环境标识来引用对应的参数配置。\n\n环境标识我们都不陌生了,它就是`process.env.NODE_ENV`。由于项目中用到了`pm2`,所以我是通过`pm2`来配置`NODE_ENV`的。\n\n```\nenv: {\n NODE_ENV: \"development\",\n PORT: 8002,\n},\nenv_production: {\n NODE_ENV: \'production\',\n PORT: 8002,\n},\n```\n\n所以,我们只要根据`NODE_ENV`来判断开发环境或生产环境,然后加载对应的参数配置即可。逻辑非常简单!\n\n```\n// 配置入口文件,根据环境标识导出配置\nconst baseEnv = require(\"./env\")\nconst devEnv = require(\"./dev.env\")\nconst prodEnv = require(\"./prod.env\")\n\nmodule.exports = process.env.NODE_ENV === \'production\' ? {\n ...baseEnv,\n ...prodEnv\n} : {\n ...baseEnv,\n ...devEnv\n}\n```\n\n# 小结\n\n本文是**Vue3+TS+Node打造个人博客(后端架构篇)**,从一个不太专业的视角来切入后端,主要介绍了我在为博客系统设计后端时的一些主要思路,诸多细节不便展开,可以打开[源码](https://github.com/cumt-robin/express-blog-backend)了解。\n\n有了这次全栈开发的经验,大大提高了我对前后端全链路的理解程度,这之后和后端开发们聊天也更有话题可聊了,有时候还能帮后端捋捋思路、一起排查下问题。总之非常奈斯!\n\n但是,要把后端做完善还有很多的路要走,看看 Java 那么多中间件就知道了,道阻且长,行则将至,加油吧!\n\n![](https://qncdn.wbjiang.cn/%E5%A5%A5%E9%87%8C%E7%BB%99.jpg)\n\n# 系列文章\n\n**Vue3+TS+Node打造个人博客**系列文章入口可点击下方链接,持续更新,欢迎阅读!点赞关注不迷路!😍\n\n- [Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)\n', '2022-03-09 09:25:04', '2024-11-11 15:44:15', 1, 770, 0, '讲讲博客后端NodeJS的关键设计思路', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E5%85%A8%E7%B3%BB%E5%88%97_node.png', 0, 0);
-INSERT INTO `article` VALUES (235, '服务器拒绝了我的ssh免密登录', '> 众里寻他千百度,蓦然回首,答案就在眼皮子底下......\n\n正如标题所述,我遇到的问题是服务器拒绝了我的ssh免密登录,具体情况是我之前已经配置好了ssh免密登录,但是最近突发 PC ssh 登录云服务器报错,接连好些天都没找到原因。\n\nssh 免密码登录本身不是一个复杂的问题,百度 / google 上面随便都找得到教程。关键点在于:\n\n1. 基于 RSA 密钥对保障通信的安全。\n2. 将公钥传递到目标服务器的 `~/.ssh/authorized_keys` 中。\n\n```shell\n// 生成密钥\nssh-keygen -t rsa -b 4096 -C \"your comment\"\n// 将公钥发送到目标服务器\nssh-copy-id -i ~/.ssh/id_rsa.pub user@host\n// 免密登录\nssh user@host\n```\n\n我自己的一台个人服务器原本也配置好了 authorized_keys,免密登录一直用得挺好,在PC本地,remote CI/CD 中一直跑得通。\n\n然而,最近我不知道在服务器上调整了什么,或者是我的 PC 发生了什么升级,不记得了,反正现象就是在 git bash 使用 ssh 免密登录上不去了,一直提示 Permission denied (publickey) 之类的报错信息,但是在 xshell 或者 CI/CD 中都是正常的。\n\n```shell\n$ ssh txcentos\nusername@xxx.xx.xx.xx: Permission denied (publickey).\n```\n\n网上自然有各种类似问题,解决方案诸如修改 .ssh 目录权限,修改 sshd_config 配置等,或者是说你的密钥不对(然而重新生成了也一样,emm...)。\n\n- 调整了权限,发现不是权限的问题\n\n```shell\nchmod 700 ~/.ssh\nchmod 600 .ssh/authorized_keys\n```\n\n- 调整了 sshd_config 的几个关键配置,发现也不是这个问题。\n\n```\nPermitRootLogin yes\nPubkeyAuthentication yes\nPasswordAuthentication no\n\n// 调整之后重启服务\nsystemctl restart sshd.service\n// 或者\nservice sshd restart\n```\n\n其实我心里很清楚,我的问题可能不是这些情况,即使从报错信息上看还是差不多的。我抱着试试的态度,改了一遍又一遍,还是不太行,要么是提示 denied,要么是 ssh 登录时让我输入密码(这还怎么免密),有点崩溃。\n\n最终决定从 ssh 命令上 debug 看看报错信息(这是我之前忽略的,应该从这里开始查的),\n\n```\n$ ssh txcentos -v\n```\n\n找到了这么一个关键信息。\n\n\n```\ndebug1: send_pubkey_test: no mutual signature algorithm\ndebug1: No more authentication methods to try.\n```\n\n顺着`no mutual signature algorithm`这个信息查到了一些资料,由于 OpenSSH 从 8.8 版本由于安全原因开始弃用了 rsa 加密的密钥,需要在 .ssh 的 config 配置中加入这么一行:\n\n```\nPubkeyAcceptedKeyTypes +ssh-rsa\n```\n\n经测试真的管用,免密登录成功!\n\n```shell\n$ ssh xxx\nLast login: Tue Sep 6 xx:xx:47 2022 from xxx.xx.xxx.xxx\n```\n\n附上参考的博客链接 https://blog.csdn.net/q274488181/article/details/121673370\n\n同时我也检查了 PC 的 openssh 版本,确实是高于 8.8 版本的。\n\n```shell\n$ ssh -V\nOpenSSH_9.0p1, OpenSSL 1.1.1o 3 May 2022\n```\n\n接着我还是去查阅 openssh 8.8 的发行日志核实了一下,确实有关于 security 的 incompatible changes,具体与 RSA/SHA-256/512 和 RSA/SHA1 有关。\n\n详细资料可以参考 https://www.openssh.com/txt/release-8.8\n\n> 总结下来就是,本次遇到问题时,我盲目自信认为可以凭自己之前的一些经验,找之前用过的一些方法去修改测试,浪费了很多时间,同时也打击了自己的信心。正确的做法是,对于一些命令行接口,可以优先确认是否能找到 debug 或者日志信息,优先从这些信息入手查问题,就比如这次的 ssh 命令,实际上是提供了 -v 选项,也就是 verbose,能看到比较详细的日志,往往会有事半功倍之效。\n\n', '2022-09-07 09:45:03', '2024-09-24 20:57:38', 1, 68, 0, '我遇到的问题是服务器拒绝了我的ssh免密登录,具体情况是我之前已经配置好了ssh免密登录,但是最近突发 PC ssh 登录云服务器报错,接连好些天都没找到原因。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90%2Fssh%E5%85%8D%E5%AF%86%E7%99%BB%E5%BD%95%E8%A2%AB%E6%8B%92.jpg', 0, 0);
-INSERT INTO `article` VALUES (236, '基于Vite打造业务组件库(开篇介绍)', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏下篇文章传送门:[组件库技术选型和开发环境搭建](https://juejin.cn/post/7153432538046791687)\n\n# 专栏介绍\n\n大家好,我是 [Tusi](https://juejin.cn/user/2752832847753085),最近在写 **基于Vite打造业务组件库** 相关专栏,欢迎读者们关注 [我的专栏](https://juejin.cn/column/7140103979697963045) 一起交流学习。\n\n我的专栏聚焦于如何搭建一个 **业务组件库**,并不会讲述怎么从 0 到 1 搭建一个类似于 **AntDesign** 或者 **ElementPlus** 这样的基础组件库,因为各个社区中这样的课程或者专栏也不少,大家从这些专栏中可以更详细地了解怎么造一些基础组件,进而掌握建设组件库的方法。\n\n同时也是因为我认为 **没必要重复造基础轮子**,在我看来,你很难做出一个`Button`组件,它会比 AntDesign 的`Button`组件的设计更完美、更好用,这并不是意味着不可能做到,只是这种投入跟最后产出的价值是不是能匹配,用圈内的话术说就是,“你做的事情,他的价值点在哪里?你是否做出了壁垒,形成了核心竞争力?你做的事情,和 AntDesign 团队的差异化在哪里?”\n\n> 🐶狗头保命\n\n没错,站在公司或者团队角度,Leader 重点关心你做的东西是不是真的对业务产生了价值。当然,这是我站在个人冲 KPI/OKR 的角度来思考这个事情的,这种造重复性基础轮子的产出,基本上很难得到 Leader 的认可,对 Leader 来说,他想的很可能是,“你为什么不直接用 AntDesign / Lodash / Dayjs 等等呢?为什么要做个差不多的东西,但是质量还没别人高?”\n\n**换个角度**,其实对个人来说,从头到尾搞一次基础设施建设,是会有很大提升和收获的。如果你想要深入某个领域,不 **彻底钻进去一探究竟** 是很难发现那些开拓者在落地的过程中都遇到了什么困难(踩了什么坑)以及解决这些难点都走了什么样的路子(也就是技术方案的演进路线)。\n\n那么什么是有价值的产出呢?我认为有这么两个方向值得我们探索。\n\n* 第一,还是造轮子,**造新的并且能落地的轮子**,新轮子是有价值的,因为它不具备重复性,暂时也没有替代物,甚至它的出现可能影响技术社区的发展。\n* 第二,**创造附加价值**。在现有轮子的基础上,塑造一个更强大的工具。我建议大家可以从一些门槛比较低的方向入手,比如基于现有的工具链,整合出一个贴近实际业务的 CLI / 可视化工具,用于提升开发效率;或者如本专栏主题一般,基于已有的三方基础组件库打造一个服务于业务开发的专用组件库。\n\n回到正题,既然本专栏讲的内容不是从 0 到 1 搭建基础组件库,那是不是意味着学习专栏过后并不能掌握搭建一个类似 AntDesign 这样大而全的组件库的核心方法呢?\n\n**答案显然是否定的**。组件库的搭建理念都是类似的,是殊途同归的,最终从输出的产物形式上看都是要满足诸如 **按需加载**、**兼容各个模块规范**、**Typescript类型支持**、**Unplugin** 这类的核心诉求,而组件的内容丰富度是其次考虑的,提供`Button`, `Icon`, `Table`, `Form` 这些组件与否,都不影响这个组件库的核心架构,即便你后续想要自行实现这些基础组件,随时都可以开始。\n\n最后,在我看来,一个采纳了业务组件库的应用,它的整体架构可能会呈现出这样一种形态:博采众长,提升效率的同时也不会束缚住开发者的手脚,不会限制开发者局限于使用某一个特定的 UI 框架,这在按需加载的支持下是可行的。\n\n![image.png](https://camo.githubusercontent.com/ed45368bd88d7c0226841d6900aa0463d1be3274d37e7f73b1a41fd293fdce93/68747470733a2f2f70392d6a75656a696e2e62797465696d672e636f6d2f746f732d636e2d692d6b3375316662706663702f61383663643563636236616434656133626162646132626433656430396437397e74706c762d6b3375316662706663702d77617465726d61726b2e696d616765233f773d3539303226683d3333383826733d36323335373826653d706e6726613d3126623d666666666666)\n\n在本专栏中,我会基于自己在多个项目中摸索总结得到的实战经验,带着读者一起探索组件库从设计到开发,再到自动化发布的全链路流水线。本专栏大致会围绕 **设计思路**、**组件开发实战**、**构建打包流程**、**发布流程**,**文档建设**等方面入手介绍,把组件库研发链路的一些**关键节点**讲清楚,**帮助读者掌握基于 Vite 构建现代组件库的核心方法**。\n\n# 前言\n\n**UI组件库** 对前端工程师而言,早已不是一个陌生的概念,BootStrap, Material, AntDesign, Element, Vant, iView,以及最近某些大厂开源出来的 AcroDesign, TDesign, Semi Design 等,这些名词对我们来说都应该不算陌生,甚至可以说日常工作中我们都至少需要与其中的一员打交道(大佬略过)。看多了组件库,自己动手写一两个小组件自然也不是什么难事,毫不夸张地说,**95%以上**的前端工程师都敢拍着自己的胸脯说:“我会开发组件!”\n\n我们认同组件开发简单易上手,但同时也不得不承认这一切都建立在不断繁荣发展的前端框架和工具链生态圈之上!\n\n伴随着 **前端框架**、**构建工具**、**UI/UX设计理念** 的更新换代,UI组件库的发展也是日新月异!确实,在现代前端框架的加持下,开发一个组件的门槛很低,开发者只需要使用框架提供的 **声明式** 语法,约定好 **输入** 和 **输出**,将组件内部 **逻辑** 组织好,一个组件就有了雏形。\n\n![父子组件模型.png](https://camo.githubusercontent.com/b68373abb8ea089a29cae477d765ad6c0c71a58381a9e68870560391533278c3/68747470733a2f2f70332d6a75656a696e2e62797465696d672e636f6d2f746f732d636e2d692d6b3375316662706663702f31633638386366316337666534316331383861633538663966323939613431397e74706c762d6b3375316662706663702d77617465726d61726b2e696d616765233f773d36313026683d35323026733d313738373726653d706e6726623d666666666666)\n\n然而门槛低不代表开发组件就很简单,开发优质的组件实际上非常考验开发者的综合设计能力。\n\n# 组件设计基本原则\n\n那么开发一个组件到底要考虑什么呢?结合我自己的实战开发经验,我想大概会有这样几个方面:\n\n## 清晰明了的输入输出\n\n输入输出是由组件提供的功能决定的,它们就像是一个组件的 **用户使用手册**,决定了用户对组件产品的第一印象。开发者应该提供语义化的、简单易懂的 API,降低使用的门槛和心智负担!\n\n## 高内聚,低耦合\n\n高内聚显得很好理解,在编写组件时,我们的出发点都是把逻辑聚合到组件中,基本上是竭尽所能做到内聚,但是这也非常考验个人的逻辑抽象能力。\n\n低耦合,则体现在不要与外部产生太多的联系。用函数思维来说就是:组件尽可能是一个纯函数,不要对外部产生副作用。\n\n当你在提供通用组件时,应当尽可能不显式依赖外部状态,比如依赖 `Store`, `Context` 之类的 App 全局状态。如果实在有需要,可以尝试通过 props 或者 inject 之类的渠道注入到组件中。类似地,也不要显式依赖 `Storage`, `Cookie` 之类的浏览器存储,因为这很容易产生冲突,对 SSR 也不友好,在必要时,稍微靠谱的方法是加上命名空间。\n\n```javascript\n// bad case\nconst store = useStore(key)\n// 依赖了全局状态\nconst innerState = computed(() => store.state.xxxModule.xxxState)\n```\n\n同时,不要在组件中破坏外部状态。举个栗子,假设你为了实现某个功能而扩展出一个原型方法,直接把 Array 或者 Object 的原型给修改了,这就属于破坏外部状态了,也就是对外部产生了 **副作用**。\n\n```javascript\n// bad case\n// 为了解决这个问题,我决定给 Array 原型加一个神奇的方法\nArray.prototype.blingbling = function() {\n // balabala 一堆代码\n console.log(\"反正就是牛逼地解决了这个问题\")\n}\n```\n\n要知道,考虑到`Object.defineProperty`无法处理数组场景,即便 Vue2 为了实现数组的响应式特性,也没有直接修改`Array.prototype`上的原型方法,而是采用了一个巧妙的方式处理,具体可以看 Vue2 源码里的`core/observer/index.js`中的 **Observer** 实现以及`core/observer/array.js`中针对 Array 的特殊处理。\n\n## 可定制,可扩展\n\n一个组件要想支撑大量的业务场景,必然应该是可定制和可扩展的。我们在开发一个组件时,会预设一种最常见的场景,这种预设一般是基于大量的实际案例总结出的经验,大概意思就是:当大家想到使用这个组件时,大部分人能想到的模样就是我预设的这个样子,这就达到了一种拿来即用的效果,大部分人都基本满意。\n\n但是,满足了 80% 的使用需求,也不代表全部,你必须足够包容。用函数思维来看,就是组件跟随用户输入的条件的变化而变化。\n\n![image.png](https://camo.githubusercontent.com/3c7fcab2d6d509a4240f4efef1ba673afc38f339c2ab3a3cdbaa053fdb46c6b7/68747470733a2f2f70332d6a75656a696e2e62797465696d672e636f6d2f746f732d636e2d692d6b3375316662706663702f64643736663531363364666234636338396638366638663237646666613738637e74706c762d6b3375316662706663702d77617465726d61726b2e696d616765233f773d32393326683d393226733d3430393726653d706e6726623d343962383764)\n\n反映到组件上,最基础的做法是:可以通过`Props`接收用户的条件,然后在模板或者逻辑中,根据用户的条件呈现出不同的效果。\n\n甚至,我们可以将部分内容的渲染权限完全交给用户,在 React 中,一切皆`Props`,自定义渲染也只是通过`Props`传递过来一段渲染函数。而在 Vue 中,具体的实现可以分为插槽、作用域插槽等。使用形式并不重要,背后的原理都是相通的。\n\n## 友好的告警提示\n\n告警信息在框架源码中会很常见。当你的使用方式与框架的指导性意见不一致时,框架内部就会通过一段判断逻辑给出一些告警信息,方便你排查问题出现的原因。\n\n```javascript\n// vue源码中关于 mount 用法的告警\nif (__DEV__ && (rootContainer as any).__vue_app__) {\n warn(\n `There is already an app instance mounted on the host container.\\n` +\n ` If you want to mount another app on the same host container,` +\n ` you need to unmount the previous app by calling \\`app.unmount()\\` first.`\n )\n}\n```\n\n在组件设计中也可以采纳这种做法,如果用户错误地使用了组件的某个属性,组件内部就应该给出友好的告警提示。站在 TypeScript 的角度来看,也能通过类型做一些约束,但是如果 TypeScript 的覆盖率不是很高,也很难考虑到所有场景,同时也没法做到兼顾运行时,所以在代码中留下一些必要的告警提示还是有必要的。\n\n同时,我们可以注意到,这些告警信息都将环境信息考虑在内,仅仅会在开发环境中出现。在 Tree Shaking 时,这些不会抵达的条件分支代码,会被判定为 Dead Code 而被裁剪掉。\n\n我们之所以费这么大劲做这种告警信息,就是为了降低用户使用时的心智负担,用户很大程度上可以从告警信息中排查出问题起因。有了这些告警信息,就不用麻烦用户通过 debug 源码得以解决问题,毕竟每个人的精力都有限,阅读源码是最后的退路,非到万不得已就没必要去读源码。\n\n## 组件文档\n\n即便我们在前面做了这么多努力,也不可能完全解决用户的疑问和焦虑。此时,一份健全的使用文档则是对用户最大的安慰。文档建设本身是一项巨大的脏活累活,如何高效又全面地把文档做好,非常考验开发者的工程能力、技巧以及耐心。\n\n## 完备的TypeScript类型支持\n\n一个没有类型支持的组件,确实很难用。React 由于与 JSX 结合紧密,本身对 TypeScript 的支持度就不错,再结合 IDE 内置的 TypeScript 类型推导能力,即便我们不写太多类型声明,得到的开发体验也不会太差。\n\nVue 在这一方面相对处于劣势,SFC(单文件组件)本身就是一个新的 DSL(领域特定语言),默认情况下与 JSX 相比,其 TypeScript 支持度自然处于下风,特别在泛型组件等场景下显得更加乏力。但是,基于 `@vue/compiler-sfc` 官方提供的 parse 和 compile 能力,再配合`tsc`或者`ts-morph`之类的工具,我们也能给 SFC 提供不错的类型支持。\n\n## 配套的工具链\n\n为了更大程度提高 DX(开发者体验),组件开发者还可以根据实际情况提供一些配套工具,目的可以是提供代码的自动补全/智能提示之类的能力;也可以是提供脚手架工具,以便快速搭建起开发环境。形式上,可以选择 CLI,或者是 IDE 插件等。总之在这方面还有很大的想象空间等着大家去发掘。\n\n# 总结\n\n这是一篇 Vite 业务组件库专栏的开篇介绍,本文首先阐述了我在做专栏选题时的初衷,也简述了本专栏接下来写作的一些着重发力的方向。接着我分享了自己在做组件开发过程中总结的一些组件设计原则和经验,希望能给读者带来一些启发和帮助!如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045),接下来可以一同探讨和交流组件库开发过程中遇到的问题。\n\n> 专栏下篇文章传送门:[组件库技术选型和开发环境搭建](https://juejin.cn/post/7153432538046791687)\n>\n> 技术交流&闲聊:[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg)\n', '2022-10-10 14:41:55', '2024-07-25 03:55:34', 1, 65, 0, '大家好,我是 Tusi,最近在写基于Vite打造业务组件库相关专栏,欢迎读者们关注我的专栏一起交流学习。 我的专栏聚焦于如何搭建一个业务组件库,帮助读者掌握基于Vite构建现代组件库的核心方法。', 'https://qncdn.wbjiang.cn/%E4%B8%93%E6%A0%8F1/logo_3x.png', 0, 0);
-INSERT INTO `article` VALUES (237, 'Vue3和@types/node的类型不兼容问题', '最近有个新项目启动,主体内容与先前做的一个项目相似度很高,于是我准备拿这个旧项目作为模板简单改改,就可以启动新项目的开发了。\n\n先说说现状,为了更好地拥抱云原生,部门内部的构建方案进行过升级,目前采用的是 Buildpacks 构建项目镜像,并且相关的服务器架构也做了调整,打镜像的 Runner 是部署在内网的,没有外网通道,也就是说**安装 npm 依赖时必须从企业私有的 Nexus NPM 代理走**。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/buildpacks.png)\n\n带来的问题就是:这个旧项目启动时还是采纳的旧版镜像构建方案,并不存在新版镜像构建方案带来的内网限制。而现在要基于这个旧项目开发新项目,对接的相关环境都是采纳的新方案,如果不将 npm registry 调整为私有的 Nexus NPM 代理,构建镜像这一步就没法走下去。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%86%85%E7%BD%91%E8%AE%BF%E9%97%AEnexus.png)\n\n所以我就必须得先把 npm registry 调整一下,重新生成 lock 文件。\n\n`.npmrc`修改成如下(已去掉敏感信息):\n\n```\nregistry=https://nexus.xxx.tech:8443/repository/npm-group/\nalways-auth=true\n_auth=xxGxxxxxxxxxxyQ0xxlGxmc=\n```\n\n`.yarnrc`也修改一下:\n\n```\nregistry \"https://nexus.xxx.tech:8443/repository/npm-group/\"\n```\n\n> npm-group 包含了 npm-proxy 和 npm-hosted,从这里既可以下载通过 npm-proxy 代理过来的公开发行的 npm 包,也可以下载通过 npm-hosted 维护的企业内部私有的 npm 包。\n\n这个项目用的是 Yarn,所以我接着删掉 `yarn.lock`,重新通过`yarn`安装依赖后生成新的 lock 文件。\n\n> 此时最好参照旧的 lock 文件,将关键依赖的版本号先锁住,再重新生成新的 lock 文件,防止在 `~`, `^` 这种约束不强的规则下,最终安装的依赖版本号发生变化的情况。\n\n生成完 lock 文件后,检查一下 dev 和 build 等场景,是不是基本上没什么问题。不出意外的话,就要出意外了!\n\n很快,我就在一段 tsx 代码上遇到了这么一个报错:\n\n```\nType \'() => void\' is not assignable to type \'MouseEvent\'.ts(2322)\nruntime-dom.d.ts(1401, 3): The expected type comes from property \'onClick\' which is declared here on type \'IntrinsicAttributes & AntdIconProps\'\n```\n\n这个报错是从 `runtime-dom.d.ts` 中抛出来的,我第一反应就是看看`@vue/runtime-dom`这个包的版本是不是变了。\n\n查了一下发现,`@vue/runtime-dom`确实是变了,从`3.2.33`变成了`3.2.40`,\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/runtime_dom%E7%89%88%E6%9C%AC%E5%8F%98%E4%BA%86.png)\n\n而这个变化是由于`vue`的版本号变化引起的,这是因为我的`vue`版本约束是`~3.2.29`,重新生成 lock 文件时会检查有没有较新的版本。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8F%98%E5%8C%96%E6%98%AFvue%E5%BC%95%E5%85%A5%E7%9A%84.png)\n\n好,那我就锁`vue`的版本号,就定为原来生成的`3.2.33`版本。\n\n```\n\"vue\": \"3.2.33\",\n```\n\n重新安装依赖,期待能解决问题。\n\n![](https://qncdn.wbjiang.cn/%E5%A5%A5%E9%87%8C%E7%BB%99.jpg)\n\n但是这并没有解决问题,报错依然存在。于是我尝试去锁可能影响这个问题的一些依赖的版本号,包括`typescript`, `@typescript-eslint/eslint-plugin`, `@vue/eslint-config-typescript` 等等,最终都没有解决这个问题,搞了个把小时,emo了...\n\n于是,我就尝试找问题源`runtime-dom.d.ts`有没有什么问题,先仔细观察下报错信息,\n\n```\nThe expected type comes from property \'onClick\' which is declared here ...\n```\n\n既然你说 `onClick` 未声明,那我把 `onClick` 设置为可选的行不行?\n\n```typescript\n// node_modules/@vue/runtime-dom/dist/runtime-dom.d.ts\n\nexport interface Events {\n // ...\n onClick?: MouseEvent;\n // ...\n}\n```\n\n改了之后这个报错还真的消失了!!!\n\n但是直接改 node_modules 里的代码肯定是不行的,离开自己的电脑,在其他机器安装依赖时就没法同步到这个修改。\n\n而借助 **patch-package** 可以实现**修改 node_modules 中的代码后也能让其他人安装依赖时同步到修改信息**,但是我还不想这么做。那么能不能在项目中加一个`d.ts`,把这个`interface Events`修改一下呢?\n\n考虑到`interface`有合并能力,我先尝试在`global.d.ts`中加同名的`interface Events`,\n\n```typescript\ndeclare interface Events {\n onClick?: MouseEvent;\n}\n```\n\n但是发现也并没有作用,因为`runtime-dom.d.ts`中用了`export interface Events`,这意味着`Events`接口是模块下的而不是全局的,我这样直接加在全局是合并不了的,那有没有办法合并模块下的`interface`呢?\n\n我简单尝试了一下`declare`一个同名的`module`,然后加入一个`interface Events`,也不行,这样就直接覆盖了`node_modules`里的类型声明。\n\n最后实在没办法了,我想到:既然覆盖了,那就全部覆盖吧!我干脆把`node_modules/@vue/runtime-dom/dist/runtime-dom.d.ts`整个文件抄了出来,就改了`onClick?: MouseEvent;`这一行,效果确实可以,这个问题算是临时解决了。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8A%84runtime-dom.png)\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8A%84runtime-dom_2.png)\n\n福无双至,祸不单行,我就知道事情没这么简单!\n\n![](https://qncdn.wbjiang.cn/%E8%A1%A8%E6%83%85%E5%8C%85/%E6%8A%91%E9%83%81.webp)\n\n继续往下测试,我又遇到一个报错:\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/div_onclick%E6%8A%A5%E9%94%99.png)\n\n这真的是搞心态啊,`@ant-design/icons-vue`不报错了,`div`又报错了。\n\n收拾好心情,发现 VSCode 右下角出现了一个提示信息。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/vscode%E6%8F%90%E7%A4%BA.png)\n\n打开一看,终于找到了问题原因,这是 Volar 给出的提示。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/volar%E6%8F%90%E7%A4%BA.png)\n\n原来是`@types/node@18.8.4`版本与`vue@3.2.40`版本不兼容,会造成模板中的 DOM event type 出错,解决的方法有两个:\n\n1. 降低`@types/node`版本至`18.8.0`。\n\n2. 升级 Vue 的版本号至`3.2.41`,后面还备注了(如果已发行)。\n\n于是,我去看了一下 Vue 的最新版本,发现 3.2.41 还没有发布,可能也正在解决这个问题吧!\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/vue%E6%9C%80%E6%96%B0%E7%89%88%E6%9C%AC.png)\n\n那就选择降低`@types/node`的版本号吧,最终解决了这个问题,前面改的那个`interface`相关的代码也可以删了。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/issue%E8%AE%A8%E8%AE%BA.png)\n\nVolar 仓库中相关 issue 还是 2 天前提出的,说明这个问题还是蛮新的。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/issue%E8%A7%A3%E5%86%B3%E6%96%B9%E6%B3%95.png)\n\n> 折腾了好久......为啥 Volar 不早点提示我呢?难道是因为我第一个报错是在`.tsx`中?估计是...', '2022-10-13 21:17:23', '2024-08-10 06:52:03', 1, 100, 0, 'Vue3er 最近可能会遇到的 TypeScript 问题。最近有个新项目启动,主体内容与先前做的一个项目相似度很高,于是我准备拿这个旧项目作为模板简单改改,就可以启动新项目的开发了,没想到问题来了。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/issue%E8%AE%A8%E8%AE%BA.png', 0, 0);
-INSERT INTO `article` VALUES (238, '组件库技术选型和开发环境搭建', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏上篇文章传送门:[基于Vite打造业务组件库(开篇介绍)](https://juejin.cn/post/7146022961894391821)\n>\n> 专栏下篇文章传送门:[实战案例:初探工程配置 & 图标组件热身](https://juejin.cn/post/7160549169566842893)\n>\n> 本节涉及的内容源码可在[vue-pro-components c1 分支](https://github.com/cumt-robin/vue-pro-components/tree/c1)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045) 专栏第 2 篇文章【组件库技术选型和开发环境搭建】,为了让读者们沉浸式体验组件库开发,我将会手把手带着读者们搭建起一个组件库的 monorepo 开发环境,相关源码可在 [vue-pro-components](https://github.com/cumt-robin/vue-pro-components) 仓库中取得。\n\n# 为什么选择 monorepo?\n\nmonorepo 这个词大家或多或少都听过,甚至已经在项目中应用过,问题来了,你能给 monorepo 下个定义吗?\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d177bd4d254a408b96391993dbf43518~tplv-k3u1fbpfcp-watermark.image)\n\n别慌,我也不会,我们来看看[维基百科给出的定义](https://en.wikipedia.org/wiki/Monorepo)。\n\n> In [version control systems](https://en.wikipedia.org/wiki/Version_control \"Version control\"), a **monorepo** (\"[mono](https://en.wiktionary.org/wiki/mono-#English \"wikt:mono-\")\" meaning \'single\' and \"repo\" being short for \'[repository](https://en.wikipedia.org/wiki/Repository_\\(version_control\\) \"Repository (version control)\")\') is a software development strategy where code for many projects is stored in the same repository.\n\n可见,monorepo 的含义就是**在一个单体仓库中管理多个项目**,这种项目管理模式在一些大型项目中已经被广泛应用,比如 [Vite](https://github.com/vitejs/vite), [Vue](https://github.com/vuejs/core), [React](https://github.com/facebook/react), [Angular](https://github.com/angular/angular), [React Native](https://github.com/facebook/react-native), [Jest](https://github.com/facebook/jest), [Pinia](https://github.com/vuejs/pinia), [Vue CLI](https://github.com/vuejs/vue-cli), [Element Plus](https://github.com/element-plus/element-plus), [Modern.js](https://github.com/modern-js-dev/modern.js), [Next.js](https://github.com/vercel/next.js) 等。如果你打开这些项目仓库,你可以发现其中一个很明显的共性:它们都采用了`packages`目录来管理子包,每个子包中都包含一个`package.json`文件,也就是说子包也是一个独立的`npm`包。\n\n进一步研究这些仓库时,我们可以发现,这些项目在支撑起整个 monorepo 体系时采用的技术方案是不一样的。\n\n有的项目简单采用了 yarn workspaces,有的则使用了 Lerna,也有的用了 pnpm,还有的用了 Changesets,再卷一点的已经用上了 Turborepo。\n\n> Changesets 和 Turborepo 不能定义为 monorepo 方案,而是 monorepo 体系中强有力的配套工具。\n\n鉴于笔者还未全面使用过以上所有方案,对于这些方案中的优缺点,无法给出客观的评价,读者们可以自行去查阅更多资料。\n\n这里简单给个参考意见,帮助不了解这块的读者先有个粗略的认识,如有错误,还请评论指出:\n\n## yarn + workspaces\n\nyarn 内置的 [workspaces](https://yarnpkg.com/features/workspaces) 特性可以让子包之间的引用变得简单(其中也用到了 symbol link),在此基础上可以衍生出更多上层的能力,Lerna 就是在此基础上发展而来的工具。workspaces 支持了 monorepo 最基础的能力,但是仅靠它也显得有点单薄,因为它没有提供包的全生命周期管理能力。\n\n> Yarn workspaces aim to make working with [monorepos](https://yarnpkg.com/advanced/lexicon#monorepository) easy, solving one of the main use cases for `yarn link` in a more declarative way. In short, they allow multiple projects to live together in the same repository AND to cross-reference each other - any modification to one\'s source code being instantly applied to the others.\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/dee9e588cd9a419fbbb7a3acbde194ca~tplv-k3u1fbpfcp-watermark.image)\n\n## Lerna\n\n[Lerna](https://lerna.js.org/) 可以解决上面说的问题,它提供了包的全生命周期管理能力,包括但不限于 **新建子包** / **删除子包** / **管理子包依赖** / **发包** 等等,并且有相关的命令行支持,能较大程度上提升 monorepo 项目开发和维护效率。除此之外,Lerna 团队还竭力提升性能和开发体验,具体见 [Why Lerna?](https://lerna.js.org/docs/introduction#why-lerna)\n\n## pnpm\n\n[pnpm](https://pnpm.io/) 从设计上就天然支持了 monorepo,同时还通过 **严格的依赖结构** / **symbol link** / **hard link** 等能力解决了 **幽灵依赖**、**依赖占用大量存储空间** 等问题。pnpm 也可以搭配 Lerna 使用。\n\n## Changesets\n\n[Changesets](https://github.com/changesets/changesets) 是 pnpm 推荐的一个致力于解决变更记录集、changelog、version 等问题的工具,据说比 Lerna Version 这块的处理更科学。它有一个**生产和消费**`.changeset`的过程,用户在一些复杂版本控制场景中有一定的**自主控制权**,因为你可以对 changeset 等内容做一定调整,自由度更高。\n\n## Turborepo\n\nTurbo,涡轮增压嘛,这就是要起飞的节奏,Turborepo 内部的核心代码是基于 **Go** 来实现的,这跟 esbuild 一样,直接是降维打击啊!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/456948441b414a128fb79d56bf66e864~tplv-k3u1fbpfcp-watermark.image)\n\n简单看了一些 [Turborepo](https://turborepo.org/) 官网的文档,可以发现 Turborepo是专注于提升构建性能的工具和平台,它在 Pipeline 编排、Output Caching、Remote Caching、Output Replaying 等方面做了很多努力,同样的事情不做第二次,这与 Lerna 现在的管理团队 Nrwl 研发的构建平台 [Nx](https://nx.dev/) 的发力方向有点相似。\n\n合理的 Pipeline 编排可以最大限度发挥 CPU 性能。缓存用好了真的是一把利刃,对于重复的工作,得到秒级甚至毫秒级的响应是真的香,这在 monorepo 项目中尤其重要,因为你不知道一个 monorepo 可能会演变成多大的工程!而且 Remote Caching 在 CI/CD 中也能发挥很大作用!\n\n为什么这些明星项目都不约而同选择了 monorepo 呢?背后的原因可能有这些:\n\n* 现代前端工程的复杂度在不断提升,使得**拆包成为了必然趋势**。\n* 但是把一个大型项目拆成多个仓库又让管理和联调变得十分困难,`npm link`之类的方案开发体验太差。\n* 目前最佳的方案还是在一个仓库维护,然后通过技术手段改善单体仓库的劣势,**既能保持子包之间的独立性,又能让包之间联调变得简单**。\n* 使得集成化的 CI/CD 变得更加便捷。\n* 更多亮点值得探索......\n\n# 技术选型\n\n在组件库的技术选型这块没有太多可说的,基本上是围绕项目的需求和自身的能力展开,按照开发过程中的实际需求引入相关的技术方案。这中间会存在主观意愿,仅供参考!\n\n* Monorepo: 我还是选择了保守一点的 Lerna,这个我相对熟悉一点,同时也是想多踩踩坑,知道坑在哪里,才能明白为什么要换更好的工具。\n* 前端框架:如专栏标题所述,我选择了个人更熟悉的 Vue3 生态。\n* 类型支持: TypeScript。\n* 基础 UI 组件库:AntDesignVue,继承了 AntDesign 的企业级设计风格,个人感觉 AntDesignVue 相对其他 Vue 生态的组件库更有质感,但是它也不是完美的。\n* 构建工具:Vite, Rollup, Gulp 以及相关插件生态。组件库建设是一个相对复杂的工程,很难依靠一种工具把所有事宜处理完毕,所以整合工具链是必要的。\n* CSS预处理器:Less。选择 Less 主要是考虑后续切换主题,这块 Less 比较好处理(纯属个人主观意见,毕竟还有不依赖 preprocessor 的)。\n* 发布流程工具:release-it。\n* CI/CD: Github Actions。这个没有强制要求,有很多选择。\n* 规范/约束之类的:ESLint, Prettier, StyleLint, husky, commitizen 等等。\n* 更多......\n\n# 开发环境搭建\n\n说太多概念也不太容易消化,我们来实操一下。\n\n## 创建 Lerna 工程\n\n首先我们需要新建并进入`vue-pro-components`工程目录,接着通过`npx lerna init`创建一个工程。\n\n```shell\n$ mkdir vue-pro-components && cd vue-pro-components\n$ npx lerna init\nlerna notice cli v4.0.0\nlerna info Initializing Git repository\nlerna info Creating package.json\nlerna info Creating lerna.json\nlerna info Creating packages directory\nlerna success Initialized Lerna files\n```\n\n可以发现 Lerna 为我们生成了一个 monorepo 项目的基本骨架:\n\n```shell\n$ tree\n.\n|-- lerna.json\n|-- package.json\n`-- packages\n```\n\n### package.json\n\n粗略看一下,`package.json`中的`private`字段设置为了`true`。\n\n```\n {\n \"name\": \"root\",\n \"private\": true,\n \"devDependencies\": {\n \"lerna\": \"^4.0.0\"\n }\n }\n```\n\n这代表什么意思呢?我们看看 npm 文档中关于 [private](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#private) 的描述。\n\n> If you set `\"private\": true` in your package.json, then npm will refuse to publish it.\n\n当`private`设置为`true`时,就代表你不需要在`npm`公开发布这个包。看到这,有的读者可能会纳闷了,“这好像有点问题吧,组件库一般是要发布的呀!”\n\n不用慌,由于我们采用的是 monorepo 架构,具体发布的组件库其实是`packages`目录下的一个子包。而整个工程的主包则是用来组织起整个大框架,不发布到`npm`也是可以理解的。\n\n同时,这也符合 [Yarn 1.X 版本的强制要求](https://classic.yarnpkg.com/en/docs/workspaces),如果需要用到`workspaces`特性,必须声明`private`为`true`。虽然 [Yarn Modern Version](https://yarnpkg.com/features/workspaces) 已经取消了这个限制,但是迟迟没有作为 Yarn 的默认安装版本,在[升级迁移](https://yarnpkg.com/getting-started/migration)这块还有不少阻力。\n\n### lena.json\n\n我们再观察一下`lerna.json`这个文件,它通过`packages`字段约定了子包都分布在哪些目录下,这里支持 glob pattern 匹配,也可以是一个 package 的 path。\n\n {\n \"packages\": [\n \"packages/*\"\n ],\n \"version\": \"0.0.0\"\n }\n\n对于`version`字段,Lerna 提供了[两种版本策略](https://lerna.js.org/docs/features/version-and-publish#versioning-strategies)供我们选择,我们应该怎么选择呢?这里先不展开说,免得大家产生太多疑问导致不必要的焦虑。\n\n工程搭建完毕后,我们先与 remote 仓库(您需要保证远程仓库存在)关联一下,方便后续提交代码。如果您已经 fork 该仓库,请将仓库地址改成您自己的。\n\n```\n git remote add origin https://github.com/cumt-robin/vue-pro-components.git\n```\n\n## 新建组件库子包\n\n有了上面的基本框架,我们可以着手新建一个组件库子包,这个包将存放组件相关源码,这里用到了`lerna create`命令。我们可以根据交互提示填上一些必要的信息,先把子包建好,一些信息可以后续再更改,不必过于纠结。\n\n```\n $ lerna create vue-pro-components\n // 在一步步提示下,先将一个npm子包搭建起来\n package name: (vue-pro-components)\n version: (0.0.0)\n description: pro components based on vue3\n keywords: components,vue3,vite,typescript,unplugin,on demand,pro\n homepage:\n license: (ISC) MIT\n entry point: (lib/vue-pro-components.js) index.js\n git repository:\n```\n\n注意,我这里用到的 package name 是 **vue-pro-components**,这个 name 也将作为我要发布到 npm 上的包名。\n\n> 也不用担心这个包名 vue-pro-components 和整个工程的目录名相同,因为主工程是不会发布到 npm 的。\n\n这里先不急着加具体内容,因为我们需要先把大的框架理清楚,继续往下看。\n\n## 新建 playground 子包\n\nplayground 翻译过来就是游乐场,这个子包可以作为我们调试组件表现的地方,这里直接选择用 Vite 初始化一个工程。\n\n我们尝试一下不使用`lerna create`命令新建 package。\n\n cd packages && yarn create vite playground --template vue-ts\n\n用 Vite 创建的这个 playground 包默认也是 private 的,playground 本来的作用就是调试或展示组件的基本效果,可以打包后作为一个 web 应用发布到公网,但是不需要发布到 npm,所以设置为 private 符合预期。\n\n## 我该用哪种 version 策略?\n\n回到上文留下的疑问,两种版本策略,我们该怎么选?\n\n1. [Fixed/Locked mode (default)](https://lerna.js.org/docs/features/version-and-publish#fixedlocked-mode-default)\n\nFixed mode 意味着`version`字段对应着具体的版本号,比如`0.1.0`。在这种模式下,各个子包的**版本号相对集中**,一般来说可以理解为同一个版本号(也有例外),Lerna 会在执行`lerna version`命令时根据用户的选择自动更新`version`字段,同时会修改发生过代码变更的子包的`package.json`中的`version`字段。\n\n我们可以来试验一下,先把代码 commit & push 到远程,这里我用了一个新分支`c1`。\n\n```shell\n// 回到根目录\ngit checkout -b c1\ngit add .\ngit commit -m \'chore: 先将代码提交到远程,方便后续测试lerna version\'\ngit push --set-upstream origin c1\n```\n\n文档中提到,如果当前 major 版本号是 0,则认为所有变更都是破坏性的,这意味着修改任何一个包中的内容,`lerna version`都会更新所有子包的版本号。我们来试试,修改其中一个子包`vue-pro-components`的内容,在`index.js`加了一行注释,然后 commit,接着使用`lerna version`更新版本号。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3cf56dc3e45e4f329834970a3409dd55~tplv-k3u1fbpfcp-watermark.image)\n\n```shell\n$ git add .\n$ git commit -m \'chore: 测试 major 版本号为 0 时修改一个包\'\n$ lerna version\nlerna notice cli v5.3.0\nlerna info current version 0.0.0\nlerna info Assuming all packages changed\n? Select a new version (currently 0.0.0) (Use arrow keys)\n> Patch (0.0.1)\n Minor (0.1.0)\n Major (1.0.0)\n Prepatch (0.0.1-alpha.0)\n Preminor (0.1.0-alpha.0)\n Premajor (1.0.0-alpha.0)\n Custom Prerelease\n Custom Version\n```\n\n当我们选择 Patch 更新后,可以看到,两个子包的版本号都变成了 0.0.1,并且`lerna.json`中的`version`也变成了`0.0.1`。\n\n```\n Changes:\n - playground: 0.0.0 => 0.0.1 (private)\n - vue-pro-components: 0.0.0 => 0.0.1\n```\n\n我们再试试加一行注释,模拟把一个包的大版本号变成 1 的场景,可以看到两个包的版本号以及`lerna.json`中的`version`也变成了`1.0.0`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/530f4e2872fe4f11ad27a143691b6047~tplv-k3u1fbpfcp-watermark.image)\n\n```\n Changes:\n - playground: 0.0.1 => 1.0.0 (private)\n - vue-pro-components: 0.0.1 => 1.0.0\n```\n\n此时,我们再加一行注释,模拟引入一个 feature,再发起 minor 位的版本号变更,会发现仅有一个包的`version`变成了`1.1.0`,同时`lerna.json`中的`version`也变成`1.1.0`,而另一个包的版本号没有变化,这看起来还比较合理,因为我们认为主版本号 1 以上的是相对稳定的版本,按需更新版本号是比较合理的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8364c002e4b1439c82d9da9d4f2c5be8~tplv-k3u1fbpfcp-watermark.image)\n\n```\n Changes:\n - vue-pro-components: 1.0.0 => 1.1.0\n```\n\n接着,我们把两个包都改一点内容再测试一次。\n\n```\n Changes:\n - playground: 1.0.0 => 1.1.1 (private)\n - vue-pro-components: 1.1.0 => 1.1.1\n```\n\n做了这些尝试我们可以发现,`lerna version`做版本变更时,只会让我们选择一次版本,这一次选择将作用到多个包上。\n\n假设某次更新版本时,我希望一个包是 minor 更新,另一个包是 patch 更新,该怎么办呢?我们继续往下看。👀\n\n2. [Independent mode](https://lerna.js.org/docs/features/version-and-publish#independent-mode)\n\nIndependent Mode 就是采用独立的版本号控制,会在执行`lerna version`命令时逐个询问各个 package 的新版本号,我们可以通过修改`lerna.json`中的`version`字段值为`independent`打开这个模式。\n\n当我只修改其中一个包,`lerna version`会提示我选择一个版本号,这个版本号也将只作用到这个包上,其他的包不受影响。\n\n```\n $ lerna version\n lerna notice cli v5.3.0\n lerna info versioning independent\n lerna info Looking for changed packages since v2.0.0\n ? Select a new version for vue-pro-components (currently 2.0.0) Patch (2.0.1)\n \n Changes:\n - vue-pro-components: 2.0.0 => 2.0.1\n```\n\n接着我们修改两个包的内容再测试一次,Lerna 会让我们单独为每个包选择新的版本号。\n\n```\n $ lerna version\n lerna notice cli v5.3.0\n lerna info versioning independent\n lerna info Looking for changed packages since vue-pro-components@3.0.0\n ? Select a new version for playground (currently 2.0.0) Minor (2.1.0)\n ? Select a new version for vue-pro-components (currently 3.0.0) Minor (3.1.0)\n \n Changes:\n - playground: 2.0.0 => 2.1.0 (private)\n - vue-pro-components: 3.0.0 => 3.1.0\n```\n\n也就是说,Independent Mode 下,版本号是**各管各的,按需选择**。\n\n> 简单总结一下:在 Fixed Mode 下,lerna.json 中记录了各个包中最新的版本号。如果当前大版本号是 0,则修改任意一个包中的内容都会引起所有包的版本号更新;反之,仅更新变动的包的版本号。还有一个场景,就是继续选择大版本的更新,也会引起所有包的版本号更新。总的来说,Fixed Mode 下,版本号捆绑性还是很强的。而在 Independent Mode 下,各个包的版本号相对独立,需要开发者结合包的修改情况来手动选择各个包的版本号。\n\n个人建议:如果你的整个 monorepo 项目中**各个子包联系性非常紧密,目标是对外提供统一的服务**,那么 Fixed Mode 是一个不错的选择,例如 Vue CLI,就是采用了 Fixed Mode。对用户来说,他享受的是整个 Vue CLI x.x.x 版本带来的能力,而不太关心 `@vue/cli-ui` 和 `@vue/cli-service` 现在是哪个版本。如果你的 monorepo 项目中**各个子包联系性稍弱,对外提供多种能力(比如 Lint 配置、Utils 工具、通用 Hooks、UI 库等等)**,那选择 Independent Mode 则是一个不错的选择,这种做法常见于企业内部,通常 monorepo 是作为整合多种能力的一个重要工具,既能在各个子包之间实现一部分复用,又能单独对外提供输出能力。当然,任何事情都不是一成不变的,如果你对版本控制欲很强,也可以果断选择 Independent Mode。\n\n> 我这里选择的是 Independent Mode。\n\n## 子包之间的引用\n\n对版本策略有个粗略的认识后,我们给子包之间建立一点联系,感受一下 monorepo 最大的魅力。\n\n我们先在`vue-pro-components`子包写一个简单的组件`Icon`,无需真正实现图标组件,仅仅用来测试一下。\n\n```vue\n\n {{ icon }} \n \n\n\n```\n\n一个粗略的目录结构大概是这样的:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e938ece3ae6e44b7928f7ffa03dcca6b~tplv-k3u1fbpfcp-watermark.image)\n\n由于 Vue3 组件需要用到框架依赖,我们需要在`package.json`中声明一个`peerDependencies`。\n\n```json\n\"peerDependencies\": {\n \"vue\": \"^3.2.0\"\n}\n```\n\n然后在项目根目录的`package.json`中加一个统一安装依赖的脚本。\n\n```json\n\"scripts\": {\n \"bootstrap\": \"lerna bootstrap -- --hoist\"\n}\n```\n\n接着执行这个`bootstrap`命令,\n\n```shell\nyarn bootstrap \n```\n\n我们可以发现,在根目录中出现了`node_modules`目录,而在各个子包中没有出现`node_modules`,这是`--hoist`在起作用,将依赖提升到了根目录,可以节省一部分空间。\n\n我们还注意到,`vue-pro-components`和`playground`两个包也出现在了`node_modules`目录中,实际上它们是软链接,链接的源目录是`packages`目录中对应的子包目录,这样的目录结构符合 Node 的模块加载策略,于是子包之间就可以像使用一个普通的 npm 包一样互相引用了。\n\n> 软链接就是 symbolic link,类似于 Windows 系统中**快捷方式**的概念。但是在 Windows 系统中, workspace 的具体实现并不是快捷方式,而是采用了 junction。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/907f47ee260542a9b2ff6400b6ab6e0f~tplv-k3u1fbpfcp-watermark.image)\n\n> `lerna bootstrap`不仅为各个 package 安装了自身的依赖,还将各个 package 以 symlink 的方式安装到了`node_modules`中,让其他 package 拥有了引用自己的能力。\n\n接着我们试着在`playground`子包中引用一下`vue-pro-components`子包的组件 **Icon**。\n\n1. 首先需要将`vue-pro-components`作为`playground`子包的一个依赖。\n\n```shell\nlerna add vue-pro-components --scope=playground\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7893c3a34350475da435a7a3b0e391ee~tplv-k3u1fbpfcp-watermark.image)\n\n2. import 引入组件并使用。\n\n```vue\n// 1. script 中引入 Icon 组件\nimport { Icon } from \"vue-pro-components\"\n\n// 2. template 中使用组件\n \n \n \n```\n\n3. 预览效果。这需要把 playground 这个子包的开发环境跑起来,也就是要执行它的`dev`脚本。为了方便起见,我们可以在项目根目录的`package.json`中加一个`playground:dev`脚本,这里用到`lerna run`,它可以根据`scope`选项执行某个子包的脚本。\n\n```\n\"scripts\": {\n \"bootstrap\": \"lerna bootstrap -- --hoist\",\n // 加入这条脚本\n \"playground:dev\": \"lerna run --scope playground dev\"\n}\n```\n\n这样,我们就可以直接在根目录直接跑 playground 的开发环境了。虽然 Icon 组件还没什么太多的内容,但是我们可以看到,playground 子包已经可以顺利引用 vue-pro-components 子包的组件了。\n\n![动画.gif](https://qncdn.wbjiang.cn/博客素材/f3f819d4665440b3a3640840ae66142c~tplv-k3u1fbpfcp-watermark.image)\n\n## 简单尝试发布到 npm\n\n整个组件库的工程配置一股脑说完,也是很难吸收的,我们先来点简单的,也是最重要的一步,把组件库先发布到 npm 上。\n\n首先你需要有一个 [npm 账户](https://www.npmjs.com/signup)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/381413d04224487aad7130653779ce80~tplv-k3u1fbpfcp-watermark.image)\n\n有了账号后,可以来到你的项目工程目录下,通过终端登录 npm,可以输入`npm adduser`或者`npm login`进行登录。\n\n```shell\nnpm adduser\n```\n\n如果登录失败,考虑你的 registry 是不是正确,如果用了国内的 npm 代理,建议登录时带上`--registry=https://registry.npmjs.org/`参数。\n\n登录成功后,就可以试着发布你的 npm 包了。`npm publish` 可以发布包,但是在 lerna 项目中,我们可以用 `lerna publish`代替。\n\n通常,我们会在项目中通过`.npmrc`或者`.yarnrc`配置一个国内的 registry 代理,加快安装依赖的速度。但是在发包的时候,我们还是要发布到 npm 官方的 registry 中,所以就需要给 `lerna publish` 配置一个 registry 参数,告诉 lerna publish 发布到哪个 registry 中。\n\n我们修改一下`lerna.json`:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c003db23ed85453397ff6b3ea1dc3ba9~tplv-k3u1fbpfcp-watermark.image)\n\n同时,还有一个地方需要修改,那就是`vue-pro-components`子包的`package.json`,需要将其`publishConfig.access`字段设置为`\"public\"`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9a41683a21e5461da5144ae8284eb96c~tplv-k3u1fbpfcp-watermark.image)\n\n从上图我们可以知道,如果一个包是 scoped package,也就是带命名空间的包,例如它的包名是`@vue-pro-components/utils`,对于这样的包,如果不设置`access`为`\"public\"`,是不能公开发布和安装的。虽然我们发布的这个 vue-pro-components 不是 scoped package,但是为了养成一个好习惯,我们还是给它设置一下`access`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/fd48e1751b794486b9c7aa0d34222115~tplv-k3u1fbpfcp-watermark.image)\n\n接着,我们在根目录`package.json`中增加一个脚本,方便我们进行发布操作。\n\n```\nlerna publish from-package --yes\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/35f4fb02035a4287a0904f2c7296c3a3~tplv-k3u1fbpfcp-watermark.image)\n\n我们试着执行这个`publish:package`脚本,如果能看到下面这样的信息,就表示发布成功了。\n\n![lerna\\_publish.gif](https://qncdn.wbjiang.cn/博客素材/3adbb28a06f04565ab37b345471d56a3~tplv-k3u1fbpfcp-watermark.image)\n\n# 结语\n\n截至到目前,我们只是在组件库开发环境搭建上做了一些粗略的尝试,对一些关键节点做了验证,整个项目还是处于一个非常简陋的状态,但是读者们也不必担心内容的丰富度,随着专栏后续内容的深入,一些工程化配置(包括 TypeScript)也会慢慢完善起来。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045),接下来可以一同探讨和交流组件库开发过程中遇到的问题。\n\n> 专栏下篇文章传送门:[实战案例:初探工程配置 & 图标组件热身](https://juejin.cn/post/7160549169566842893)\n\n> 技术交流&闲聊:[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg)', '2022-10-31 18:05:15', '2024-11-13 07:53:49', 1, 248, 0, '为了让读者们沉浸式体验组件库开发,我将会手把手带着读者们搭建起一个组件库的 monorepo 开发环境。', 'https://qncdn.wbjiang.cn/博客素材/a2cc4a9b9b26409fb1d8bad8d1be84e3~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (239, '实战案例:初探工程配置 & 图标组件热身', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏上篇文章传送门:[组件库技术选型和开发环境搭建](https://juejin.cn/post/7153432538046791687)\n>\n> 专栏下篇文章传送门:[Web 中的字体和 SVG 图标,你了解多少?](https://juejin.cn/post/7163889112364089380)\n>\n> 本节涉及的内容源码可在[vue-pro-components c2 分支](https://github.com/cumt-robin/vue-pro-components/tree/c2)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045) 专栏第 3 篇文章【实战案例:初探工程配置 & 图标组件热身】,我将从业务系统中最基础的**图标组件**入手,带着读者们练练手找找感觉,快速进入开发状态,顺便了解一些基本的前端工程配置。\n\n# 引入Formatter/Linter工具\n\n在正式地开发组件之前,我们需要一点点准备工作。\n\n为了提高开发效率,避免低级错误,我们有必要先引入一些工具,毫无疑问,ESLint, Prettier, StyleLint 可以先安排上,**相关配置点到为止,不会一来就堆大量的配置**。\n\n首先我们把 VSCode 的相关插件安装好。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e7fe45e8fc78467db4e2f554730d57ee~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d95e5a54691d49a8928900721771b531~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7b856e22e6cc4298aa26434d0305171a~tplv-k3u1fbpfcp-watermark.image)\n\n由于我们用的是 Vue3 开发组件库,Volar 也可以直接安装上!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8de2f160aec94a9b90486fefc16a4c18~tplv-k3u1fbpfcp-watermark.image)\n\n我们还将这些插件加到了`.vscode/extensions.json`中,这样别人打开这个项目时,VSCode 就会自动推荐 ta 安装相关的插件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/b4ff19b9568e4c63a054345ffda77319~tplv-k3u1fbpfcp-watermark.image)\n\n## ESLint\n\n然后我们从 [ESLint](https://eslint.org/) 开始配置环境。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/98251aa8ce394e4daf6c0968e5d119e6~tplv-k3u1fbpfcp-watermark.image)\n\n打开官网,可以看到官方已经给我们提供了相关命令,我们执行`npm init @eslint/config`初始化一下。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/47ebf706fcaa4a4ea6ff63b8c705292b~tplv-k3u1fbpfcp-watermark.image)\n\n会发现安装依赖过程中 Yarn 给我们抛了一个错误。在 workspaces 特性启用时,Yarn 默认认为我们执行`yarn add`时是希望将依赖安装到某个 workspace 下面而不是工程的根目录下。而这里,我们需要将 eslint 的这些依赖安装到工程的根目录下,可以加上`-W`参数手动安装一下依赖,这些依赖在上面的日志信息中可以找到。\n\n```shell\nyarn add -DW eslint-plugin-vue@latest eslint-config-standard-with-typescript@latest @typescript-eslint/eslint-plugin@^5.0.0 eslint@^8.0.1 eslint-plugin-import@^2.25.2 eslint-plugin-n@^15.0.0 eslint-plugin-promise@^6.0.0 typescript@*\n```\n\n此时可以发现 eslint 已经在报一些错误了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7ebbe3f63c5b4896a10b57877574ad0e~tplv-k3u1fbpfcp-watermark.image)\n\n而对于 indent,我习惯用 4 个 space,这里自定义一下 rule。\n\n```javascript\n\"rules\": {\n \"indent\": [\"warn\", 4]\n}\n```\n\n改完之后,indent 相关的报错信息消失了,而其他的错误依旧在,此时,还只能通过右键菜单来进行 Format,不是特别方便。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6ee2a5f4341e46418130b08b53fcf4cc~tplv-k3u1fbpfcp-watermark.image)\n\n为了方便使用和自动修复一些代码质量问题,我们把 VSCode 和 ESLint 的 Fix 能力结合一下。我们新增一个`.vsocde/settings.json`,配置如下:\n\n```json\n{\n \"editor.tabSize\": 4,\n \"eslint.validate\": [\"javascript\", \"typescript\", \"javascriptreact\", \"typescriptreact\", \"vue\"],\n // 防止内置css校验和stylelint重复报错\n \"css.validate\": false,\n \"less.validate\": false,\n \"scss.validate\": false,\n \"editor.formatOnSave\": false,\n // 代码保存动作\n \"editor.codeActionsOnSave\": {\n \"source.fixAll.eslint\": true,\n \"source.fixAll.stylelint\": true\n },\n \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n \"[vue]\": {\n \"editor.defaultFormatter\": \"Vue.volar\"\n },\n \"typescript.tsdk\": \"node_modules/typescript/lib\"\n}\n```\n\n接着验证一下是不是生效了,从下图中可以看到,保存代码可以自动 format 了。\n\n![保存自动format.gif](https://qncdn.wbjiang.cn/博客素材/55e3f414068c46a48c8a90924f568360~tplv-k3u1fbpfcp-watermark.image)\n\n基本的路子摸清后,我们可以完善一下 JavaScript 的编码风格规范了,闭着眼睛推荐`eslint-config-airbnb-base`,具体规范可以参考`airbnb/javascript`(请自行上github找一下),阅读一遍有助于培养良好的编码意识。\n\n```\nyarn add -DW eslint-config-airbnb-base eslint-plugin-import\n```\n\neslint 关键配置:\n\n```\nextends: [\n \'eslint:recommended\',\n \'plugin:import/recommended\',\n \'airbnb-base\'\n],\nplugins: [\'import\'],\n```\n\n> It requires `eslint` and `eslint-plugin-import`.\n>\n> eslint-plugin-import 是 eslint-config-airbnb-base 要求安装的,同时也是开发过程中的一个利器,保证我们能按预期使用 ES 的模块 import/export。\n\n## StyleLint\n\n接着我们把负责样式风格和质量的 [StyleLint](https://stylelint.io/) 也配置一下,这里顺手安装了几个 config,包括 StyleLint 的标准配置以及应用到 SCSS-like 文件 和 Vue 文件的特有配置。\n\n```\nyarn add -DW stylelint stylelint-config-standard stylelint-config-standard-scss stylelint-config-standard-vue postcss-html\n```\n\n> postcss-html 是与 stylelint-config-standard-vue 配合使用的。\n\n初始配置文件可以简单引入上面那几个 config。\n\n```\nmodule.exports = {\n extends: [\n \'stylelint-config-standard\',\n \'stylelint-config-standard-scss\',\n \'stylelint-config-standard-vue/scss\'\n ],\n rules: {\n indentation: 4\n }\n}\n```\n\n为了与 VSCode 更好地集成,我们修改一下`.vscode/settings.json`,加入以下配置:\n\n```\n\"stylelint.validate\": [\n \"css\",\n \"less\",\n \"vue\"\n]\n```\n\n此时我们随意修改一下样式,测试一下效果,可以看到基本的提示和修复能力都有了。\n\n![stylelint自动修复.gif](https://qncdn.wbjiang.cn/博客素材/fd5d1cd6fd5f4446ae8716fc1f525a4b~tplv-k3u1fbpfcp-watermark.image)\n\n## Prettier\n\n项目中要不要使用 Prettier 取决于个人,没有强制的要求,毕竟没有 Prettier 之前,大家也活得挺好。做这个决定前要搞清楚 Prettier 和 ESLint / StyleLint 这类 Linter 扮演的角色分别是什么。简单说就是 **Prettier 负责代码风格,而 Linter 负责代码质量**。\n\n> 引用官方文档的一句话:**Prettier for formatting** and **linters for catching bugs!**\n\n读过 Prettier 的这篇[文档](https://prettier.io/docs/en/integrating-with-linters.html)你就可以知道,Prettier 和 Linters 会有一些功能交叉和规则冲突。功能交叉指的是 Linter 除了负责代码质量外,本身也可以定义规则约束代码风格,这就有可能会与 Prettier 的代码风格产生冲突。这个时候,就需要通过 Linter 体系中的一些插件配置关掉一部分与 Prettier 有冲突的规则,尽量在风格上以 Prettier 为准,比如 [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) 和 [stylelint-config-prettier](https://github.com/prettier/stylelint-config-prettier)。\n\n我们安装一下 Prettier 和相关配套试试:\n\n```\nyarn add -DW prettier eslint-config-prettier stylelint-config-prettier\n```\n\n新建一个`prettier.config.js`配置文件,写入一些简单的配置:\n\n```\nmodule.exports = {\n tabs: false,\n tabWidth: 4,\n endOfLine: \'auto\',\n semi: false,\n singleQuote: true\n}\n```\n\n接着把`eslint-config-prettier`和`stylelint-config-prettier`配置好。\n\n```\n// eslint 配置\nextends: [\n // 引入 eslint-config-prettier\n \'prettier\'\n],\n```\n\n```\n// stylelint 配置\nextends: [\n // 引入 stylelint-config-prettier\n \'stylelint-config-prettier\'\n],\n```\n\n此时,我们会发现随意修改 vue 文件后,对于一些低级的代码风格问题,VSCode 提示都没有了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7d330cebe4644e17949e4eded4f1e6db~tplv-k3u1fbpfcp-watermark.image)\n\n我去,这都不报错!看来是`eslint-config-prettier`把有冲突的 rules 关得很彻底!好,这个时候我们需要把 Prettier 的输出反馈给 ESLint,让 ESLint 来做提示,这需要用到 [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier)。\n\n> Runs [Prettier](https://github.com/prettier/prettier) as an [ESLint](https://eslint.org/) rule and reports differences as individual ESLint issues.\n\n先安装一下依赖,\n\n```\nyarn add -DW eslint-plugin-prettier\n```\n\n然后把下面的 ESLint 配置做好,这相当于把 Prettier 作为 ESLint 检查工序中的一个环节了。\n\n```\n// .eslintrc.js\n{\n \"plugins\": [\"prettier\"],\n \"rules\": {\n \"prettier/prettier\": \"error\"\n }\n}\n```\n\n![prettier报错反馈给eslint.gif](https://qncdn.wbjiang.cn/博客素材/7cddcfb8b7fe43348ced7db29d8562ef~tplv-k3u1fbpfcp-watermark.image)\n\n哥们儿,这感觉嘎嘎上来了,要的就是这个效果,看来这就是 Prettier 接管了 ESLint 一部分工作的精髓啊!\n\n类似地,我们把`stylelint-prettier`也安装一下。\n\n```\nyarn add -DW stylelint-prettier\n```\n\n修改配置:\n\n```\n// stylelint.config.js\n{\n \"plugins\": [\"stylelint-prettier\"],\n \"rules\": {\n \"prettier/prettier\": true\n }\n}\n```\n\n# TypeScript\n\n我们尝试把一些原有的 js 文件改成 ts,会发现 ESLint 先报了一个错,这是因为 ESLint 的内置 parser [Espree](https://github.com/eslint/espree) 不能处理 ts 文件,我们需要引入新的 parser。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/191f9df53c834410934726ad146600f3~tplv-k3u1fbpfcp-watermark.image)\n\n对于 ESLint 和 TypeScript 的结合,我们主要关注这个仓库 [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint),这里面有我们需要的 `@typescript-eslint/parser`和`@typescript-eslint/eslint-plugin`。\n\n我们先安装一下这些依赖:\n\n```\nyarn add -DW typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin\n```\n\n新建一个`tsconfig.json`,基本内容如下:\n\n```\n{\n \"compilerOptions\": {\n \"target\": \"esnext\",\n \"module\": \"esnext\",\n \"strict\": false,\n \"jsx\": \"preserve\",\n \"importHelpers\": true,\n \"moduleResolution\": \"node\",\n \"baseUrl\": \"./\",\n \"rootDir\": \".\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"allowSyntheticDefaultImports\": true,\n \"sourceMap\": true,\n \"resolveJsonModule\": true,\n \"allowJs\": true,\n \"strictNullChecks\": true,\n \"lib\": [\"esnext\", \"dom\", \"dom.iterable\", \"scripthost\"]\n },\n \"include\": [\"**/*.ts\", \"**/*.tsx\", \"**/*.vue\", \"**/tests/**/*.ts\", \"**/tests/**/*.tsx\", \"**/components.d.ts\"],\n \"exclude\": [\"node_modules\"]\n}\n```\n\n接着在`.eslintrc.js`中把 `@typescript-eslint/parser`和`@typescript-eslint/eslint-plugin` 及相关配置处理好。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/89f9515e5b7142c886af28e5c20b3336~tplv-k3u1fbpfcp-watermark.image)\n\n一切都比较符合预期,但是当我们打开一个`.vue`文件时,会发现有报错信息:\n\n> Parsing error: \"parserOptions.project\" has been set for @typescript-eslint/parser.\n> The file does not match your project config: packages\\vue-pro-components\\src\\icon\\icon.vue.\n> The extension for the file (.vue) is non-standard. You should add \"parserOptions.extraFileExtensions\" to your config.\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9611569107444ef291143fe6aa45a27c~tplv-k3u1fbpfcp-watermark.image)\n\n我的第一反应是认为我们配置的`@typescript-eslint/parser`无法识别`.vue`文件,这时候就需要用到`vue-eslint-parser`了。\n\n然而引入`vue-eslint-parser`并把基本配置做好后,这个报错依然没有消失。想着关机一次试试,没有用。等了两天再打开,又不报错了,没想明白。不过`vue-eslint-parser`肯定是少不了的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/268e4f01c6734d7da877b95447627303~tplv-k3u1fbpfcp-watermark.image)\n\nTypeScript 在配合`eslint-plugin-import`使用时,我们还需要配置一下[eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript),这个在相关插件的文档中也有提到。否则会报这些错误:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/feacdfe5e81b4dd6b4e465a5a7c8feda~tplv-k3u1fbpfcp-watermark.image)\n\n```\nyarn add -DW eslint-import-resolver-typescript\n```\n\n补充的关键配置如下:\n\n```\n// .eslintrc.js\nextends: [\n // ...\n \'plugin:import/typescript\',\n],\nsettings: {\n // ...\n \'import/resolver\': {\n typescript: {\n alwaysTryTypes: true, // always try to resolve types under `@types` directory even it doesn\'t contain any source code, like `@types/unist`\n // Multiple tsconfigs (Useful for monorepos)\n // use an array of glob patterns\n project: [\'tsconfig.json\', \'packages/*/tsconfig.json\'],\n },\n },\n},\n```\n\n# less\n\n我们测试一下能不能正常使用 less 开发,先把`icon.vue`的`style block`的`lang`设置为`less`试试。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f44a0712877a40f4a922887a61966cd8~tplv-k3u1fbpfcp-watermark.image)\n\n由于 package `vue-pro-components`中的文件都改成 ts 了,其中也包括入口文件`index.ts`,所以我们还需要把`package.json`的`main`入口修改成`index.ts`,才能顺利调试。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6751cf649cd44ed9a75b1e87ad4b7ba3~tplv-k3u1fbpfcp-watermark.image)\n\n但是把 dev 环境跑起来后,还是报了 less 相关的错误。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/fe5335cb9109448d8425eda4a93c10da~tplv-k3u1fbpfcp-watermark.image)\n\n由于 dev 环境是 package `playground`的,只是它引用了 package `vue-pro-components`中的`Icon`组件,所以是 package `playground`的环境中缺失`less`,我们给它安装一下`less`依赖。\n\n```\nlerna add less --scope=playground --dev\n```\n\n# 图标组件需求\n\n基本的环境准备好之后,我们来实现一个简单的 Icon 组件热热身。\n\n虽然 UI 组件库都标配了 Icon 组件,但是这些图标通常来说是不够用的,很难满足不同项目的需求,所以有必要自己实现一个 Icon 组件,能够方便地管理和使用图标。\n\n前端与 UI 设计师通常利用 iconfont 来进行图标协作,图标的表现形式有字体图标,SVG 图标等,我们就先从字体图标开始。\n\n## 准备一个 iconfont 项目\n\n每个业务项目用到的图标肯定是有差异的,我们先选一些图标做个示例,为了方便,这里直接选用了一套[阿里云官网官方图标库](https://www.iconfont.cn/collections/detail?spm=a313x.7781069.1998910419.d9df05512&cid=16472),然后把这些图标抄到自己的图标项目中。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/be85c882f5f644eea3228f4b27e31dda~tplv-k3u1fbpfcp-watermark.image)\n\n大概看了一下,图标也挺多的,一个个加到购物车手也会很累。于是我观察了一下 DOM 结构,发现可以用脚本模拟一下点击加购物车的行为,那就不浪费时间了,直接上脚本。\n\n```\n[...document.querySelector(\".collection-detail ul.block-icon-list\").children].forEach(child => {\n child.children[2].children[0].click()\n})\n```\n\n![iconfont脚本模拟点击.gif](https://qncdn.wbjiang.cn/博客素材/55b98d4b6cf4477e828bfe4fedc3501a~tplv-k3u1fbpfcp-watermark.image)\n\n> 图标复制到项目中后发现图标的默认命名有点呆,全是 icon-test11 这样的,辨识度太低。懒得一个个改名字,最后还是换了一个图标库 [Hippo Design 官方图标库](https://www.iconfont.cn/collections/detail?spm=a313x.7781069.1998910419.d9df05512&cid=22664)。\n\n## 了解字体图标的基本原理\n\n顾名思义,字体图标本质上也是利用字体文件来展示图标的。字符的展示是依赖字符编码的,从 ASCII 到 Unicode,字符集也在不断丰富。计算机并不认识文字、符号或图标,本质上都是通过字符编码结合字体文件、排版引擎等来做渲染的。而 Unicode 预留了`E000-F8FF`范围作为私有保留区域,这个区间的 Unicode 码可以用来自定义一些内容,那么用来做字体图标显然也是非常合适,前端根据 Unicode 码就能显示对应的图标。\n\n> [PUA](https://baike.baidu.com/item/%E7%A7%81%E4%BA%BA%E4%BD%BF%E7%94%A8%E5%8C%BA/61727452?fr=aladdin),即 Private Use Areas,私人使用区相同的代码点可被分配为不同的字符,因此用户可能因安装了某种字体,看到其显示为一种形态,但使用了其他字体的用户可能看到完全不同的字符。\n>\n> 这也就是说,不同的字体文件可以重复利用这个区域的 Unicode,但是可以展示出不同的形态,这也就可以理解为什么我们能展示各种各样的图标了。\n\n然而直接用 Unicode 并不方便记忆和理解,所以我们会在 Unicode 编码基础上再封装一层,通过不同的 class 结合伪元素来表现图标,类似下面这样:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a20dcbe041504889aaca8def3a09fcb5~tplv-k3u1fbpfcp-watermark.image)\n\n## 引入字体文件\n\n接上面,你首先需要有一个图标库对应的字体文件,而这个字体文件可以来源于 iconfont。\n\n如果希望偷偷懒,或者不关注 iconfont cdn 的稳定性,你完全可以选择使用在线的 css 文件,这个 css 文件中也会引用在线的 ttf 等字体文件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/62903e51dc144051bbd5edd6acef3daf~tplv-k3u1fbpfcp-watermark.image)\n\n如果你关注内容的稳定性,不希望因为 iconfont cdn 问题导致业务损失,那么我建议把相关资源(包括 css 文件及其关联的字体文件)下载到项目中使用。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f0c0af9100994aa69cf56d41cc332dbc~tplv-k3u1fbpfcp-watermark.image)\n\n还有一个要考虑的问题,字体文件这些资源放在组件库中加载合适,还是放在业务项目中加载合适?\n\n我想,应该是放在业务项目中加载字体文件等资源比较合适。因为不同的业务项目用到的图标库肯定是有差异的,如果把字体文件内置到图标组件中,就会导致图标库都是一样的,显然没法满足各个项目的需求。\n\n而在我们的 monorepo 工程中,`playground`就扮演着业务项目的角色,可以用来测试组件库的表现,所以我们先在`playground`中引入生成的在线 css 文件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e055a1feb5204fcb8bed4ceafff69846~tplv-k3u1fbpfcp-watermark.image)\n\n## 思路整理\n\n字体等资源准备好之后,就可以思考怎么基于这些资源实现组件了。\n\n我们知道,css 文件中已经将各个图标封装成 class 了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f2bda52eecf44a8ea17e8036afaa684e~tplv-k3u1fbpfcp-watermark.image)\n\n只要我们引用这些 class,就能得到一个字体图标,我们来试试看:\n\n```\n \n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/78bce817df3649edb6994897dde7baaa~tplv-k3u1fbpfcp-watermark.image)\n\n可以发现效果已经出来了。但是我们是写死测试的,要实现一个可复用的图标组件,显然还要预留一些属性交给外部配置,很容易想到的属性有:\n\n- 图标的名称:用来唯一确认一个图标,一个名称对应一个 class,这个 class 会对应一个唯一的 Unicode 编码。\n- 图标的颜色:字体图标本身也是一个“字”,和普通的字没啥区别,所以可以用`color`属性控制颜色。\n- 图标的大小:同上,可以用`font-size`控制图标的大小,但是通过`font-size`只能控制一个大概的大小,并不等价于绝对意义上的宽高。下面是我设置`font-size: 15px`的效果,可见真实的高度并不是`15px`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e878725473c74e899d240b129b9f312d~tplv-k3u1fbpfcp-watermark.image)\n\n如果你希望控制地很彻底,那就应该另外通过`width`和`height`去控制了。但是我认为大部分情况,没有这个必要,用`font-size`粗略控制一下字体图标的大小就差不多了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/20dc5a64ccb94e6bac6137b1e9c503a5~tplv-k3u1fbpfcp-watermark.image)\n\n- 图标的 class 前缀:目前也是写死的 `vp-icon-`作为前缀,虽然没什么大问题,但是最好留个配置项。\n\n## 组件属性\n\n我们把属性单独提到一个`props.ts`中维护,利用 Vue 提供的 `ExtractPropTypes` 可以把属性的类型提取出来。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9108f7e55d734871ad6b300b9bafa1a3~tplv-k3u1fbpfcp-watermark.image)\n\n## 组件主体\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/db9ab775f56a4b20ace3c25d1fbd99a1~tplv-k3u1fbpfcp-watermark.image)\n\n主体逻辑不是很复杂,首先必须引用一个基本的 class `iconfont`,这个 class 是用于控制字体等基本属性的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a394a379e25d4ffd8193f7567bb48d10~tplv-k3u1fbpfcp-watermark.image)\n\n接着通过`iconPrefix`和`icon`的拼接组成一个完整的`class`,用来映射到具体的图标。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7c8af285fcd94b4099f359ab968ac946~tplv-k3u1fbpfcp-watermark.image)\n\n其他的属性`color`和`size`就是辅助控制颜色和大小的。\n\n## 组件名的处理\n\n组件名可以由文件名自动推断出来,但是为了和文件名解耦,我们还是希望定义一个组件`name`。但是在 setup 语法糖下,Vue 官方并没有提供类似于`defineProps`这样的编译宏,让我们方便地定义`name`,唯一的办法是另外写一个普通的`script`块,在其中的默认导出中包含`name`字段,但是这显得很不方便。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d3d2f07964ee468b8559fceb16bcbfcb~tplv-k3u1fbpfcp-watermark.image)\n\n还好已经有人通过插件解决了这个问题。但是在相关 RFC 的讨论中,似乎尤大也并未完全支持这种做法,具体见 https://github.com/vuejs/rfcs/discussions/430 ,所以是否采纳这种做法还值得考虑一番。\n\n换个思路,咱们先在`icon/index.ts`中扩展一下`name`字段。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/76e0cc4e152d4f7b8ed13f81b1facfe7~tplv-k3u1fbpfcp-watermark.image)\n\n## 类型问题 & 插件化\n\n我们可以观察到,在 Volar 的加持下,模板中的组件类型显得还比较完善。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5dc45a59f63a4cec9c37ec15e8cb2a82~tplv-k3u1fbpfcp-watermark.image)\n\n但是在 ts 上下文中,组件的类型似乎还未展示出来。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d196bfbf16154576855098fe7b00de69~tplv-k3u1fbpfcp-watermark.image)\n\n与此同时,组件还没有对应的`install`方法,这样就不能单独作为一个插件被`use`。我们借鉴一下其他 UI 组件库的做法,用一个`withInstall`函数把组件包装一下。\n\n考虑到这类通用的工具方法还可以要暴露给外部项目使用,我们可以把工具方法封装到`@vue-pro-components/utils`这个包中。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/747261f8920749a780bf9a8a4d6de281~tplv-k3u1fbpfcp-watermark.image)\n\n接着 Icon 的导出部分就可以写成这样了:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/91c8f0a33f2e4872b529b690a2bc07a0~tplv-k3u1fbpfcp-watermark.image)\n\n而且我们能看到,这个时候 Icon 的类型提示也出来了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/eacbcd997446426dbfcf20bca4ec9ee1~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0f62528db193422d8e2a1211962d6429~tplv-k3u1fbpfcp-watermark.image)\n\n# 结语\n\n在本节中,我们继续完善了一些工程化配置,但是在配置上也是点到为止,没有堆砌太多的插件或者配置项,以防让人眼花缭乱,无法抓到重点。接着,我们通过一个字体图标组件需求的实战,初步掌握了如何组织起一个组件。接下来,我们会继续通过一些实战案例查漏补缺,在实际运用中看看我们还缺失一些什么东西。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2022-11-24 19:50:01', '2024-08-06 07:25:01', 1, 73, 0, '从业务系统中最基础的图标组件入手,带着读者们练练手找找感觉,快速进入开发状态,顺便了解一些基本的前端工程配置。', 'https://qncdn.wbjiang.cn/博客素材/349ea0d15cc54fdeaaceef503f27462c~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (240, 'Web 中的字体和 SVG 图标,你了解多少?', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏上篇文章传送门:[实战案例:初探工程配置 & 图标组件热身](https://juejin.cn/post/7160549169566842893)\n>\n> 专栏下篇文章传送门:[衍生需求:按钮集成图标组件 & 图标选择器](https://juejin.cn/post/7166029886128128014)\n>\n> 本节涉及的内容源码可在[vue-pro-components c3 分支](https://github.com/cumt-robin/vue-pro-components/tree/c3)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045) 专栏第 4 篇文章【Web 中的字体和 SVG 图标,你了解多少?】,我们接着上篇的字体图标组件实战,继续探索图标的另一个展现形式 —— SVG 。在开发 SVG 图标组件之前,还有一些问题我们必须搞清楚!\n\n# 值得了解的字体知识\n\n不能盲目地开发组件,我们可以先问问自己:**既然有了字体图标,为什么还需要 SVG 图标呢?**\n\n在回答这个问题之前,我们还需要了解字体的一些基础知识,免得在具体应用时一知半解。\n\n我们首先来看一下计算机字体有哪几种大的分类:\n\n> 引自[维基百科 Computer_font](https://en.wikipedia.org/wiki/Computer_font):\n>\n> There are three basic kinds of computer font file data formats:\n>\n> - **Bitmap** fonts consist of a matrix of dots or [pixels](https://en.wikipedia.org/wiki/Pixel \"Pixel\") representing the image of each glyph in each face and size.\n> - **Vector** fonts (including, and sometimes used as a synonym for, **outline** fonts) use [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve \"Bézier curve\"), drawing instructions and mathematical formulae to describe each glyph, which make the character outlines scalable to any size.\n> - **Stroke** fonts use a series of specified lines and additional information to define the size and shape of the line in a specific typeface, which together determine the appearance of the glyph.\n\n- 位图字体(同义词:点阵字体)本质上是点或像素组成的矩阵(也就是点阵)。像素化的内容在较高分辨率的设备上会有比较糟糕的表现(失真、模糊等),因为把低像素的内容放在高分辨率的设备上显示时,计算机会不知道如何展示(具体表现为不知道内容像素和设备像素的对应关系),这就需要根据 [Nearest-neighbor interpolation](https://en.wikipedia.org/wiki/Nearest-neighbor_interpolation) 算法进行最近邻插值,最终计算出来的位置不一定理想,同时也可能出现锯齿,这种展示是有损的,需要结合抗锯齿算法来做优化。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0e5abc5fc80f4743a9ac347a1446d812~tplv-k3u1fbpfcp-watermark.image)\n\n- 矢量字体(同义词:轮廓字体)是像素无关的,它使用贝塞尔曲线、绘图指令、数学公式等描述字形,它不像位图字体预先处理好像素,而是实时计算渲染,相对来说速度更慢。但是这样的字体理论上就可以适应各种大小的分辨率,但是最终效果也取决于渲染引擎的具体实现,可能也会出现锯齿,因为矢量在理论上是完美的,但是对应到显示设备上还是要落到具体像素上的,最终还是要结合抗锯齿、[字体微调](https://en.wikipedia.org/wiki/Font_hinting)、[亚像素渲染](https://en.wikipedia.org/wiki/Subpixel_rendering)、[DirectWrite](https://en.wikipedia.org/wiki/DirectWrite) 等技术手段优化。矢量字体主要包括 PostScript [Type 1 and Type 3 fonts](https://en.wikipedia.org/wiki/Type_1_and_Type_3_fonts \"Type 1 and Type 3 fonts\"), [TrueType](https://en.wikipedia.org/wiki/TrueType \"TrueType\"), [OpenType](https://en.wikipedia.org/wiki/OpenType \"OpenType\") 等几类。\n\n> Type 1, Type 3 都是 Adobe 搞的;TrueType 是苹果搞出来对抗 Adobe 的,后面又许可微软加入一起用;最后微软又联合 Adobe 搞了 OpenType(可以理解为 Type 1 和 TrueType 的超集),纯纯的都是商业竞争啊!\n>\n> .ttf 扩展名表示常规 TrueType 字体或具有 TrueType 轮廓的 OpenType 字体。\n>\n> .woff 是 OpenType 字体或者 TrueType 字体,由于是 web 专用字体,采用了 zlib 压缩。woff 由 Mozilla 基金会、Opera Software和微软于 2010年4月向万维网联盟(W3C)提交, 在2012年12月13日成为了 W3C 推荐标准,woff2 由谷歌负责推进,改进了压缩方案,于2018年3月成为了 W3C 推荐标准,是未来 web 字体的发展方向。\n\n- 笔画字体是矢量字体的一种细分形式,使用一系列指定的线条和附加信息来定义特定字体中线条的大小和形状,它们共同决定了字形的外观。从直觉上不容易看出它和轮廓字体的区别,不过从字面意思看,轮廓字体有点类似于艺术字那种比较饱满的效果,会有描边和填充的操作,需要更多的顶点数量来支撑起整个字形;而笔画注重骨架和描边,需要较少的顶点,更省空间,在表意文字上用得比较多。\n\n> 下图引用了发明专利《一种笔划矢量字库的存取方法》的附图,侵删!\n>\n> 左边是笔画字体,右边是轮廓字体。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ac0660e18f454a8faff6b1ee2c342174~tplv-k3u1fbpfcp-watermark.image)\n\n位图字体的优点在于它相对于其他字体在制作、渲染等方面更简单,速度也更快;其最大的缺陷是不能自适应各种分辨率的显示设备,同时在展示是否粗体、是否斜体、不同字号时都需要一套单独的字形光栅图像,这是乘法级的存储冗余,比较浪费存储空间(虽然也可以通过算法调整变换出字体变体,但是比较耗费性能)。虽然有这些缺陷在,但是在早期计算机性能较弱的情况下,位图字体显得非常合时宜,有着不可替代的地位。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5defd8ffc01e4db19062e851ba99dd9f~tplv-k3u1fbpfcp-watermark.image)\n\n随着计算机性能(算力、渲染等)的提升,位图字体的地位开始受到矢量字体的挑战,在需要任意弹性伸缩展示字形的场景下,矢量字体成了绝佳的选择;而位图字体则活跃在一些需要考虑速度、简单程度、硬件性能等因素的场景下,例如嵌入式设备、操作系统终端控制台、点阵打印机等。\n\n# Web 字体是矢量字体吗?\n\n了解了这些字体知识后,问题来了,Web 网页中我们常用的的字体都是什么类型的字体?\n\n直觉告诉我们,好像是矢量字体,因为伸缩网页时,字还是很清晰,没有出现锯齿。\n\n![web字体伸缩都很清晰.gif](https://qncdn.wbjiang.cn/博客素材/9fb4543d9df44cfc93bcbef81314eb55~tplv-k3u1fbpfcp-watermark.image)\n\n事实确实如此,点阵字体是计算机早期采用的字体,80年代开始,矢量字体就慢慢流行了起来,我们现在在网页中看到的基本上都是矢量字体。\n\n字体有很多种,但是能直接用在 Web 中的不多,因为不能保证用户的电脑中安装了指定的字体。所以指定`font-family`时通常考虑使用 Web 安全字体,保证各个操作系统、新旧版本、英文中文、emoji 等都能兼顾。\n\n```css\nfont-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Ubuntu, Cantarell, \"Helvetica Neue\", Arial, \"Noto Sans\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"WenQuanYi Micro Hei\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n```\n\n# 字体图标 VS SVG 图标\n\n既然 Web 字体基本上都是矢量字体,字体图标基本上用的也是`.ttf`, `.woff`, `.woff2`等矢量字体,看起来没什么缺点,那么为什么 SVG 图标慢慢成为了各大网站的首选呢?\n\n首先,字体是一种资源,如果字体加载失败,字体图标也就会渲染失败,可能会出现“小方格”。并且,如果由于网络等原因导致字体加载较慢,可能会出现占位抖动的情况(采用Base64内联字体会解决这个问题)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8234a8b54d8a4abfb027f2e9ec7b422b~tplv-k3u1fbpfcp-watermark.image)\n\n而 SVG 是 HTML 文档内联的内容,渲染上会更可靠一点。\n\n其次,字体图标并不支持彩色图标,即便现在彩色字体在 Web 中慢慢有了一些声音(iconfont 平台也支持了彩色字体图标),但是兼容性还有一些欠缺,在生产使用时还需要慎重考虑。而 SVG 本身是通过 XML 描述绘图指令,在绘图细节上控制力更强,天然支持彩色!不过随着未来 Web 彩色字体的普及,这方面也将不再是字体图标的劣势。\n\nSVG 图标可以支持更丰富的表现能力,比如滤镜、动画等,同时也支持 DOM 操作,这些是字体图标比不了的。\n\n字体图标的可访问性并不友好。字体图标是通过伪元素实现,比 SVG 的可访问性要差一些,SVG 内部可以使用`title`和`desc`标签描述信息,这无论对 SEO 还是无障碍阅读来说都显得更合适。\n\n在图标数量很多时,SVG 图标的优势更为明显,SVG 支持按需使用。而字体图标的图标和图标之间耦合在一个字体文件中,这会导致最终的字体文件很大。\n\n但也不是说字体图标就一无是处,字体图标在浏览器兼容性方面要更胜一筹,不过在“IE 已死”的局面下似乎也无关痛痒(老系统例外)。\n\n总的来说,字体图标和 SVG 图标都有各自的优势,并不是说哪个一定比另一个优秀,我们可以结合实际场景来考虑选用。\n\n# SVG 图标的使用方式\n\n- 直接使用 SVG:内联 SVG,图片,背景图等形式,缺点是使用起来不是很方便,通用性不够。\n- SVG Sprite:基于 symbol + use 实现,是 SVG 版本的雪碧图,可以封装成组件使用。\n- SVG 独立组件:把每个 SVG 图标做成按需加载的组件,这样就可以按需使用,同时兼具组件化的优点,类似于`@ant-design/icons-vue`提供的图标单组件。\n\n# SVG 图标组件实现思路\n\n我们先尝试一下 SVG Sprite 的方式。\n\nSVG Sprite 是先有 symbol,再通过 use 去引用。\n\n> The **``** element is used to define graphical template objects which can be instantiated by a [``](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use) element.\n\nsymbol 标签是用来定义图形模板对象的,但是不会直接渲染,需要通过 use 标签去实例化。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d156f43bb88b4fba9154db4305d11af0~tplv-k3u1fbpfcp-watermark.image)\n\n上图就是 SVG symbol 的大致结构,可以发现最外层的 svg 下面是一个 defs 标签。\n\n> The **``** element is used to store graphical objects that will be used at a later time. Objects created inside a `` element are not rendered directly。\n\n在SVG 中,可复用的内容定义在 defs 下面,并不局限于 symbol。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/cde665600604437da5bc0e9f39d60f10~tplv-k3u1fbpfcp-watermark.image)\n\n> symbol 也不强制放在 defs 下面,直接放在 svg 标签下也可以,因为它本身就是模板的含义,不会直接渲染。\n\n我们还可以发现,每个 symbol 上都有一个属性 id,而这个 id 将成为 use 标签引用的依据,`xlink:href`通过`#`加 id 的方式就可以引用对应的 symbol。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4c3b62ac71004d17bde0f42028eb4d65~tplv-k3u1fbpfcp-watermark.image)\n\n了解这些知识后,我们应该清楚,要实现 SVG Sprite 组件,第一步是要生产出 symbol 内容。\n\nUI 交付给前端的一般是一个个独立的 SVG 文件,那么怎么把这些独立的 SVG 文件变成我们想要的 symbol 呢?\n\n这涉及到文件操作,如果用 nodejs 实现,必然离不开 [fs](https://nodejs.org/api/fs.html) 模块相关的 api,基本的原理就是读文件的字符串内容,然后做拼接处理,输出一个字符串,这个字符串最终可以通过 innerHTML 方式插入到 HTML 文档流中。\n\n如果你的图标类的 SVG 文件都是放在项目工程中的,那么可以选用 [svg-sprite-loader](https://www.npmjs.com/package/svg-sprite-loader)(webpack 体系)直接把这部分工作做好,剩下的事情就是封装组件。\n\n> Vite 中也可以实现类似的插件,大家按 vite svg sprite 等关键词去搜一搜就能找到很多。\n\n如果我们使用的是 iconfont,也可以直接用 iconfont 提供的脚本。通过`script`标签引入这个 js 文件后,会自动创建相关的 SVG symbol。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0add2aba493f4b92a4ab91cc1a7210b5~tplv-k3u1fbpfcp-watermark.image)\n\n剩下的工作就是把 use 的使用封装到组件中,让业务调用变得简单。\n\n# 编码实现 SVG 图标组件\n\n基本逻辑捋清楚了,我们来编码实现一下。\n\n> 为了和字体图标组件区分开,我们把上节中实现的字体图标组件`Icon`重新命名为`IconFont`,而本次要实现的 SVG 图标组件就叫做`IconSvg`。\n\n首先在业务项目中引入 iconfont 图标项目中的这个在线 js 文件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d476530a9403433183b48f4e2366b8ae~tplv-k3u1fbpfcp-watermark.image)\n\n按照[前一篇文章](https://juejin.cn/post/7160549169566842893#heading-10)所述,我们可以暂时把`playground`包看作是业务项目,所以直接在`playground`包中的`index.html`中引入这个 js 就可以了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3bd5f43127c64225b46e6c497153049c~tplv-k3u1fbpfcp-watermark.image)\n\n引入 js 后,我们可以看到 SVG symbol 已经生成好了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8ad5fc66db3b48f693f3bbe8490eba62~tplv-k3u1fbpfcp-watermark.image)\n\n> 注意:如果您担忧 cdn 的稳定性,可以考虑把 iconfont 项目中的相关资源下载到项目中直接引用,这样就不用担心哪天线上业务由于 cdn 问题受到影响。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0eec5270682f474aadfc4fac74192593~tplv-k3u1fbpfcp-watermark.image)\n\n接着就是在组件中把 use 的逻辑处理好。\n\n```vue\n\n \n \n \n \n\n\n```\n\n属性定义基本上与`IconFont`组件一致,但是具体用法有一点区别。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/1fa3d19cbb594c1d8aa25ff28e33e129~tplv-k3u1fbpfcp-watermark.image)\n\n组件的尺寸还是交给属性`size`控制,但是对应到 style 上是由`width`和`height`控制(因为`font-size`对 svg 无效)。\n\n组件的颜色是通过 style 的`fill`控制的(因为`color`也对 svg 无效)。\n\n我们看看目前的使用效果。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/b6b27eafc4014bd4aa505b8c8a1638e8~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/77008a7bafc842cfa4861c52354c6415~tplv-k3u1fbpfcp-watermark.image)\n\n基本效果出来了,但是我们发现一个问题,如果不绑定`size`和`color`属性,SVG 图标的表现不符合我们的预期。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/34942dbdf8aa44e1b0d3be335557a59f~tplv-k3u1fbpfcp-watermark.image)\n\n默认的尺寸太大了,预期应该是和同一个块中的文字差不多大。\n\n默认的颜色也不符合预期,我们希望它能跟随父级元素的字体颜色,这样才显得协调。\n\n所以,还需要再做一些优化。我们把组件的外层包裹一个`span`标签,让`svg`的尺寸继承`span`的字体大小,让`svg`的颜色继承`span`的颜色。\n\n- 尺寸上的继承:可以设置`svg`的宽高都为`1em`,这样就可以与文本的字体大小保持一致。\n- 颜色上的继承:可以设置`svg`的`fill`属性值为[currentColor](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#currentcolor_keyword),currentColor 是一个 css 变量,它能取到当前元素的`color`属性值,这样就可以与文本颜色保持一致了。\n\n代码改造如下:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e7f6fa4dcb944087b70b8782a01721ab~tplv-k3u1fbpfcp-watermark.image)\n\n其中样式部分如下:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ee5eb214ff78431ca40bd590862425b1~tplv-k3u1fbpfcp-watermark.image)\n\n这里用到了一个`text-rendering: optimizelegibility;`,对抗锯齿、字体微调等会更友好,具体见[这篇MDN介绍](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-rendering)。\n\n后面两个属性`-webkit-font-smoothing: antialiased;`和`-moz-osx-font-smoothing: grayscale;`也是对抗锯齿的优化。\n\n而`line-height`设置为`0`,可以消除行高对整个 span 尺寸的影响,使得 svg 的尺寸能准确表现出来。\n\n再次查看效果,发现不传任何属性时,SVG 图标也能与文本效果协调。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0f6ff8efb52e4cc4a022a9dccd8f8d5c~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6c62a737989f45fabe3037caf0b9ec5c~tplv-k3u1fbpfcp-watermark.image)\n\n至此,SVG 图标组件已经能应付大部分场景了。\n\n# 参考文献\n\n- [Computer font](https://en.wikipedia.org/wiki/Computer_font)\n- [一种笔划矢量字库的存取方法](https://patents.google.com/patent/CN101957837B/zh)\n\n# 结语\n\n在本节中,我们首先了解了一些字体相关的基础知识,以及使用 SVG 图标的一些优点,接着学习了实现 SVG 图标组件的基本思路和编码过程。本文写作过程中,我有感觉到字体是个很复杂的知识领域,若文中相关知识点叙述有误,还请指出!在实际项目中,对图标组件还会有一些拓展的需求,我们在下篇文章中会具体展开聊聊。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2022-11-24 19:52:15', '2024-08-15 11:00:16', 1, 40, 0, '我们接着上篇的字体图标组件实战,继续探索图标的另一个展现形式 —— SVG 。在开发 SVG 图标组件之前,还有一些问题我们必须搞清楚!', 'https://qncdn.wbjiang.cn/博客素材/994303bb7bcd44fa82f3e3263dc57001~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (241, '衍生需求:按钮集成图标组件 & 图标选择器', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏上篇文章传送门:[Web 中的字体和 SVG 图标,你了解多少?](https://juejin.cn/post/7163889112364089380)\n>\n> 本节涉及的内容源码可在[vue-pro-components c4 分支](https://github.com/cumt-robin/vue-pro-components/tree/c4)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045) 专栏第 5 篇文章【衍生需求:按钮集成图标组件 & 图标选择器】,聊聊实际业务中与图标组件相关的一些衍生需求,例如:\n\n- 怎么通过一个简单的`icon`属性就能在`a-button`中用上我们的图标组件?\n- 怎么实现一个可视化的图标选择器?\n\n# 按钮集成图标组件\n\n## 背景介绍\n\n按钮中搭配图标一起用,是再常见不过的场景了。ant-design-vue 的 Button 组件具备自定义图标的能力,具体是通过`icon`插槽实现的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c003fa88e4b14e62825ff10aa84de3ae~tplv-k3u1fbpfcp-watermark.image)\n\n虽然能实现,但是感觉写起来也挺复杂的,代码量不少,那么能不能简化成这样呢?只要通过一个`icon`属性(而不是插槽)就能把图标展示出来呢?最理想的状态是还能同时支持我们自己的业务图标。\n\n```\n// 比较理想的用法\n// 既支持\nSearch \nSearch \n```\n\n事实上,`ant-design-vue`没有支持这种能力。\n\n首先,从字符串到组件,是需要一个解析的过程,这对应`resolveComponent`,简单看下源码,`resolveComponent`内部会调用`resolveAssets`,我们发现,这需要将组件注册好,不管是注册到局部还是全局,都可以。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a92ba4505d9b422f99b8b1eac53d0516~tplv-k3u1fbpfcp-watermark.image)\n\n而 ant-design-vue 是一个通用组件库,它提供的图标都是一个个独立的组件,这些组件都在`@ant-design/icons-vue`这个包里。如果要实现字符串到组件的解析能力,就要求把图标组件都提前注册好,这就违背了按需加载的初衷。\n\n另外, ant-design-vue 也要考虑用户自定义图标的场景,所以综合来看留个插槽算是比较合理的做法。\n\n然而,对业务方来说,通常考虑的是:\n\n- **大而全**:能力丰富,既要有原始组件本身的能力,还能增加一些定制的能力;\n- **用起来方便**:提供最简单的用法;\n- **性能过得去**:没有明显的性能负担即可。\n\n那么,我们自己来尝试实现一下这些能力。\n\n## 封装按钮组件\n\n`a-button`我们也不能改,所以,需要先做一个`vp-button`,它既有`a-button`的全部能力,还能支持使用各种图标组件,这样才不至于说封装了一个组件,却牺牲了底层的能力。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/217614c39aef4a4aa401787265661d78~tplv-k3u1fbpfcp-watermark.image)\n\n我们首先要考虑的是:AButton 本身有很多属性,那么我们怎么让 VpButton 同样支持这些属性呢?\n\n有两条路子可供选择:\n\n1. 利用`v-bind=\"$attrs\"`透传属性,简单快捷。\n\n2. 将 AButton 支持的 props,都列入 VpButton 的 props 中,然后 VpButton 再原样通过属性绑定传递给 AButton,这样就能保证这些 props 的响应式依然有效。\n\n路子1虽然是最简单的,但是也存在一些问题。在 Vue2 中,因为 patch 过程中的`updateChildComponent`执行时会给`$attrs`重新赋值,这就会导致不管`$attrs`绑定的那部分属性有没有更新过,都会导致其对应的`Watcher`更新。\n\n可以用这个[codesandbox链接](https://codesandbox.io/s/relaxed-mountain-2kk5qq?file=/src/App.vue)测试一下 Vue2 的表现,打断点试试。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/61282517d51d416c8d9c4507f71a5cd2~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n在 Vue2 组件嵌套很深时,如果不合理地使用`$attrs`容易引发一些性能危机,引起某些业务逻辑不必要地执行,最终导致页面卡顿。\n\n那么 Vue3 在这方面的表现如何呢?使用`$attrs`是否也会触发不必要的更新?\n\n我们也开一个[codesandbox链接](https://codesandbox.io/s/sad-cori-2v76w3?file=/src/App.vue)测试一下。经测试,Vue3 此场景下并没有触发不必要的更新。\n\n我们在源码里寻找一下关于`$attrs`的踪迹,发现在 patch 的过程中没有直接给`$attrs`重新赋值的操作,但是有一个`hasAttrsChanged`的判断,仅当`hasAttrsChanged`为`true`时,才会执行`$attrs`的更新操作。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/66011912df4b43f0a786f4ed4b82881c~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n```js\ntrigger(instance, TriggerOpTypes.SET, \'$attrs\')\n```\n\n这就仅在`$attrs`真正变化的时候才会更新组件,应当是 Vue3 针对此问题的优化。\n\n看起来 Vue3 场景下走路子1也没什么大问题,不过为了后续更方便地生成完整的组件文档,我还是倾向于使用路子2。\n\n路子2是比较靠谱的,但是使用起来很繁琐,需要将 AButton 支持的属性重复定义在 VpButton 中。此外,一旦粗心就可能会遗漏一些属性,这就会导致功能是有缺失的,那么怎么解决这些问题呢?\n\n我的思路是:\n\n1. 想办法把 AButton 的 props 定义取出来,与我们要额外扩展的属性做一个合并,统一作为 VpButton 的 props 定义。这样一来,从外部调用者的视角来看,VpButton 支持的属性就是完整的,给人的直观感觉就是:VpButton 是 AButton 的加强版,我可以放心使用。\n2. 在 VpButton 内部需要封装 AButton,同时要从所有 props 中将属于 AButton 的那部分 props 挑选出来,传递给 AButton,这样对 AButton 来说就是无感的,因为我们传给 AButton 的属性是完全符合要求的。\n\n只要我们封装的 VButton 满足了上面这两点,这个组件就是趋近完美的,它向上对调用者提供了更强大的能力,同时向下又包容了 AButton 的能力。\n\n我们来试试看,大致查阅 ant-desigin-vue 的 Button 组件源码,我们可以发现,AButton 的属性是由这些代码构造出来的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/1554717b1b0f40ed977e3dfa9d542bb2~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/b977ad91bf454b8c9e8a47be5538aa47~tplv-k3u1fbpfcp-watermark.image)\n\n我们新建一个`button/props.ts`文件,尝试一下下面的代码,看看能不能拿到预期的 AButton 属性定义,如果能成功,那就意味着我们就不必一个一个属性地重复定义了,同时也意味着我们得到了一种扩展属性的基本方法。\n\n```\nimport buttonProps from \'ant-design-vue/es/button/buttonTypes\'\nimport { initDefaultProps } from \'ant-design-vue/es/_util/props-util\'\n\nconst _buttonProps = initDefaultProps(buttonProps(), {\n type: \'default\',\n})\n\nconsole.log(_buttonProps)\n```\n\n打印出来发现,这就是我们需要的 AButton 的属性定义:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c7ccf4e860934b428a5a3cb3b7658a87~tplv-k3u1fbpfcp-watermark.image)\n\n接着我们用一个`enhancedProps`来定义需要扩展的属性,这里先给出以下几个属性:\n\n```typescript\nexport const enhancedProps = {\n // 对应自定义图标的名称\n ico: {\n type: String,\n },\n // 图标的大小\n icoSize: {\n type: Number,\n },\n // 图标颜色\n icoColor: {\n type: String,\n },\n // 按钮主体颜色,影响边框颜色,背景色\n primaryColor: {\n type: String,\n },\n}\n```\n\n用`ico`接收图标名称,是为了避免与`AButton`的`icon`插槽冲突。\n\n然后我们把`_buttonProps`和`enhancedProps`这两部分组成一个完整的`props`。\n\n```typescript\nexport const innerKeys = Object.keys(_buttonProps)\nexport const enhancedKeys = Object.keys(enhancedProps)\n\nexport const props = {\n ..._buttonProps,\n ...enhancedProps,\n}\n\nexport type VpButtonProps = ExtractPropTypes\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a180c87b4e444db583eb726edc6329f2~tplv-k3u1fbpfcp-watermark.image)\n\n可以发现属性很完整了,其中框起来的部分是我们扩展的属性,剩下的都是 AButton 支持的属性。\n\n接下来就是看怎么使用这些属性了,直接上组件主体代码,这里用了 tsx 实现。\n\n```tsx\nimport { defineComponent } from \'vue\'\nimport { Button } from \'ant-design-vue\'\nimport IconSvg from \'../icon-svg\'\nimport { innerKeys, props as buttonProps } from \'./props\'\nimport { usePickedProps } from \'../hooks/props\'\n\nexport default defineComponent({\n name: \'VpButton\',\n props: buttonProps,\n setup(props, { slots }) {\n // 把属于 AButton 的属性挑选出来,再绑定到 AButton 上\n const innerProps = usePickedProps(props, innerKeys)\n\n return () => (\n (\n <>\n {props.ico && !props.loading ? : null}\n {slots?.default?.()}\n >\n ),\n }}\n > \n )\n },\n})\n\n```\n\n这里用到了一个`usePickedProps`方法,将 AButton 支持的属性全部挑选出来,然后绑定到 AButton 上(因为 props 中有我们扩展的属性,而这部分不需要传给 AButton)。\n\n`usePickedProps`的逻辑也不复杂,主要是基于`lodash-es`的`pick`方法进行属性挑选,然后用`computed`计算属性返回结果,这样才能保证得到的`innerProps`是具备响应式特性的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/bd169cae70574554b2b64c274fbca636~tplv-k3u1fbpfcp-watermark.image)\n\n而在图标这块的处理,除了支持通过`ico`属性直接展示`IconSvg`图标,我们依然支持通过`icon`插槽进行自定义的图标展示,这与 AButton 的默认行为是一致的。\n\n当我们在 package `playground`引入这个 VpButton 组件使用时,会发现报了一个错误`Uncaught ReferenceError: React is not defined`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2f3853f17305480b893798a7423e42a7~tplv-k3u1fbpfcp-watermark.image)\n\n这是因为我们的当前环境还不支持`jsx`,需要引入一个`@vitejs/plugin-vue-jsx`插件。\n\n```\n// 安装一下 jsx 插件\nlerna add @vitejs/plugin-vue-jsx --scope=playground --dev\n```\n\n`vite.config.ts`增加 jsx 相关配置:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9fba41080b8e4099a3d090e05f5877b8~tplv-k3u1fbpfcp-watermark.image)\n\n由于 VpButton 内部用到了 AButton 和 IconSvg 这两个组件,而这两个组件也是有定义样式的,所以我们在`button/style/index.less`中引入一下相关的样式依赖。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9a0945669342438b9f767467a8b3a7ec~tplv-k3u1fbpfcp-watermark.image)\n\n接着我们测试一下基本效果,基本上可以满足常见使用场景:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/dc18781324574b8eb7d7b72e68e0bc5a~tplv-k3u1fbpfcp-watermark.image)\n\n## ico属性支持多种图标源可行吗?\n\n那么有没有可能实现上面说的:用一个`ico`属性,既能支持 ant-design 的内置图标,又能支持由 IconSvg 组件实现的业务图标呢?我们可以尝试做一下看看。\n\n如上文所述,首先需要有一个字符串到组件的解析过程,这需要用到`resolveComponent`,这部分逻辑可以内置到 VpButton 组件中。与此同时,还需要将 ant-design 的图标注册到组件上下文中,这部分操作放在业务调用方比较合适(这可以支持按需加载),因为我们不可能把所有 ant-design 的图标都注册到 VpButton 组件中,这会让 VpButton 组件变成一个巨型组件。\n\n好,思路清楚后,我们首先实现 VpButton 内部的逻辑。为了减少判断逻辑,我们通过一个`icoSource`标识图标的来源,默认为`\"biz\"`,表示展示 IconSvg 支持的业务图标,同时支持`\"antd\"`,表示展示 ant-design 的图标。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3c877e9dedfd4f1e87c277a9fc60f3d9~tplv-k3u1fbpfcp-watermark.image)\n\n当`icoSource`的值为`\"antd\"`时,我们利用 Vue 提供的`resolveComponent`和`h`进行组件解析和渲染,否则逻辑照旧。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/de7f0b3d9a91457983f1a6b23c3d9aa2~tplv-k3u1fbpfcp-watermark.image)\n\n接着我们在`App.vue`调用一下。\n\n引入`PlusOutlined`图标组件:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4d31fc2bdffa46bc860ac9402874bbc6~tplv-k3u1fbpfcp-watermark.image)\n\n尝试通过`icoSource`和`ico`属性渲染出图标:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/93b97a7421c5417584cd7d4cd3c0161d~tplv-k3u1fbpfcp-watermark.image)\n\n结果发现,`resolveComponent`还是找不到 PlusOutlined 组件。\n\n> [Vue warn]: Failed to resolve component: PlusOutlined\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4657c7dc6c114c0483b7c4acb859f5bc~tplv-k3u1fbpfcp-watermark.image)\n\n回头看了一下源码`resolveComponent`的流程,发现它只会在当前组件实例和应用实例中去寻找组件,而`resolveComponent`是在 Button 组件中使用的,即便我们在`App.vue`中引入了 PlusOutlined 也是解析不到的,所以只能在应用实例全局注册 PlusOutlined,类似这样:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/55976e1ff75443f0b310c05cb4cfcebb~tplv-k3u1fbpfcp-watermark.image)\n\n效果就出来了:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4671c60680a44372b33a22a77e667586~tplv-k3u1fbpfcp-watermark.image)\n\n但是这样用起来也是相当繁琐,虽然实现了功能,但还不如直接用`icon`插槽简单呢,所以这条路基本上可以选择放弃了。\n\n> 这部分代码可以见这个[版本](https://github.com/cumt-robin/vue-pro-components/tree/1130957c17996e1a3aed418f5a94720a4491a0ed),相关分支上就不保留这部分代码了。\n\n如果与`unplugin-vue-components`配套使用,其提供的`AntDesignVueResolver`也支持自动识别并导入`@ant-design/icons-vue`中的图标,用起来也算方便。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e619c929eab44e36875e84a869882846~tplv-k3u1fbpfcp-watermark.image)\n\n# 图标选择器\n\n在中后台或者一些低码搭建场景中,很多地方需要动态配置图标,最常见的可能就是给菜单配图标。比较简单的实现方式就是直接用一个文本输入框配置图标名,但是这样并不直观,也容易出错,因为你不确定你输入的图标名是不是对应一个有效的图标。而且这要求操作人员熟知图标的名称,显然不是很方便。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/31e3069d201d4dbbaa10784012724e93~tplv-k3u1fbpfcp-watermark.image)\n\n那么能不能提供一个图标选择器进行可视化的配置呢?我们可以来试一试!\n\n要进行图标的选择,首先必须知道有哪些图标,也就是需要有一个图标清单。那么具体怎么做呢?\n\n一个简单粗暴的方法是:项目中维护一个数组,把 icon 名称全部都手动录入。但是这样显得很繁琐,每个业务项目都要手动录,太容易出错了。\n\n另一个方法是:从 iconfont 图标库中寻找有用的信息,基于这些信息编写脚本自动生成一个图标清单。\n\n那么 iconfont 中有哪些我们可以利用的信息呢?\n\n我最开始想的是检查 iconfont 项目调用的接口,从接口中把信息抓出来。确实找到了一个`detail.json`请求,这里有相关的 icons 数组。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2fd2e28a04664998a4ef21287e71bfbd~tplv-k3u1fbpfcp-watermark.image)\n\n> 记得前些时间还检查过,iconfont 还没有提供这个 icons 字段,可能最近优化了。\n\n虽然请求是找到了,但是还要考虑调这个请求是不是要验证 token 等身份信息。果不其然,需要验证!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/76da2a90fe86430493a49321634e64a1~tplv-k3u1fbpfcp-watermark.image)\n\n这也就意味着,如果我们想用这个能力,需要打通登录流程,先调登录接口,再调这个 detail.json 的请求。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c978625cb55849b89514247122a97fee~tplv-k3u1fbpfcp-watermark.image)\n\n只要模拟一下这个登录请求即可,看着不复杂,其实做起来不简单,首先要搞清楚 password 的加密策略,还有两个 bx- 开头的字段是怎么得来的,这需要研究一下 iconfont 相关的 js 代码。\n\n而且,我们需要把账号密码存在某个配置文件中,不是很安全,所以也不建议这样做。\n\n## 承认不完美\n\n要写这么一节,我觉得也是挺逗的。\n\n自古文人相轻,实际上,各行各业都是这样,搞技术的也逃脱不了这种怪圈。\n\n接上面,我的需求是找到图标清单,所以自然是从 iconfont 提供的一些信息中去找,从在线的信息中确实只找到了这些。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/53568085ff614aa091581a1d9331e3be~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/65dac6dd7a464d4cbd40c0d8b7795fea~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ce016f7d975840439c529b44b4d0c431~tplv-k3u1fbpfcp-watermark.image)\n\n以及调用的一些接口信息。\n\n我最大的问题就是我没有去尝试把那个 js 的后缀改为 json 试一试。事实上,cdn 上确实有这个 json 链接。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f7a5fbf660e248a88a28a57ab930eee1~tplv-k3u1fbpfcp-watermark.image)\n\n从这个 json 中取出图标清单就更简单了。但是,我之前没发现,sorry,因此写了后面一节稍微复杂的解法。\n\n> 但我至少是解决了问题。\n\n于是,某喷子找到了这个喷点,就马上开始了,我让他指条路,不知道触动了他哪条神经。\n\n终于想通了为什么 Uzi 拿不到冠军,尤雨溪在国外才能做出 Vue。\n\n> 就这样吧,EQ 闪你都不会吗?灯笼不会捡吗?\n\n还是感谢您提供的信息吧,改成从 json 取信息了。\n\n## js 链接 + 正则取得图标清单\n\n我们换个思路,既然不想登录,但是又要获得图标清单,那就只能从一些公开的资源上去做文章了。\n\n还好,iconfont 提供的 js 链接是公开的,而且这里面也包含了图标信息。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/91e03826000b4fc48daf55fb48b2d310~tplv-k3u1fbpfcp-watermark.image)\n\n我们发现,这里面有一些特征可以捕捉到,只要把符合`vp-icon-`前缀的内容提取出来,就能得到图标清单。\n\n话不多说,直接上代码,主要是一些正则的逻辑:\n\n```\nimport fs from \"fs\"\nimport axios from \"axios\"\n\nconst SVG_ICON_SCRIPT_URL = \"https://at.alicdn.com/t/c/font_3736402_d50r1yq40hw.js\"\nconst SVG_ICON_PREFIX = \"vp-icon-\"\n\nfunction getIcons(str) {\n const reg = new RegExp(`id=\"${SVG_ICON_PREFIX}([^\"]+)\"`);\n return str.match(/id=\"([^\"]*)\"/g).map((item) => item.replace(reg, \"$1\"));\n}\n\nexport async function genIconListJson() {\n try {\n const res = await axios.get(SVG_ICON_SCRIPT_URL);\n console.log(res)\n if (res.status === 200) {\n const iconList = getIcons(res.data);\n console.log(iconList);\n fs.writeFile(new URL(\"../src/assets/json/icons.json\", import.meta.url), JSON.stringify(iconList, null, 2), function (err) {\n if (err) {\n return console.error(err);\n }\n console.log(\"图标清单写入成功!\");\n });\n } else {\n console.error(res.status, res.statusText);\n }\n } catch (err) {\n console.error(err);\n }\n}\n\ngenIconListJson();\n```\n\n执行脚本后,就能得到一个 json 文件了,这里有全部的图标名称。我们特意去掉了图标前缀,因为 IconSvg 组件的 icon 属性只需要简单的名称即可,其内部会与前缀拼接。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/02fe5dcd2b6245c7baf06bdebc6eba32~tplv-k3u1fbpfcp-watermark.image)\n\n## 根据图标清单实现选择器\n\n拿到了图标清单,剩下的工作就比较简单了,无非是把图标循环渲染出来,让用户选择即可。同时提供一个搜索功能,方便在图标数量很大时能够通过名字检索。\n\n代码并不复杂,感兴趣的可以 fork 源码看一下。这里展示下图标选择器的使用效果。\n\n![图标选择器.gif](https://qncdn.wbjiang.cn/博客素材/7f518769938547c49c73f54710d2cb3d~tplv-k3u1fbpfcp-watermark.image)\n\n# 结语\n\n本文以实际业务中与图标组件相关的衍生需求为背景,介绍了如何封装一个基础组件,以及如何在封装组件时既能在基础组件之上做扩展,同时又不牺牲掉基础组件的原有能力。总的来说,这不仅仅是在讲解如何开发一个组件,更多的是介绍一种通用的上层组件封装思想,希望对大家有所帮助。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2022-12-03 20:03:35', '2024-08-12 14:26:07', 1, 98, 0, '本文是 基于Vite+AntDesignVue打造业务组件库 专栏第 5 篇文章【衍生需求:按钮集成图标组件 & 图标选择器】,聊聊实际业务中与图标组件相关的一些衍生需求...', 'https://qncdn.wbjiang.cn/博客素材/ffac7bda71e94322b14df507c91e2bce~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (242, '花1块钱让你的网站支持 ChatGPT', '[OpenAI 又放大招了,GPT3.5 API 开放使用,1分钟上手体验!](https://juejin.cn/post/7205774080535134266)\n\n最近 ChatGPT 在技术圈子可太火了,票圈也被刷屏。我也决定来凑个热闹,给自己的博客加一个 ChatGPT 对话功能。\n\n先附上[体验链接](https://blog.wbjiang.cn/chatgpt),源码在底部也可以找到。\n\n![chatgpt\\_博客效果.gif](https://qncdn.wbjiang.cn/博客素材/9f7485b5932a4ba7978753bb9ed725de~tplv-k3u1fbpfcp-watermark.image)\n\n# 体验 ChatGPT\n\n[ChatGPT](https://openai.com/) 是 Open AI 训练的一个 AI 对话模型,可以支持在多种场景下进行智能对话。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a54e959b63d140aa8a1b368cb093a1eb~tplv-k3u1fbpfcp-watermark.image)\n\n想体验 ChatGPT,首先要[注册](https://beta.openai.com/login/)账户,但是这个产品在国内网络并不能直接用,需要自行解决网络问题。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/dd375a8d7ee94ce3b3f61c452b21dd5d~tplv-k3u1fbpfcp-watermark.image)\n\n搞定网络问题后,注册时会让你提供邮箱验证,\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2423993fee494526a38f4cc7e30235b3~tplv-k3u1fbpfcp-watermark.image)\n\n接着要验证手机号,但是很遗憾国内手机号用不了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6a50e1c938c14423ab9b46c6d25dfc29~tplv-k3u1fbpfcp-watermark.image)\n\n你也可以选择用 Google 账号登录,但是最终还是要验证手机号。\n\n所以我们需要先找一个国外的能接收短信验证码的手机号,此时可以上[SMS-ACTIVATE](https://sms-activate.org/cn)。\n\n> 这是一个在这个星球上数以百万计的服务中注册帐户的网站。 我们提供世界上大多数国家的虚拟号码,以便您可以在线接收带有确认代码的短信。 在我们的服务中,还有虚拟号码的长期租赁,转发连接,电话验证等等。\n\nSMS-ACTIVATE 上的价格是卢布,我们需要使用手机号码做短信验证,经过查询可以发现,最便宜的是印度地区的手机号,零售价格是 10.5 卢布。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3129699e14154f3b981a603d00cf8f88~tplv-k3u1fbpfcp-watermark.image)\n\n按照汇率算了一下,大概是1块多RMB。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8873d8fb9bd64d25affe6eeba07612c2~tplv-k3u1fbpfcp-watermark.image)\n\nSMS-ACTIVATE 支持用某宝充值,我买了一个印度号,就可以收到来自 Open AI 的验证码了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ca6aa8a7d1794c5e8e95688356bfcaea~tplv-k3u1fbpfcp-watermark.image)\n\n注意,这个号码只是租用,是有期限的,所以我们要抓紧时间把注册流程搞完,20分钟过了,这个号码就不是你的了。\n\n注册完 Open AI 的账号后,就可以到 ChatGPT 的 [Web工作台](https://chat.openai.com/chat)体验一把 AI 对话了。\n\n![chatgpt体验.gif](https://qncdn.wbjiang.cn/博客素材/44a33e23986540f1959916633bc52311~tplv-k3u1fbpfcp-watermark.image)\n\n# 通过 API 接入 Open AI 能力\n\n体验完 ChatGPT 之后,对于搞技术的我们来说,可能会想着怎么把这个能力接入到自己的产品中。\n\n## 快速上手\n\nChatGPT 是 Open AI 训练出来的模型,Open AI 也提供了 API 给开发者们调用,[文档](https://beta.openai.com/)和[案例](https://beta.openai.com/examples)也比较全面。\n\n机器学习很重要的一个步骤就是调参,但对于前端开发者来说,大部分人肯定是不知道怎么调参的,那我们就参考官方提供的最契合我们需求的案例就好了,这个 Chat 的案例就非常符合我们的场景需要。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f1aba22c4ad5494ab4e3b8e8ee7c7800~tplv-k3u1fbpfcp-watermark.image)\n\n官方有提供一个 nodejs 的 starter,我们可以基于此快速上手测试一把。\n\n git clone https://github.com/openai/openai-quickstart-node.git\n\n它的核心代码是这么一部分,其中用到的[openai](https://www.npmjs.com/package/openai)是官方封装好的 NodeJS Library。\n\n const completion = await openai.createCompletion({\n model: \"text-davinci-003\",\n prompt: \'提问内容\',\n temperature: 0.9,\n max_tokens: 150,\n top_p: 1,\n frequency_penalty: 0,\n presence_penalty: 0.6,\n });\n\n在调用 API 之前需要先在你的 Open AI 账户中[生成一个 API Key](https://beta.openai.com/account/api-keys)。\n\n目前官方给到的免费额度是 18 刀,超过的部分就需要自己付费了。计费是根据 Token 来算的,至于什么是 Token,可以参考[Key concepts](https://beta.openai.com/docs/introduction/key-concepts)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/980be36a4df84d9782c82c9e4b013deb~tplv-k3u1fbpfcp-watermark.image)\n\n我们把上面那个 Chat 案例的参数拿过来直接用上,基本上也有个七八分 AI 回答问题的样子了,这个可以自己去试一试效果,并不复杂。\n\n接着就是研究一下怎么把这个 starter 的关键代码集成到自己的产品中。\n\n## 产品分析\n\n我之前有在自己的博客中做过一个简单的 [WebSocket 聊天功能](https://blog.wbjiang.cn/chat),而在 AI 对话这个需求中,前端 UI 部分基本上可以参考着WebSocket 聊天功能改改,工作量不是很大,主要工作量还是在前后端的逻辑和对接上面。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/1dae1627b6bc4fc98b5120a995ffeafc~tplv-k3u1fbpfcp-watermark.image)\n\nChatGPT 的这个产品模式,它不是一个常规的 WebSocket 全双工对话,而是像我们平常调接口一样,发生用户输入后,客户端发送请求到服务端,等待服务端响应,最后反馈给用户,它仅仅是从界面上看起来像是聊天,实际上不是一个标准的聊天过程。所以前后端交互主要还是靠 HTTP 接口对接。\n\n## 核心要素 Prompt\n\n在`openai.createCompletion`调用时有一个很重要的参数`prompt`,它是对话的上下文信息,只有这个信息足够完整,AI 才能正确地做出反馈。\n\n举个例子,假设在对话过程中有2个回合。\n\n // 回合1\n 你:爱因斯坦是谁?\n AI: 爱因斯坦(Albert Einstein)是20世纪最重要的物理学家,他被誉为“时空之父”。他发现了相对论,并获得诺贝尔物理学奖。\n\n第一个回合中,传参`prompt`是`爱因斯坦是谁?`,机器人很好理解,马上能给出符合实际的回复。\n\n // 回合2\n 你:他做了什么贡献?\n AI: 他为社会做出了许多贡献,例如改善公共卫生、建立教育基础设施、提高农业生产能力、促进经济发展等。\n\n第二个回合传参`prompt`是`他做了什么贡献?`,看到机器人的答复,你可能会觉得有点离谱,因为这根本就是牛头不对马嘴。但是仔细想想,这是因为机器人不知道上下文信息,所以机器人不能理解`他`代表的含义,只能通过`他做了什么贡献?`整句话去推测,所以从结果上看就是符合语言的逻辑,但是不符合我们给出的语境。\n\n如果我们把第二个回合的传参`prompt`改成`你: 爱因斯坦是谁?\\nAI: 爱因斯坦(Albert Einstein)是20世纪最重要的物理学家,他被誉为“时空之父”。他发现了相对论,并获得诺贝尔物理学奖。\\n你: 他做了什么贡献?\\nAI:`,机器人就能够理解上下文信息,给出接下来的符合逻辑的答复。\n\n // 改进后的回合2\n 你:他做了什么贡献?\n AI: 爱因斯坦对科学有着重大的贡献,他发明了相对论,改变了人们对世界、物理定律和宇宙的认识,并为量子力学奠定了基础。他还发现了...\n\n所以,我们的初步结论是:`prompt`参数应该包含此次对话主题的较完整内容,才能保证 AI 给出的下一次回答符合我们的基本认知。\n\n## 前后端交互\n\n对于前端来说,我们通常关注的是,我给后端发了什么数据,后端反馈给我什么数据。所以,前端关注点之一就是用户的输入,用上面的例子说,`爱因斯坦是谁?`和`他做了什么贡献?`这两个内容,应该分别作为前端两次请求的参数。而且,对于前端来说,我们也不需要考虑后端传给 Open AI 的`prompt`是不是完整,只要把用户输入的内容合理地传给后端就够了。\n\n对于后端来说,我们要关注 session 问题,每个用户应该有属于自己和 AI 的私密对话空间,不能和其他的用户对话串了数据,这个可以基于 session 实现。前端每次传过来的信息只有简单的用户输入,而后端要关注与 Open AI 的对接过程,结合用户的输入以及会话中保留的一些信息,合并成一个完整的`prompt`传给 Open AI,这样才能得到正常的对话过程。\n\n所以基本的流程应该是这个样子:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/167c93895c904548b4317fd2858c664c~tplv-k3u1fbpfcp-watermark.image)\n\n我们根据这个流程输出第一版代码。\n\n### 后端V1版本代码\n\n router.get(\'/chat-v1\', async function(req, res, next) {\n // 取得用户输入\n const wd = req.query.wd;\n // 构造 prompt 参数\n if (!req.session.chatgptSessionPrompt) {\n req.session.chatgptSessionPrompt = \'\'\n }\n const prompt = req.session.chatgptSessionPrompt + `\\n提问:` + wd + `\\nAI:`\n try {\n const completion = await openai.createCompletion({\n model: \"text-davinci-003\",\n prompt,\n temperature: 0.9,\n max_tokens: 150,\n top_p: 1,\n frequency_penalty: 0,\n presence_penalty: 0.6,\n stop: [\"\\n提问:\", \"\\nAI:\"],\n });\n // 调用 Open AI 成功后,更新 session\n req.session.chatgptSessionPrompt = prompt + completion.data\n // 返回结果\n res.status(200).json({\n code: \'0\',\n result: completion.data.choices[0].text\n });\n } catch (error) {\n console.error(error)\n res.status(500).json({\n message: \"Open AI 调用异常\"\n });\n }\n });\n\n### 前端V1版本关键代码\n\n const sendChatContentV1 = async () => {\n // 先显示自己说的话\n msgList.value.push({\n time: format(new Date(), \"HH:mm:ss\"),\n user: \"我说\",\n content: chatForm.chatContent,\n type: \"mine\",\n customClass: \"mine\",\n });\n loading.value = true;\n try {\n // 调 chat-v1 接口,等结果\n const { result } = await chatgptService.chatV1({ wd: chatForm.chatContent });\n // 显示 AI 的答复\n msgList.value.push({\n time: format(new Date(), \"HH:mm:ss\"),\n user: \"Chat AI\",\n content: result,\n type: \"others\",\n customClass: \"others\",\n });\n } finally {\n loading.value = false;\n }\n };\n\n![chatgpt\\_v1.gif](https://qncdn.wbjiang.cn/博客素材/11641a26e347417688a993342926096a~tplv-k3u1fbpfcp-watermark.image)\n\n基本的对话能力已经有了,但是最明显的缺点就是一个回合等得太久了,我们希望他速度更快一点,至少在交互上看起来快一点。\n\n## 流式输出(服务器推 + EventSource)\n\n还好 Open AI 也支持 stream 流式输出,在前端可以配合 EventSource 一起用。\n\n> You can also set the `stream` parameter to `true` for the API to stream back text (as [data-only server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)).\n\n基本的数据流是这个样子的:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5f6d240925294a3ba1d3f1a9f488de32~tplv-k3u1fbpfcp-watermark.image)\n\n后端改造如下:\n\n router.get(\'/chat-v2\', async function(req, res, next) {\n // ...省略部分代码\n try {\n const completion = await openai.createCompletion({\n // ...省略部分代码\n // 增加了 stream 参数\n stream: true\n }, { responseType: \'stream\' });\n // 设置响应的 content-type 为 text/event-stream\n res.setHeader(\"content-type\", \"text/event-stream\")\n // completion.data 是一个 ReadableStream,res 是一个 WritableStream,可以通过 pipe 打通管道,流式输出给前端。\n completion.data.pipe(res)\n }\n // ...省略部分代码\n });\n\n前端放弃使用 axios 发起 HTTP 请求,而是改用 EventSource。\n\n const sendChatContent = async () => {\n // ...省略部分代码\n // 先显示自己说的话\n msgList.value.push({\n time: format(new Date(), \"HH:mm:ss\"),\n user: \"我说\",\n content: chatForm.chatContent,\n type: \"mine\",\n customClass: \"mine\",\n });\n \n // 通过 EventSource 取数据\n const es = new EventSource(`/api/chatgpt/chat?wd=${chatForm.chatContent}`);\n \n // 记录 AI 答复的内容\n let content = \"\";\n \n // ...省略部分代码\n \n es.onmessage = (e) => {\n if (e.data === \"[DONE]\") {\n // [DONE] 标志数据结束,调用 feedback 反馈给服务器\n chatgptService.feedback(content);\n es.close();\n loading.value = false;\n updateScrollTop();\n return;\n }\n // 从数据中取出文本\n const text = JSON.parse(e.data).choices[0].text;\n if (text) {\n if (!content) {\n // 第一条数据来了,先显示\n msgList.value.push({\n time: format(new Date(), \"HH:mm:ss\"),\n user: \"Chat AI\",\n content: text,\n type: \"others\",\n customClass: \"others\",\n });\n // 再拼接\n content += text;\n } else {\n // 先拼接\n content += text;\n // 再更新内容,实现打字机效果\n msgList.value[msgList.value.length - 1].content = content;\n }\n }\n };\n };\n\n从代码中可以发现前端在 EventSource message 接收结束时,还调用了一个 feedback 接口做反馈。这是因为在使用 Pipe 输出时,后端没有记录 AI 答复的文本,考虑到前端已经处理了文本,这里就由前端做一次反馈,把本次 AI 答复的内容完整回传给后端,后端再更新 session 中存储的对话信息,保证对话上下文的完整性。\n\nfeedback 接口的实现比较简单:\n\n router.post(\'/feedback\', function(req, res, next) {\n if (req.body.result) {\n req.session.chatgptSessionPrompt += req.body.result\n res.status(200).json({\n code: \'0\',\n msg: \"更新成功\"\n });\n } else {\n res.status(400).json({\n msg: \"参数错误\"\n });\n }\n });\n\n我这里只是给出一种简单的做法,实际产品中可能要考虑的会更多,或者应该在后端自行处理 session 内容,而不是依靠前端的反馈。\n\n最终的效果大概是这个样子:\n\n![chatgpt\\_博客效果.gif](https://qncdn.wbjiang.cn/博客素材/9b2140b37b0242c286cca494ce49c391~tplv-k3u1fbpfcp-watermark.image)\n\n## 限制访问频次\n\n由于 Open AI 也是有免费额度的,所以在调用频率和次数上也应该做个限制,防止被恶意调用,这个也可以通过 session 来处理。我这里也提供一种比较粗糙的处理方式,具体请往下看。实际产品中可能会写 Redis,写库,加定时任务之类的,这方面我也不够专业,就不多说了。\n\n针对访问频率,我暂定的是 3 秒内最多调用一次,我们可以在调用 Open AI 成功之后,在 session 中记录时间戳。\n\n req.session.chatgptRequestTime = Date.now()\n\n当一个新的请求过来时,可以用当前时间减去上次记录的`chatgptRequestTime`,判断一下是不是在 3 秒内,如果是,就返回 HTTP 状态码 429;如果不在 3 秒内,就可以继续后面的逻辑。\n\n if (req.session.chatgptRequestTime && Date.now() - req.session.chatgptRequestTime <= 3000) {\n // 不允许在3s里重复调用\n return res.status(429).json({\n msg: \"请降低请求频次\"\n });\n }\n\n关于请求次数也是同样的道理,我这里也写得很简单,实际上还应该有跨天清理等逻辑要做。我这里偷懒了,暂时没做这些。\n\n if (req.session.chatgptTimes && req.session.chatgptTimes >= 50) {\n // 实际上还需要跨天清理,这里先偷懒了。\n return res.status(403).json({\n msg: \"到达调用上限,欢迎明天再来哦\"\n });\n }\n\n同一个话题也不能聊太多,否则传给 Open AI 的 prompt 参数会很大,这就可能会耗费很多 Token,也有可能超过 Open AI 参数的限制。\n\n if (req.session.chatgptTopicCount && req.session.chatgptTopicCount >= 10) {\n // 一个话题聊的次数超过限制时,需要强行重置 chatgptSessionPrompt,换个话题。\n req.session.chatgptSessionPrompt = \'\'\n req.session.chatgptTopicCount = 0\n return res.status(403).json({\n msg: \"这个话题聊得有点深入了,不如换一个\"\n });\n }\n\n## 切换话题\n\n客户端应该也有切换话题的能力,否则 session 中记录的信息可能会包含多个话题的内容,可能导致与用户的预期不符。那我们做个接口就好了。\n\n router.post(\'/changeTopic\', function(req, res, next) {\n req.session.chatgptSessionPrompt = \'\'\n req.session.chatgptTopicCount = 0\n res.status(200).json({\n code: \'0\',\n msg: \"可以尝试新的话题\"\n });\n });\n\n# 结语\n\n总的来说,Open AI 开放出来的智能对话能力可以满足基本需求,但是还有很大改进空间。我在文中给出的代码仅供参考,不保证功能上的完美。\n\n附上[源码地址](https://github.com/cumt-robin/vue3-ts-blog-frontend),可以点个 star 吗,球球了\\[认真脸]。\n\n关注[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),一起聊聊技术。', '2022-12-15 09:50:44', '2024-10-08 08:06:42', 1, 195, 0, '最近 ChatGPT 在技术圈子可太火了,票圈也被刷屏。我也决定来凑个热闹,给自己的博客加一个 ChatGPT 对话功能。', 'https://qncdn.wbjiang.cn/博客素材/0089138f69b54826ab5d7c7463b2af5c~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (243, '实现一个靠谱好用的全屏组件,顺手入门 Headless 组件', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏上篇文章传送门:[衍生需求:按钮集成图标组件 & 图标选择器](https://juejin.cn/post/7166029886128128014)\n>\n> 本节涉及的内容源码可在[vue-pro-components c5 分支](https://github.com/cumt-robin/vue-pro-components/tree/c5)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\") 专栏第 6 篇文章【实现一个靠谱好用的全屏组件,顺手入门 Headless 组件】,聊聊一个使用频率还挺高的组件——全屏组件,顺便了解下什么是 Headless 组件,并尝试动手将一个普通组件改造成 Headless 组件。\n\n# 全屏组件\n\n我们在项目中或多或少会用到进出全屏的功能,这样可以最大化利用可视区域,但是,实现一个完善的全屏功能并不简单。\n\n首先,各浏览器内核对于全屏 API 的实现不一样,你可能会看到诸如`requestFullscreen`, `webkitRequestFullScreen`, `mozRequestFullScreen`, `msRequestFullscreen`之类的进入全屏的方法,退出全屏的方法也不例外。\n\n其次,各浏览器中能用来判断全屏状态的属性和方法也不尽相同,比如`document.fullscreenElement`, `document.webkitFullscreenElement`等等,甚至有的情况下用`document.fullscreenElement`也无法准确反映全屏的状态,比如你在 Chrome, Edge, Firefox 等浏览器中通过 F11 按键进入全屏后,`document.fullscreenElement`的值会是`null`,并且`fullscreenchange`事件也不会触发;而通过调用`requestFullscreen()` API 进入全屏后,`document.fullscreenElement`的值就是正确的。\n\n对于做项目的开发者们来说,这种不一致就让人很恼火,因为我们仅靠`document.fullscreenElement`并不能确保在界面上可以反馈正确的状态,此时我们需要寻找一种方法 hack,解决这种不一致问题。\n\n![全屏状态不一致.gif](https://qncdn.wbjiang.cn/博客素材/b576d07532684e308e6ca2ef86b51854~tplv-k3u1fbpfcp-watermark.image)\n\n全屏/退出全屏的触发方式比较多,可能有通过按键`F11`, `ESC`等触发,也有可能通过监听某个界面元素的交互事件并结合全屏 API 触发,这会让全屏的状态判断变得更复杂。\n\n为了解决这些问题,我们有必要把这些繁琐和不确定性集中处理掉,对外提供干净、简洁、一致的 API。那么我们要做哪些事情呢?我想大概有以下几点:\n\n- 检测当前环境是否允许/支持全屏能力(对应`fullscreenEnabled`)。\n- 提供进入/退出全屏的 API(名字可以是`enterFullscreen`, `exitFullscreen`)。\n- 提供统一的判断全屏状态的方法(名字可以是`isFullscreen`)。\n- 提供获取全屏元素的方法(名字可以是`getFullscreenElement`)。\n- 提供监听/取消监听全屏事件的能力(名字可以是`subscribeFullscreenChange`, `unsubscribeFullscreenChange`)\n\n## 检测当前环境是否允许/支持全屏能力\n\n由于浏览器厂商的具体实现差异,可能会出现部分浏览器不支持全屏 API的情况,或者有提供某种配置或开关,能够做到启用/禁用全屏特性。因此最保险的做法是:在我们使用全屏 API 之前,做一次全屏特性支持度检测。\n\n检测的逻辑并不复杂,只要将标准的`fullscreenEnabled`用上,同时将浏览器前缀考虑在内即可。\n\n```\n/**\n * @description 判断浏览器当前状态是否允许启用全屏特性\n */\nexport function isFullscreenEnabled(): boolean {\n return !!(document.fullscreenEnabled || document.webkitFullScreenEnabled || document.mozFullScreenEnabled || document.msFullScreenEnabled);\n}\n```\n\n## TypeScript 类型扩展\n\n但是我们可以发现,在使用 TypeScript 编写这部分代码时,IDE 会在类型上给我们抛出错误信息,这是因为标准的`lib.dom.d.ts`中没有声明带有各个浏览器前缀的 API,所以是不能直接用`webkitFullScreenEnabled`, `mozFullScreenEnabled`等方法的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a4752a67e24b43df866a4a0550370a09~tplv-k3u1fbpfcp-watermark.image)\n\n而为了照顾各种浏览器,我们又不得不写这些兼容代码。因此,我们需要对`interface Document`做一些扩展,使得扩展出来的类型能够支持调用`webkitFullScreenEnabled`等方法。\n\n考虑到`document`对象是浏览器运行时的全局属性,第一种做法是直接在`global`上扩展`Document`接口。\n\n```\ndeclare global {\n interface Document {\n webkitFullScreenEnabled?: boolean\n mozFullScreenEnabled?: boolean\n msFullScreenEnabled?: boolean\n }\n}\n```\n\n在`.ts`文件中,通过`declare global`可以扩展全局类型,再依靠`interface`的 Merge 能力,我们就能对`Document`接口进行扩展,补充一些运行时特有的属性或方法。此时,我们可以观察到类型错误信息已经不存在。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3b8cb3b7ef4f4bf695596282c18d9abb~tplv-k3u1fbpfcp-watermark.image)\n\n另一种做法是定义一个子类型(SubType)继承`Document`接口,我们把这个子类型命名为`EnhancedDocument`,再对这个子类型做扩展,接着用类型断言将`document`对象断言为`EnhancedDocument`类型。\n\n```\ninterface EnhancedDocument extends Document {\n webkitFullScreenEnabled?: boolean\n mozFullScreenEnabled?: boolean\n msFullScreenEnabled?: boolean\n}\n```\n\n> Sometimes you will have information about the type of a value that TypeScript can’t know about.\n\n类型断言是一个从抽象到更具体的做法,有时候我们能知道一些 TypeScript 无法感知的类型信息。在 TypeScript 层面,它认为 document 就是 Document 类型,这是因为 TypeScript 无法确定具体的运行时环境是什么样的。而作为开发者,我们很清楚,当代码在浏览器执行时,它可能会有`webkitFullScreenEnabled`或`mozFullScreenEnabled`等可选属性(取决于浏览器实现),所以断言为`EnhancedDocument`类型也是合理的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/72f7202f76494d1b873a3ac330baee6e~tplv-k3u1fbpfcp-watermark.image)\n\n## 进入/退出全屏\n\n对于进入全屏而言,触发的目标元素可能是`document.body`,也可能是具体的某一个页面元素。考虑到调用[requestFullscreen](https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullScreen)会返回一个 Promise,我们可以将`enterFullscreen`封装为一个异步函数。\n\n```typescript\n/**\n * 进入全屏\n * https://developer.mozilla.org/zh-CN/docs/Web/API/Element/requestFullScreen\n * @param {EnhancedHTMLElement} [element=document.body] - 全屏目标元素,默认是 body\n * @param {FullscreenOptions} options - 全屏选项\n */\nexport async function enterFullscreen(element: EnhancedHTMLElement = document.body, options?: FullscreenOptions) {\n try {\n if (element.requestFullscreen) {\n await element.requestFullscreen(options)\n } else if (element.webkitRequestFullScreen) {\n await element.webkitRequestFullScreen()\n } else if (element.mozRequestFullScreen) {\n await element.mozRequestFullScreen()\n } else if (element.msRequestFullscreen) {\n await element.msRequestFullscreen()\n } else {\n throw new Error(\'该浏览器不支持全屏API\')\n }\n } catch (err) {\n console.error(err)\n }\n}\n```\n\n退出全屏有一点不一样,因为退出全屏的 API 只在 Document 接口中有定义,这一点可以参考[Fullscreen API Standard](https://fullscreen.spec.whatwg.org/)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8e93d27f61fb406d808abfc56e76756b~tplv-k3u1fbpfcp-watermark.image)\n\n退出全屏的代码封装如下:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e1d87e8050f64712acd95c050c832f59~tplv-k3u1fbpfcp-watermark.image)\n\n其中有一个`webkitExitFullscreen`和`webkitCancelFullScreen`让我迷惑了一会,最后从 WebKit JS 的文档中了解到已经不建议使用`webkitCancelFullScreen`了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/fc5e4fc5bf584541bc293575e2679f28~tplv-k3u1fbpfcp-watermark.image)\n\n为了避免写太多`as`类型断言,这里通过一个变量`doc`接收了`document`的值,同时将`doc`的类型声明为`EnhancedDocument`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/246d56ac93444015b20aa284776596fe~tplv-k3u1fbpfcp-watermark.image)\n\n从类型兼容的角度看,`EnhancedDocument`是`Document`的子类型,一个父类型的值(document)赋给一个子类型的变量(doc)看起来似乎不是类型安全的,但是实际赋值过程中并没有报类型错误,这似乎有违我之前的认知。\n\n> 你可以把狗赋值给动物类型,但是不能把动物赋值给狗类型。这就符合类型安全。\n\n仔细观察后,我发现这是因为`EnhancedDocument`扩展的属性都是可选的,这种时候,TypeScript 会认为`EnhancedDocument`和`Document`是互相兼容的。从类型的使用上来看也是安全的,如果你要用到可选属性,必然少不了要用到类型守卫。\n\n一旦我们给`EnhancedDocument`增加一个必选属性,这种赋值就违背类型兼容了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3dd8694be445461380bdd7a926a315d8~tplv-k3u1fbpfcp-watermark.image)\n\n## 获取全屏元素\n\n获取全屏元素也只能通过`document`上的`fullscreenElement`属性取得,这在标准中也有定义。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c61d88b6e0794b578b74b24181be9f9c~tplv-k3u1fbpfcp-watermark.image)\n\n代码相对简单,封装如下:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f50fffe124f445bcbcd134690f2867f8~tplv-k3u1fbpfcp-watermark.image)\n\n## 判断全屏状态\n\n标准中没有告诉我们怎么判断全屏状态,但是我们可以在【获取全屏元素】的基础上得到启发。如果通过`getFullscreenElement`函数得到的结果不是`null`,就可以认为当前是全屏状态。\n\n```\n/**\n * @description 判断当前是否是全屏状态\n */\nexport function isFullscreen(): boolean {\n return !!getFullscreenElement() || window.innerHeight === window.screen.height\n}\n```\n\n为了确保准确性,我还加了一个或的逻辑(判断视口高度和屏幕高度是否一致)。\n\n## 监听/取消监听全屏事件\n\n事件监听也不复杂,主要是将参数的支持做好,并且把浏览器兼容性考虑在内。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5de4c561199e4e189ac0f433b87b2221~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ae42722df02e43548e9c37a783fd89ae~tplv-k3u1fbpfcp-watermark.image)\n\n## 全屏状态一致性问题\n\n前面介绍了好几个应用层面的 API,但是我们还遗漏了一个重要问题,就是在上文中提到的 F11 按键和调用 API 的不一致问题,这会导致我们在获取全屏元素和判断全屏状态时都有可能出错。\n\n我的做法是:既然 F11 的行为与预期不一致,那我就将 F11 按键逻辑优化一下,禁止其默认行为(进入全屏),并根据当前是否是全屏状态调用`enterFullscreen()`或者`exitFullscreen()`。这样一来,就能保证进入全屏的入口都是通过 API 触发的,从而保证全屏状态的一致性。\n\n```\n/**\n * 阻止F11按键的默认行为,并根据当前的全屏状态调用进入/退出全屏,\n * 解决通过F11按键和API两种方式进入全屏时出现的状态不一致问题。\n */\nexport function patchF11DefaultAction(): void {\n window.addEventListener(\'keydown\', (e) => {\n // https://w3c.github.io/uievents-code/\n if (e.code === \'F11\') {\n e.preventDefault()\n if (isFullscreen()) {\n exitFullscreen()\n } else {\n enterFullscreen()\n }\n }\n })\n}\n```\n\n如果您想了解全屏API更细致的内容,可以查阅[Fullscreen API Standard](https://fullscreen.spec.whatwg.org/)。\n\n## 封装为 Vue 组件\n\n对基础的全屏API做了封装后,我们就可以在此基础上封装一个全屏业务组件了。\n\n核心逻辑不复杂,主要是:\n\n- 根据当前是否是全屏状态,在 UI 上提供进入/退出全屏的能力。\n- 在适当的时机检查全屏状态,比如挂载/全屏事件触发后。\n- 提供函数类型的属性`getElement`,让调用者可以自由选择进入全屏的目标元素。之所以提供函数类型的`getElement`,是为了兼容 dom 异步挂载的情况。\n\n由于不同的项目可能对全屏这块的 UI 实现有不同的要求,这里就不细说了,唯一要注意的是全屏态的叠加问题,如果你希望控制 top layer 的叠加问题,就需要在逻辑中控制好进出全屏的顺序问题(比如先退出,再进入,保证只有一个全屏 layer)。注意看 body 和 div 标签右侧的 top-layer。\n\n![全屏top-layer叠加.gif](https://qncdn.wbjiang.cn/博客素材/b92d1dee78cc420da19fa7efe8a47e35~tplv-k3u1fbpfcp-watermark.image)\n\n如果你想要了解组件的具体实现,可以前往[源码](https://github.com/cumt-robin/vue-pro-components/blob/c5/packages/vue-pro-components/src/fullscreen/fullscreen.vue)查看。\n\n# Headless 组件\n\nHeadless 组件的概念可以类比于 Headless 浏览器,其核心是一种重逻辑、轻 UI 的思想。\n\n> 引用 TanStack Table 给出的介绍:\n>\n> **Headless UI** is a term for libraries and utilities that provide the logic, state, processing and API for UI elements and interactions, but **do not provide markup, styles, or pre-built implementations**.\n\n虽然各大流行组件库都提供了较为通用的样式,并且也能通过覆盖样式支持一定程度上的定制。但是,这种 UI 范式也很难满足复杂的定制需求,我们可能会有这样的困惑:\n\n- 明明逻辑很相似,我却无法复用这个组件,需要改源码或者重新开发一个新组件。\n- 这个组件很契合我的需求,需求做到一半时发现:就差一个 div 不能定制了,其他的都满足需求......\n- 本来 2 人天的需求,因为某个 UI 组件不可控,直接导致人天翻倍。\n\n对于业务开发者来说,我们可能会提出这样的诉求:组件库能不能在提供一套 UI 实现的同时,把组件的所有状态和 API 都开放出来,让我们有自行实现 UI 渲染的可能性呢?这在某种程度上和 Headless 组件的理念不谋而合。\n\n## 我对 Headless 的理解\n\n介绍 Headless 组件的文章也有不少了,这里简单谈谈我对 Headless 组件的一点粗浅的理解和看法。\n\n在我看来,Headless 组件适合的场景是:\n\n- 组件逻辑相对简单,但是 UI 通用性不强,经常需要根据业务需求定制 UI 的场景。\n- 组件逻辑很复杂,需要通过抽象来实现复用,但是服务的上层通常不是具体的业务项目,大概率是组件库。\n- 跨框架复用,状态和逻辑用纯 js 管理,上层应用再针对框架去做适配层。\n\n举实际的例子说明下:\n\n场景1:我要实现一个全屏组件,但是有的业务项目希望全屏组件**对应的 UI 是一个按钮**,有的业务项目希望是一个**图标**,有的希望是**图标 + 文字**,甚至有更多可能性......虽然在 UI 方面有**多样性**的需求,但是**底层逻辑都是一样或类似**的,无非就是控制进出全屏、监听全屏的状态等。这种时候,提供一个可复用的 Hook 或者 Headless 组件是值得考虑的。\n\n场景2;我所在的公司是字节挑逗(瞎编的),公司有两个子品牌,一个是 dy,一个是 tt,两个团队都有一套组件库,都实现了比较复杂的 Table, Form 等组件,并且都服务了很多个上层业务,可能从直观上看,两套组件库主要是 UI 长得有点不一样,但是底层逻辑差不多。此时,我希望**两个品牌方团队能共用一套逻辑实现组件库,将关键逻辑下沉**。那么 Headless 组件可能是一个解决方案。\n\n场景3:我所在的公司是字节挑逗,公司的前端框架既有 Vue,也有 React,在这两套框架之上,都实现了对应的组件库,此时我想把逻辑下沉实现更大程度复用,**状态和逻辑不再依赖任何框架**(纯 js 撸一套,可能再用个类封装下),而在具体的框架之上再做**适配**工作(将**底层封装好的状态和逻辑**与**框架中的状态/属性/事件等概念**结合起来)。当然,这**也适用于跨平台的场景**。\n\nHeadless 是**直接服务业务方**,还是**服务特定框架下的 UI 组件库**,亦或是**对接框架或平台的适配层**,都是有可能的,这需要结合实际场景来考虑。Headless 不是万能和普适的,但确实给我们提供了一个新的值得探索的思路。\n\n## 开发一个 Headless 组件\n\n虽然 Headless 组件也火了一段时间了,但是目前在社区中还没有形成对 Headless 的共识,没有一个我们公认为**最佳实践**的做法。我们的第一个问题可能是:我开发的 Headless 组件要对外输出什么内容?是一个组件,还是一段逻辑?\n\n从 Headless 组件的中心思想——**逻辑层与表现层解耦**(具体表现为:内部封装状态和逻辑,对外支持 UI 的高度定制化)来看,这似乎与 Render Props / 作用域插槽 / Hooks 等概念有一定的相似性。如果要跨框架或者跨平台,Headless 组件可能也是纯 JS 的。这就决定了 Headless 组件并不是拘泥于某一种特定的形式,从现在社区中有的一些产品中,我们也能看出一些端倪。\n\n- 比如 Semi Design 就将一个组件分为了 Foundation 和 Adapter 部分,Foundation 负责实现组件通用的 JS 逻辑,Adapter 则是针对各个前端框架的适配层。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8ceb99297116455dbefb606c33151456~tplv-k3u1fbpfcp-watermark.image)\n\n- [React Hook Form](https://react-hook-form.com/)也是一种 Headless 的实现,其在 Hook 内部把表单的核心逻辑都实现了,对外提供了状态,方法等,你只要拿着暴露出来的状态和 API,与视图做交互即可,这样一来,你可以在表单 UI 的实现上发挥充分的想象力,而不是局限于修改 css 或者拿着几个有限的 Render Props 做定制。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7d6f64d2a7054410b9cfbadd8e22f8f1~tplv-k3u1fbpfcp-watermark.image)\n\n- 还有直接挂上 Headless 招牌的 TanStack Table。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/41dff1655b43428487658d602cf33b8a~tplv-k3u1fbpfcp-watermark.image)\n\nTanStack Table 在底层用纯 JS 实现了通用的 core 核心,并在上层提供了各大前端框架的 Adapter,当然你也可以选择直接用它的核心模块`createTable`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/dadf25bf144b4415a2970574a7356189~tplv-k3u1fbpfcp-watermark.image)\n\n以 Vue 为例,对外提供的`useVueTable`就是将`createTable`核心与 Vue 的各个 API 做了 binding。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/cd87d88ebf414a56b4203bc79041716c~tplv-k3u1fbpfcp-watermark.image)\n\n你可能会认为这跟 Hooks 之类的没有区别,这无可厚非,它们确实很相似。不过换个角度看,你可以认为 Hooks 之类的技术底座,是实现 Headless 组件的一种方式或者途径,但是它们并不是严格意义上的一回事。\n\n以我们目前实现的这个全屏组件而言,其实它最适合的 Headless 形式是 Hook。首先,我做的这个组件库是面向 Vue 框架的,并不需要像 Semi Design 或者 TanStack Table 这类方案一般提供 JS 层面的抽象。因此,我们借助 Vue Composition API,就能很快抽象出一个可复用的 Headless 组件,这样一来,业务方基于此就能很快定制出自己想要的 UI 效果。\n\n> 说了一圈,好像又陷入僵局了。额,Headless 可以是 Hook,也可以不是,不要纠结。\n\n那么我们就以这个全屏组件为例说说,怎么做一个 Headless 组件。\n\n不管 UI 怎么变,其实只关注两个事情:\n\n- 当前是否为全屏状态\n- 切换全屏状态的 API\n\n所以,我们可以把逻辑抽象成这样,对外只暴露`isTargetFullscreen`和`toggleFullscreen`即可:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3354dcf7e0cf4aedb5a5fe5c9753989a~tplv-k3u1fbpfcp-watermark.image)\n\n这样一来,我们封装的全屏组件就能以这个 Hook 为基础简化:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d5b767f1ce324c86a81ddacbdeff054f~tplv-k3u1fbpfcp-watermark.image)\n\n同时,外部也可以直接使用`@vue-pro-components/headless`提供的`useFullscreen`能力,实现 UI 自主可控(比如用一个开关组件承载全屏能力)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/04511af35001421aa86ed567adb7cc86~tplv-k3u1fbpfcp-watermark.image)\n\n![useFullscreen.gif](https://qncdn.wbjiang.cn/博客素材/3e721d092d0f4552ad069cd8b24d4df5~tplv-k3u1fbpfcp-watermark.image)\n\n# 结语\n\n本文和前2篇文章都聚焦于**怎么实现基础的复杂度不高的业务组件**,看起来可能有点枯燥乏味,但也是为了**打个基础,引导部分还不太熟悉组件开发的读者慢慢进入状态,掌握组件开发的一些基本思想**。实际上,**开发组件**和**发布可用的组件**之间还**隔着一条鸿沟**,这就是从开发环境到生产环境必经的路,也是组件库研发过程中最复杂的部分。要越过这条鸿沟,就必须掌握一些工程化能力。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2023-01-05 20:51:00', '2024-11-02 19:44:07', 1, 138, 0, '聊聊一个使用频率还挺高的组件——全屏组件,顺便了解下什么是 Headless 组件,并尝试动手将一个普通组件改造成 Headless 组件。', 'https://qncdn.wbjiang.cn/博客素材/d158b670810c4830a267fc2d21abcf7e~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (244, '在发布组件库之前,你需要先掌握构建和发布函数库', '> 本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!\n>\n> 专栏上篇文章传送门:[实现一个靠谱好用的全屏组件,顺手入门 Headless 组件](https://juejin.cn/post/7171274503152992287)\n>\n> 专栏下篇文章传送门:[函数库Rollup构建优化](https://juejin.cn/post/7176938419392774203)\n>\n> 本节涉及的内容源码可在[vue-pro-components c6 分支](https://github.com/cumt-robin/vue-pro-components/tree/c6)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\") 专栏第 7 篇文章【在发布组件库之前,你需要先掌握构建和发布函数库】,聊聊怎么构建和发布一个函数库。\n\n如上篇文章结语所述,**开发组件**和**发布可用的组件**之间还**隔着一条鸿沟**,这就是从开发环境到生产环境必经的路,也是组件库研发过程中最复杂的部分。要越过这条鸿沟,就必须掌握一些工程化能力。\n\n然而,构建和发布组件库是一个较复杂的体系化的工程,构建组件库不仅要处理 js, ts,可能还要处理 jsx, tsx, 样式等内容,如果采用的开发框架是 Vue,你可能还需要处理 SFC 的 parse, transpile 等过程。总之,这中间会涉及很多种 DSL(领域特定语言)的处理,还要注意各个工序的顺序问题,这听起来似乎不是很简单的一件事,容易让初学者摸不着头脑。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e13d1040da3e4610bba7e86dc6ba1abc~tplv-k3u1fbpfcp-watermark.image)\n\n为了打破这种迷茫,我们可以将构建整个组件库的工作拆解出来,选择从某一个方向切入,由点到面逐个突破,最终形成构建组件库的全局思维。那么最适合作为我们学习入口的当然是函数库的构建,因为它通常只涉及 JS/TS,这是我们最熟悉的领域。\n\n# 构建函数库\n\n## 为什么要做构建工作?\n\n截至到目前,我们在本专栏中实现的一些组件/函数/Hook等内容都还停留在源码层面,基本上是以`.ts`, `.tsx`, `.vue`等形式存在的,并且我们可以发现,`package.json`中的`main`入口都是`index.ts`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/bf8c2b2b75634468b4e5927be2849658~tplv-k3u1fbpfcp-watermark.image)\n\n而在我们的认知中,我们用的一些常见的库,它们提供的`main`, `module`等入口通常是`xxx.js`,而不是用一个`.ts`文件作为入口。\n\n这并不是说,不能把 TS 之类的源码发布到 npm 上并作为引用入口,实际上只要使用依赖的项目方把构建的流程打通,也不是不可行。但是对于项目方来说,我引用一个依赖,就是要用标准化的东西,拿来即用,如果你让我自己把构建流程做出来,那我可能就不想用了。\n\n简单的库还好说,可能接入 Webpack 或者 Vite 之类的工具就搞定了。但是对于一些复杂的库来说,从源码到输出标准化的制品会经历很多道工序,你不能寄希望于调用方把这个事情做了,因此库的维护者非常有必要做好构建工作。\n\n## 做哪些构建工作?\n\n一个典型的 npm 包,可能会在其`package.json`中包含以下关键字段:\n\n```\n{\n // ...省略部分字段\n \"main\": \"lib/index.js\",\n \"module\": \"es/index.js\",\n \"types\": \"types/index.d.ts\",\n \"unpkg\": \"dist/index.js\",\n \"jsdelivr\": \"dist/index.js\",\n \"files\": [\n \"dist\",\n \"lib\",\n \"es\",\n \"types\"\n ],\n // ...剩余字段\n}\n```\n\n- lib 目录下输出的是符合 CJS 模块规范的产物,通过`main`字段指定。\n- es 目录下输出的是符合 ES 模块规范的产物,通过`module`字段指定。\n- types 目录用于放置类型声明文件,也可以通过`@types/xxx`来提供类型声明。\n- unpkg 和 jsdelivr 用于通过 cdn 访问发布在 npm 上的 umd 内容。以我之前发布的一个[进度条组件](https://juejin.cn/post/6844903990610624520)为例,你只要按这个格式去访问,就能得到你发布的内容。\n\n```\nhttps://unpkg.com/vue-awesome-progress@1.9.7/dist/vue-awesome-progress.min.js\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6187e45aed334893a4c380d72f3e16be~tplv-k3u1fbpfcp-watermark.image)\n\njsdelivr 也是类似的,只不过是路径前缀有点区别。\n\n```\nhttps://cdn.jsdelivr.net/npm/vue-awesome-progress@1.9.7/dist/vue-awesome-progress.min.js\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9599f50090b1489b82ca24339c9605b8~tplv-k3u1fbpfcp-watermark.image)\n\n同样地,以 vue-pro-components 这个包为例,之前讲解简单发布流程时也发布到了 npm,因此也可以通过 cdn 访问到。\n\n```\nhttps://unpkg.com/vue-pro-components@0.0.2/lib/vue-pro-components.js\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/82d77e75579740b6add613c2493bf143~tplv-k3u1fbpfcp-watermark.image)\n\n> 由于先前没有写什么实际内容就以教程的形式发布了,纯属是浪费资源了。建议不要随意发布没有意义的包。\n\n- files 则是指定发布和安装时包含哪些文件或目录(支持 glob pattern),合理的配置可以减少 publish 和 install 的资源数。如果不做任何配置,就会发布和安装整个工程,这实际上是一种浪费。\n\n根据前面的叙述,我们可以知道,一个函数库大体上要提供符合 ESM, CJS, UMD 模块规范的制品。\n\n从 TS 源码到 ESM, CJS, UMD 等规范下的制品,其实就是对应打包构建的过程了。\n\n## 怎么构建函数库?\n\n先画个图列举一下我们要做什么事情:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2793883d0438478ab821c25955083b48~tplv-k3u1fbpfcp-watermark.image)\n\n再确定哪些事情是串行的,哪些事情可以并行做。\n\n仔细品味,不难想明白除了清理目录(dist, es, lib, types 等目录)的工作需要先行,其他的工作都可以并行执行(因为它们之间没有依赖关系)。所以,整个构建的任务流大概是这样的:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8524826f2a0549d5b15ae9326238289c~tplv-k3u1fbpfcp-watermark.image)\n\n大概的流程梳理清楚后,就可以逐个实现任务,并且把所有任务有序组织起来。\n\n在打包函数库这方面,rollup 是一个绝佳的选择。\n\n```\nyarn add -DW rollup\n```\n\n为了组织任务流,我们需要选用一个好用的工具,而 gulp 就是这个不二之选。\n\n```\nyarn add -DW gulp\n```\n\ngulp 默认采用的是 CJS 模块规范,这是执行 Node 脚本时的常规操作。\n\n而 Rollup 默认支持 ES6 的配置写法,这是因为 Rollup Cli 内部会处理配置文件。\n\n> 引用自 rollup 官网\n>\n> *Note: Rollup itself processes the config file, which is why we\'re able to use `export default` syntax – the code isn\'t being transpiled with Babel or anything similar, so you can only use ES2015 features that are supported in the version of Node.js that you\'re running.*\n\n一个是 CJS,一个是 ESM,这让两者的结合出现了一点阻碍。还好,gulp 4.x 版本也提供了使用 ESM 编写任务的指导性文档,\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4e469282115c4a1487762bcbc4ae134e~tplv-k3u1fbpfcp-watermark.image)\n\n并且推荐我们采用`gulpfile.babel.js`来组织我们的配置文件,这背后依赖了`@babel/register`,而`@babel/register`底层是用到了 NodeJS 的 require hook。\n\n> 引用自 babel 官网\n>\n> `@babel/register` uses Node\'s `require()` hook system to compile files on the fly when they are loaded.\n\n其他可选的方案还有 sucrase/register。\n\n基于此,我们可以做到统一使用 ESM 来组织构建流程。\n\n## 清理目录\n\n因为在开始新的构建工作之前可能存在上一次构建的产物,所以对于构建产生的 dist, es, lib, types 等目录,我们需要将其清理干净,这本质上是文件操作,但是在 gulp 生态中有很多插件可以让我们选择,就没必要自己手撸一个文件清理的流程了。这里我们选用[gulp-clean](https://www.npmjs.com/package/gulp-clean)。\n\n文件处理最重要的是把路径设置正确,否则一波类似`rm -rf`的操作,可能就真的啥都没了,特别是当你写完的代码还没提交到 git 时,一波命令行操作那就是血与泪的教训(本人亲身经历,二次撸码真的痛苦)。\n\n我们把常用的路径放在`build/path.js`中维护。\n\n```\nimport { resolve } from \"path\";\n\n// 工程根目录\nexport const ROOT_PATH = resolve(__dirname, \"../\");\n\n// utils 包的根目录\nexport const UTILS_PATH = resolve(ROOT_PATH, \"./packages/utils\");\n```\n\n接着就可以写 gulpfile 了。\n\n```\nimport { src } from \"gulp\";\nimport clean from \"gulp-clean\";\nimport { UTILS_PATH } from \"./build/path\";\n\n// 待清理的目标目录\nconst ARTIFACTS_DIRS = [\"dist\", \"es\", \"lib\", \"types\"]\n\n// 把清理的过程稍微封装下,便于各个子包都能用上\nfunction cleanDir(dir = \"dist\", options = {}) {\n return src(dir, { allowEmpty: true, ...options }).pipe(clean({ force: true }))\n}\n\n// 暴露出清理 utils 包产物目录的方法\nexport const cleanUtils = cleanDir.bind(null, ARTIFACTS_DIRS, { cwd: UTILS_PATH })\n```\n\n我们目前还没有实现打包过程,可以先加几个临时文件测试一下。\n\n![清理工作.gif](https://qncdn.wbjiang.cn/博客素材/a60c228603a84f68a64e40a29305a379~tplv-k3u1fbpfcp-watermark.image)\n\n## 构建目标产物\n\n构建工作就是 Rollup 的舞台了,我们把各个构建的子任务用 Rollup 组织好后让 gulp 去调用即可。\n\n我们先看看 Rollup 会干什么,\n\n> Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger and more complex, such as a library or application.\n\n看这意思,应该是会把多个文件打包成一个 bundle。一个入口文件,引用了其他模块,模块下面可能还有引用其他的依赖,这会形成一个依赖图,最终根据 format 参数打包成一个符合指定模块规范的 bundle,这比较符合我们的常规思维。但是,对于库开发者来说,我不仅要打包出符合模块规范的内容,通常还要生成独立的文件,用于支持按需加载等场景。就像 lodash,它有很多个工具函数,打包后除了提供 bundle,也会提供很多独立的 js 模块,我们可以单独引用某一个模块,配合一些工具,还能做到按需引入。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7c3a8fb50e1b4a76b9b7b8b0e539e9f4~tplv-k3u1fbpfcp-watermark.image)\n\n## 构建 UMD bundle\n\n凡事从易到难,我们还是先从最简单的生成 UMD bundle 开始。\n\n由于我们的源码是用 ts 写的,所以要引入一个插件[@rollup/plugin-typescript](https://www.npmjs.com/package/@rollup/plugin-typescript)。\n\n入口文件就用`packages/utils/src/index.ts`即可,它引用了其他独立的模块,这样就能把 utils 的各个工具函数都打包到一起。\n\n```\n// packages/utils/src/index.ts\nexport * from \'./install\'\nexport * from \'./fullscreen\'\n```\n\n考虑到要用 gulp 集成,我们采用的是 Rollup 提供的 Javascript API 来编写构建流程。\n\n```\nimport { rollup } from \'rollup\'\nimport rollupTypescript from \'@rollup/plugin-typescript\'\nimport { resolve } from \'path\'\nimport { UTILS_PATH } from \'./path\'\n\nexport const buildBundle = async () => {\n // 调用 rollup api 得到一个 bundle 对象\n const bundle = await rollup({\n input: resolve(UTILS_PATH, \'src/index.ts\'),\n plugins: [rollupTypescript()],\n })\n\n // 根据 name, format. dir 等参数调用 bundle.write 输出到磁盘\n await bundle.write({\n name: \'VpUtils\',\n format: \'umd\',\n dir: resolve(UTILS_PATH, \'dist\'),\n sourcemap: true\n })\n}\n```\n\n接着,就可以把这个`buildBundle`函数集成到 gulp 中起来使用了。gulp 是支持通过 Promise 来标记任务完成信号的,同样也可以用异步函数。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/1d50bdf11ed64bbf9f0d2d1742b5e467~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5c3c27dd8f0045e49449628ef49d229d~tplv-k3u1fbpfcp-watermark.image)\n\n```\nimport { series, src } from \"gulp\";\n// ...省略其他代码\n// 先 cleanUtils,再 buildBundle,通过 series 按顺序执行\nexport const buildUtils = series(cleanUtils, buildBundle);\n```\n\n测试一下效果,发现已经可以构建出符合 UMD 模块规范的产物了,第一小步算是迈出去了。\n\n![build_bundle.gif](https://qncdn.wbjiang.cn/博客素材/15a0f9d8e4a2469982ba13f5baf4e71e~tplv-k3u1fbpfcp-watermark.image)\n\n## 构建 ESM & CJS,支持按需加载\n\n接下来就是看怎么构建符合 ESM 和 CJS 规范的产物,同时要支持多文件独立输出,以支持按需加载。\n\n要输出多个文件,其实可以考虑指定多个构建入口,以单个模块作为入口,就能输出这个模块对应的构建结果。Rollup 本身也支持指定数组或对象形式的 input 参数作为多入口,这和 Webpack 也是相似的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/37916e04a330458eae8e257b24385b66~tplv-k3u1fbpfcp-watermark.image)\n\n我们用到一个[fast-glob](https://www.npmjs.com/package/fast-glob),这可以让我们避免繁琐的文件列举。\n\n```javascript\nimport fastGlob from \'fast-glob\'\nimport { UTILS_PATH } from \'./path\'\n\n// 通过 fast-glob 快速得到多入口,避免繁琐的文件列举\nconst getInputs = async (glob = \'src/**/*.ts\') => {\n return await fastGlob(glob, {\n cwd: UTILS_PATH,\n absolute: true,\n onlyFiles: true,\n ignore: [\'node_modules\'],\n })\n}\n```\n\n接着就是把构建流程写好。其实构建 ESM 和 CJS 模块有很多相似性,因为它们的输入都是一样的,只不过输出不一样。所以,我们可以在同一个函数`buildModules`中把这两件事情一起做了。\n\n```\nexport const buildModules = async () => {\n // 得到多文件入口\n const input = await getInputs()\n\n // 得到公共的 bundle 对象\n const bundle = await rollup({\n input,\n plugins: [rollupTypescript()],\n })\n\n // 用 Promise.all 标识:ESM 和 CJS 都完成了,才算 buildModules 完成\n await Promise.all([\n // 输出 ESM 到 es 目录\n bundle.write({\n format: \'esm\',\n dir: resolve(UTILS_PATH, \'es\'),\n }),\n // 输出 CJS 到 lib 目录\n bundle.write({\n format: \'cjs\',\n dir: resolve(UTILS_PATH, \'lib\'),\n })\n ])\n}\n```\n\n然后,我们可以在`build/build-utils.js`新增一个`startBuildUtils`函数,作为对外提供的调用接口。\n\n`startBuildUtils`函数通过 gulp 的 `parallel` 方法并行执行构建`buildModules`和`buildBundle`的任务。因为`buildModules`内部是通过`Promise.all`并行执行 ESM 和 CJS 的输出,所以本质上 ESM, CJS, UMD 模块的构建都是并行的,这也符合我们最开始的规划。\n\n`gulpfile.babel.js`可以改造为:\n\n```\nexport const buildUtils = series(cleanUtils, startBuildUtils);\n```\n\n我们看看效果,可以发现生成的内容完全符合预期,\n\n- 既可以支持我们通过`@vue-pro-components/utils/es/install`或者`@vue-pro-components/utils/es/fullscreen`按需引入独立的模块。\n- 也可以直接`import { enterFullscreen } from \"@vue-pro-components/utils\"`。\n- 配合一些工具,也能实现后者到前者的转换,同时保障开发效率和生产质量。\n\n![build_utils.gif](https://qncdn.wbjiang.cn/博客素材/8ce7a07967e14c75966e4085df385e19~tplv-k3u1fbpfcp-watermark.image)\n\n## 构建类型声明文件\n\n到这里,我们发现还缺少的就是类型声明了,我试着在`buildBundle`时同时把`declaration`给生成了,但是报了一个错,生成的 types 目录不能在`bundle.write`指定的`dir`目录之外。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/eca3ccdd470442629a979f2ad3d077d9~tplv-k3u1fbpfcp-watermark.image)\n\n把`declarationDir`改为`resolve(UTILS_PATH, \'./dist/types\')`倒是可以,不过生成到 dist/types 目录下不符合我的预期。\n\n于是我就考虑加一个`buildTypes`方法用于单独生成类型声明。\n\n```\nexport const buildTypes = async () => {\n const bundle = await rollup({\n input: resolve(UTILS_PATH, \'src/index.ts\'),\n plugins: [\n rollupTypescript({\n compilerOptions: {\n rootDir: resolve(UTILS_PATH, \"src\"),\n declaration: true,\n declarationDir: resolve(UTILS_PATH, \'./types\'),\n emitDeclarationOnly: true,\n },\n }),\n ],\n })\n\n await bundle.write({\n dir: resolve(UTILS_PATH, \'types\'),\n })\n}\n```\n\n不过我发现,即便我配置了`emitDeclarationOnly`,最终生成的 types 目录下还是有一个`index.js`文件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5b3fcc5a69344d718b1981c517066083~tplv-k3u1fbpfcp-watermark.image)\n\n看了一下`@rollup/plugin-typescript`的文档,发现是插件忽略了这部分配置。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/538c44be24fc42ea9bf581900a410d32~tplv-k3u1fbpfcp-watermark.image)\n\n来不及想为什么了,这里直接改用一个专门用于生成类型声明的插件[rollup-plugin-dts](https://www.npmjs.com/package/rollup-plugin-dts),`buildTypes`函数改造成如下:\n\n```\nexport const buildTypes = async () => {\n const input = await getInputs()\n\n const bundle = await rollup({\n input,\n plugins: [dts()],\n })\n\n await bundle.write({\n dir: resolve(UTILS_PATH, \'types\'),\n })\n}\n```\n\n`startBuildUtils`函数中也可以加入`buildTypes`任务了。\n\n```\nexport const startBuildUtils = parallel(buildModules, buildBundle, buildTypes)\n```\n\n![build_utils_types.gif](https://qncdn.wbjiang.cn/博客素材/ab03c3ecf1e34eaf8fee3ddfca07a1f4~tplv-k3u1fbpfcp-watermark.image)\n\n# 发布函数库\n\n构建的工作做好之后,就可以准备发布到 npm 上了。\n\n首先将`package/utils`的版本号修改一下,我们可以根据`lerna version`的提示修改版本号。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d2f71dc3fc1247dabad8bf813d421250~tplv-k3u1fbpfcp-watermark.image)\n\n接着运行`package.json`中定义的`publish:package`脚本,就可以发布到 npm 上了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/02fcdba2f9dc4e928fde5b1d1d574468~tplv-k3u1fbpfcp-watermark.image)\n\n接着我们可以找个地方验证一下`@vue-pro-components/utils`这个包是不是可以正常使用,在线 IDE 可能是最直观的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e982776ee5a04d74b5ae571f6b359449~tplv-k3u1fbpfcp-watermark.image)\n\n由于某在线 IDE 的 iframe 没有 allow fullscreen 特性,我们需要手动给它修改一下。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4fc2acb043ab4ce8bc1f8517db082cc6~tplv-k3u1fbpfcp-watermark.image)\n\n效果这就有了:\n\n![测试utils全屏.gif](https://qncdn.wbjiang.cn/博客素材/3f8f53e72dc04c8db74baf62648a4157~tplv-k3u1fbpfcp-watermark.image)\n\n# 结语\n\n本文主要介绍了一个函数库的构建和发布的基本流程,虽然打通了基本流程,但也还存在很多优化的空间,比如怎么把构建和发布的流程串起来,而不是一条接一条命令地手动执行。不过,以此为基础,我们就可以继续探索更为复杂的组件库的构建和发布流程了。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2023-02-19 23:26:06', '2024-07-24 20:00:17', 1, 53, 0, '发布一个属于你的函数库,它不香吗?咱们聊聊怎么构建和发布一个函数库。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/77c6cd69fb6143d5bf910630558705a9~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (245, '前端常见登录方案梳理', '前端登录有很多种方式,我们来挑一些常见的方案先梳理一下,后续再补充更多的。\n\n# 账号密码登录\n\n在系统数据库中已经有了账号密码,或者通过注册渠道生成了账号和密码,此时可以直接通过账号密码登录,只要账号密码正确就认为身份合法,可以换到系统访问的 token,用于后续业务鉴权。\n\n# 验证码登录\n\n比如手机验证码,邮箱验证码等等。用户首先提供手机号/邮箱,后端根据会话信息生成一个特定的码下发到用户的手机或者邮箱(通过运营商提供的能力)。\n\n用户得到这个码后填入登录表单,随手机号/邮箱一并发给后端,后端拿到手机号/邮箱、码后,与会话信息做校验,确认身份信息是否合法。\n\n如果一致就检查数据库中是否有这个手机号/邮箱,有的话就不用创建用户了,直接通过登录;没有的话就说明是新用户,可以先创建用户,绑定好手机号/邮箱,然后通过登录。\n\n# 第三方授权\n\n比如微信授权,github授权之类的,可以通过OAuth授权得到访问对方开放API的能力。\n\nOAuth 协议读起来很复杂,其实本质上就是:\n\n- 我是开发者,有个自己的业务系统。\n- 用户想图方便,希望通过一些常用的平台(比如微信,支付宝等)登录到我的业务系统。\n- 但是这也不是你想用就能用的,我首先要去三方平台登记一下我的应用,比如注册一个微信公众号,公众号再绑定我的业务域名(验证所有权),可能还要交个费做微信认证之类的。\n- 交了保护费后(经过上面的操作),我的业务系统就是某三方平台的合法应用了,就可以使用某三方平台的开放接口了。\n- 此时用户来到我的业务系统客户端,点击微信一键登录。\n- 然后我的业务系统就会按照微信的规矩生成一些鉴权需要的信息,拉起微信的中间页(如果是手机客户端,那可能就是通过 SDK 拉起手机微信)让用户授权。\n- 用户同意授权,微信的中间页鉴权成功后,就会给我的客户端返回一个 code 之类的回调信息,客户端需要把这个 code 传给后端。\n- 后端拿到这个 code 可以去微信服务器换取 access_token,基于这个 access_token,可以获取微信用户基本开放信息和帮助用户实现基础开放功能等。\n- 后端也可以基于此封装自定义的登录态返给客户端,如有必要,也可以生成用户表中的记录。\n- 此时我就认为这个用户是通过微信合法登录到我的系统中了。\n\n有些字段或者信息之类的可能会描述得不够精确,但是整个鉴权的思路大概就是这样。\n\n# 微信小程序登录\n\n## wx.login + code2Session 无感登录\n\n如果你的业务系统需要鉴权大部分接口,但是又不想让用户一打开小程序就去输入啥或者点啥按钮登录,那么无感登录是比较适合的。\n\n关键是找到能唯一标识用户身份的东西,openid 或者 unionid 就不错。那么怎么无感得到这些?wx.login + code2Session 值得拥有。\n\n小程序前端 wx.login 得到用户登录凭证 code(目前说的有效期是五分钟),然后把 code 传给服务端,服务端调用微信服务的 auth.code2Session,使用 code 换取 openid、unionid、session_key 等信息,session_key 相当于是当前用户在微信的会话标识,我们可以基于此自定义登录态再返回给前端,前端拿着登录态再访问后端的业务接口。\n\n## getPhonenumber授权手机号登录\n\n当指定 button 组件的 open-type 为 getPhoneNumber 时,可以拉起手机号授权,手机号某种程度上可以标识用户身份,自然也可以用来做登录。\n\n旧版方案中,getPhonenumber得到的 e 对象中有 encryptedData, iv 字段,传给后端,根据[解密算法](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html#%E5%8A%A0%E5%AF%86%E6%95%B0%E6%8D%AE%E8%A7%A3%E5%AF%86%E7%AE%97%E6%B3%95)能得到手机号和区号等信息。手机号也相当于是一种可以唯一标识用户的信息(虽然一个人可以有多个手机号,不过宽松点来说也可以用来标识用户),自然可以用来生成用户表记录,后续再与其他信息做关联即可。\n\n但是旧版方案已经不建议使用了,目前 getPhonenumber得到的 e 对象中有 code 字段,这个 code 和 wx.login 得到的 code 不是同一回事。我们把这个 code 传给后端,后端再调用 [phonenumber.getPhoneNumber](https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/phonenumber/phonenumber.getPhoneNumber.html)得到手机号信息。\n\n接着再封装登录态返回给前端即可。\n\n# 微信公众号登录\n\n首先分析一下渠道,在微信环境中,用户可能会直接通过链接访问 H5,也可能通过公众号菜单进入 H5。\n\n微信公众号网页提供了授权方案,具体可以参考这个[网页授权](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html)文档。\n\n授权有两种形式,snsapi_base 和 snsapi_userinfo。\n\n这个授权是支持无感的,具体见这个解释。\n\n> 关于特殊场景下的静默授权\n> \n> 上面已经提到,对于以snsapi_base为 scope 的网页授权,就静默授权的,用户无感知;\n> \n> 对于已关注公众号的用户,如果用户从公众号的会话或者自定义菜单进入本公众号的网页授权页,即使是 scope 为snsapi_userinfo,也是静默授权,用户无感知。\n\n这基本上就是说,如果是 snsapi_base 方式,目的主要是取 token 和 openid,用来做后续业务鉴权,那就是无感的。\n\n如果是 snsapi_userinfo 方式,除了拿鉴权信息,还要要拿头像昵称等信息,可能需要用户授权,不过只要关注了该公众号,也可以不出现授权中间页,也是无感的。\n\n下面说下具体的交互形式。\n\nsnsapi_base 场景下,需要绑定一个回调地址,交互形式是:\n\n1. 根据标准格式提供链接:\n\n```\nhttps://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect\n```\n\n2. 你可以在公众号菜单跳转这个标准链接,或者通过其他网页跳转这个链接。这个链接是个微信鉴权的中间页,如果鉴权没问题就会回调到 REDIRECT_URI 对应的业务系统页面,也就是用户真正前往的网页,用户能感知到的就是网页的进度条加载了两次,然后就到目标页面了,基本上是无感的。\n\n3. 页面在回调时会在 querystring 上携带 code 参数。前端在这个页面拿到 code 后,可以传给后端,后端就可以调下面这个接口得到 token 信息,然后封装出登录态返给前端。\n\n```\nhttps://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code\n```\n\n具体实现时,不一定要在页面层级上完成 code 换 token 的操作,也可以在应用层级上实现。\n\n4. 后续可以根据需要进行 refreshToken。\n\nsnsapi_userinfo 场景下,也是跳一个标准链接。与 snsapi_base 场景相比,除了 scope 参数不一样,其他都一样。跳转这个标准链接时会根据有没有关注公众号决定是否要拉起授权中间页面。\n\n```\nhttps://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect\n```\n\n接着也可以根据 code 换 token,进行必要的 refreshToken。\n\n最重要的是,在 scope=snsapi_userinfo 场景下,还可以发起获取用户信息的请求,这才是它与 snsapi_base 的本质区别。如果 scope 不符合要求,则无法通过调用下面的接口得到用户信息。\n\n```\nhttps://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN\n```\n\n还有一些公告调整内容要注意一下:\n\n- [微信网页授权能力调整公告](https://developers.weixin.qq.com/community/minihome/doc/000c2c34068880629ced91a2f56001)\n\n# 结语\n\n好了,前端常见的一些登录方式先整理到这里,实际上还有很多种方案还没提到,比如生物认证登录,运营商验证登录等等,后面再补充,只要是双方互相认可的方案,并且能标识用户身份,不管是严格的还是宽松的,都可以拿来做认证使用,具体还要根据你的业务特性决定。', '2023-02-20 21:48:50', '2024-08-08 14:23:47', 1, 100, 0, '前端登录有很多种方式,我们来挑一些常见的方案先梳理一下,后续再补充更多的登录认证方案。欢迎大家留言补充。', 'https://cdn.pixabay.com/photo/2015/01/08/18/25/desk-593327__480.jpg', 0, 0);
-INSERT INTO `article` VALUES (246, '调试不方便?我直接把公众号网页线上环境搬到本地!', '在开发微信公众号网页时,我们最关心的一个问题就是调试。\n\n- 怎么调试线上环境?\n- 调试是否足够方便?\n\n本文分享一种能够极大提高效率的微信公众号网页开发调试技巧,可以实现在本地开发时直连线上生产环境。如果你还不清楚这种场景下的调试技巧,不妨花几分钟阅读本文。\n\n# 微信生态内容管控\n\n在了解调试技巧的基本原理之前,我们有必要先搞明白微信在内容管控上是怎么做的,知己知彼才能找到突破口。\n\n## 微信小程序\n\n在小程序方面,由于小程序本质上是微信这个超级应用下的子应用,上线发布都是要经过微信审核的,部署时也是在部署在微信的服务器上,而不是开发者自行管理 Web 服务器,这就是说微信对小程序有绝对的管控权。\n\n同时,在微信开发者工具中,微信也会校验很多身份信息,比如开发者工具的会话信息是否有效(通过微信扫码登录),小程序的 APP ID 是否正确,开发者是否拥有这个 APP ID 对应的小程序的开发权限,某些敏感的 API(比如支付)还会校验服务端域名、签名等信息,当然还有很多地方也会涉及各种校验,这里就不一一列举。\n\n通过这些手段,微信基本上能识别出坐在电脑前的开发者身份,因此可以信任我们在微信开发者工具上调试小程序提供的各种 API。\n\n## 微信公众号网页\n\n那么微信公众号网页是否也如此呢?事实上不是,微信公众号网页本质上还是网页,这个网页是由开发者自行部署和维护的,管理权在开发者手里。但是对于微信来说,它作为一个平台,必然要对内容进行校验和监管。在网站方面,能用来校验所有权的方式不多,最直接有效的方式当然是校验域名。如果开发者需要使用微信生态提供的能力(比如授权,JSSDK 开放能力等),就必须先在微信公众平台后台中将域名配置好,并且按要求验证域名所有权。此外,不同类型、是否微信认证通过的公众号也有着不同的调用权限。\n\n![](https://qncdn.wbjiang.cn/博客素材/b6de784f64fb4b80a66e02ca87eb5626~tplv-k3u1fbpfcp-watermark.image)\n(截取了一部分)\n\n而在监管方面,由于公众号网页内容是由开发者自行设计实现,发版上线也是由开发者控制的,微信并不能做太多的干预(不方便加审核环节),这就导致微信在公众号网页内容监管上没法做太多事情,万一出现 hdd 等不良信息,除了加黑名单封杀也别无他法。监管能力和审核机制对平台来说是很重要的,否则哪天搞得不好就分分钟翻车,这也是微信选择力推小程序生态的一个重要因素。\n\n# 公众号网页调试\n\n在调用微信公众号 JSSDK API 或进行网页授权时,首先会校验的就是域名,域名如果与微信后台配置的不一致,就无法成功调用相关能力。\n\n而我们在配置域名时,通常会把测试环境/生产环境的域名都配置上,方便调试。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f4d42c6195c6401b8486767541e3e76e~tplv-k3u1fbpfcp-watermark.image)\n\n但是本地开发时,我们的 devServer 配置一般是以 localhost 或者 127.0.0.1 作为主机名,再加上某个端口访问网页,但是这种形式是不被微信开发者工具认可的,因为它只接受已备案的并且通过微信后台校验的域名。\n\n所以当涉及到授权或调试微信开放能力时,很多人的调试链路是:**本地盲改 -> 发布到测试服务器上 -> 在测试环境测试功能 -> 如果存在bug,本地继续修改,重复以上步骤**。\n\n这会浪费很多时间!那么有没有一种更方便的调试模式,让我们能够在本地开发时就能调用微信的各个开放能力,本地调试没问题后再发布到服务器上呢?答案是肯定的!\n\n## 模拟线上环境调试\n\n第一步是搞定微信开发者工具的调试流程。\n\n虽然支付等特殊场景在开发者工具上不方便测试,但是搞通开发者工具的调试流程也足够应对大部分场景了。\n\n我们知道,微信首先会检查域名,那我们就从域名上下功夫。\n\n先说一个不知道算不算冷门的知识:webpack 的 devServer 可以指定 host 为一个域名,Vite Server 同样也可以。port 参数也可以指定为 80。\n\n基于此,假设我们的线上域名是`juejin.cn`,我们可以先在本地开发时指定 host 为`juejin.cn`, port 为 80。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/928313afcf3b45dd957c7dafde38058e~tplv-k3u1fbpfcp-watermark.image)\n\n此时我们打开浏览器通过`http://juejin.cn:80`这个地址访问,80 端口是 http 的默认端口,所以带不带 80 其实都一样,此时我们的请求会根据 DNS 解析访问到线上服务器,由于代理服务器配置了 301 重定向,http 80 端口的请求会被重定向到 https 443 端口,\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e8297f05417f4c0c9b2f67c97df7c81a~tplv-k3u1fbpfcp-watermark.image)\n\n所以浏览器会再发起一次请求到`https://juejin.cn`,最终得到的响应还是来源于我们的线上环境。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f0a24560272c45c4a9695abde911ae8d~tplv-k3u1fbpfcp-watermark.image)\n\n有的读者可能就会问了,既然 devServer host 配置了线上域名,但最终的请求还是转到线上的机器去了,那这对我本地调试来说有什么意义?\n\n这就要搞清楚 DNS 的解析流程是怎么回事了。我们知道,IP 对应的服务器才是真正提供服务的,域名只不过是一个名字,而 DNS 服务就是负责把域名解析到 IP 上的。\n\n而 DNS 解析的第一道关口就是本机的 hosts 文件,hosts 文件中找不到的记录,才会往 DNS 服务器去找。\n\n那我们只要把 hosts 文件给改了,让`juejin.cn`解析到我本地的 IP 不就行了吗?我们来试试。\n\nhosts 文件在 Windows 操作系统中通常是位于`C:\\Windows\\System32\\drivers\\etc`目录下,我们修改一下这个文件,加入一条记录,接着需要用管理员权限保存。\n\n```\n127.0.0.1 juejin.cn\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ff8025ec94354ae48b10be346abe31a7~tplv-k3u1fbpfcp-watermark.image)\n\n这相当于把域名`juejin.cn`通过本地 hosts 文件解析到`127.0.0.1`,这样一来,访问`juejin.cn`等价于访问 `127.0.0.1`。\n\n接着使用`http://juejin.cn:80`进行访问,会发现打开的网页内容确实是由自己的本地服务提供的。\n\n![d872e2bd5a3b88b59ed9aa9dd60d858.png](https://qncdn.wbjiang.cn/博客素材/db1cb3899770418db403c34dfaf715ef~tplv-k3u1fbpfcp-watermark.image)\n\n在微信开发者工具中也同样适用。\n\n![df46a2bc709cbdb9767f57498b17d2f.png](https://qncdn.wbjiang.cn/博客素材/4a315b05ef4a45a0ad67861722006bbf~tplv-k3u1fbpfcp-watermark.image)\n\n此时我们在微信开发者工具中调用[网页授权](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html)或者[JSSDK API](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html)是被微信认可的,这就相当于实现了在本地调试公众号网页线上环境的需求。\n\n整个访问链路大概是这样的:\n\n![访问链路](https://qncdn.wbjiang.cn/博客素材/73cade39a91048b6b5f42d6d1474b0fd~tplv-k3u1fbpfcp-watermark.image)\n\n> 我们用的是 http 协议进行调试,注意在【公众号设置-功能设置】中,不要开启域名的强制https校验。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/97197ab1d22d41c39f1086deb7a23e98~tplv-k3u1fbpfcp-watermark.image)\n\n### 解决内核记住并强制访问 https 的问题\n\n如果不小心输入了 https 进行访问,微信开发者工具的浏览器内核会强制后续都按 https 访问,这样一来,就没法用上述技巧调试了。\n\n这时候即便点击 Clear Cache 也无法解决这个问题,\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/63561bacf72d4558b58750ac629ca2d9~tplv-k3u1fbpfcp-watermark.image)\n\n尝试在 Chrome 中使用`chrome://net-internals/#hsts` Delete domain security policies 也无法解决此问题。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d277a02c5dbd4a67aa842b6887961834~tplv-k3u1fbpfcp-watermark.image)\n\n重启也没用的。怎么办呢?\n\n此时,我们可以找到`C:\\Users\\YourName\\AppData\\Local\\微信开发者工具`目录,把其中的 User Data 目录给删除掉。再重新打开微信开发者工具时,就是一个全新的状态,也就可以继续用 http 调试了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e7cf47f9707a4f92b05f53602a037168~tplv-k3u1fbpfcp-watermark.image)\n\n> 注意,删除 User Data 目录后,你之前导入的小程序项目都将消失,后续需要重新导入各个小程序。\n\n当然,我们也可以在本地搭建起 https 环境用于调试,不过这就超出本文要讨论的范畴了,本文中不便展开叙述。\n\n### 80端口占用问题\n\n在 windows 中有遇到过80端口被占用的问题。\n\n可以用`netstat -aon | findstr :80`检查一下,如果感觉有可疑的进程,可以考虑用`taskkill /pid xxx -f`杀掉。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/769bc3b8ee54423bb67b48f49d8effde~tplv-k3u1fbpfcp-watermark.image)\n\n也可以检查下注册表`HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\HTTP`这一项,将 Start 设置为 0。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/514e4b7ea20b43ffa48167a8d4bb4454~tplv-k3u1fbpfcp-watermark.image)\n\n```\n// 其他可能有帮助的检查命令\nnetsh http show servicestate\n```\n\n## 真机线上环境调试\n\n更高级的问题来了,有些 API(比如支付)在 PC 端微信开发者工具中也不能调试,为了提高开发效率,我们希望能够在真机中直连 PC 本地开发环境进行调试,功能调试正常后再发布到线上。而在真机上,微信也会校验我们的访问域名,那么真机怎么直连我们的本地开发环境进行调试,然后还能被微信认可呢?\n\n答案是还是修改 hosts 文件,把真机的 hosts 文件改了,比如将`juejin.cn`解析到指定 IP,不过不是解析到`127.0.0.1`,而是解析到 PC 机的局域网 IP 上。\n\n> 这要求真机和 PC 在同一个局域网内,否则后续请求是走不通的。\n\n然而安卓修改 hosts 文件不是一件容易的事情,由于 Android 就是 Linux 基础上发展来的,而厂商都把修改 hosts 这种底层的权限都屏蔽了,所以要修改 hosts 文件,最直接的办法就是先让手机取得 Root 权限,但是 Root 权限一旦打开,风险也很高,那么有没有办法不 Root 也能修改 hosts 文件呢?\n\n有一种方法是通过 VPN 解决,我们在安卓端安装一个[Virtual Hosts](https://github.com/x-falcon/Virtual-Hosts),Virtual Hosts 支持通过 hosts.txt 文件解析,这就可以将线上域名解析到 192.168.x.x 这样的 PC 端 IP 上。\n\n![1d8917320f31822288e6c48c79acfc9.jpg](https://qncdn.wbjiang.cn/博客素材/1b0b8b607fc94f5b891d33ca4c7be3e1~tplv-k3u1fbpfcp-watermark.image)\n\n> 如果不生效,可能是 DNS 缓存问题,这个时候可能要重启一下安卓机,或者等 DNS 缓存的时间过去才能生效。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/77a6d88c886746fb859b184d1cdd0637~tplv-k3u1fbpfcp-watermark.image)\n\n当然,不做开发调试时,还是要把 hosts 文件和相关配置改回来,免得影响正常使用。\n\n# 结语\n\n这种修改 hosts 文件的行为,其实是 DNS 欺骗的一种表现形式。学会 DNS 欺骗后,很多相似场景的需求都可以迎刃而解。欢迎留言讨论。', '2023-02-21 23:35:21', '2024-11-13 17:42:02', 1, 167, 0, '在开发微信公众号网页时,我们最关心的一个问题就是调试。 怎么在本地调试线上环境? 调试过程是否足够方便?', 'https://qncdn.wbjiang.cn/博客素材/5371b6373a834beabb09b761a1e85aee~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (247, '函数库Rollup构建优化', '专栏上篇文章传送门:[在发布组件库之前,你需要先掌握构建和发布函数库](https://juejin.cn/post/7171792173984612366)\n\n本节涉及的内容源码可在[vue-pro-components c7 分支](https://github.com/cumt-robin/vue-pro-components/tree/c7)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\") 专栏第 8 篇文章【函数库Rollup构建优化】,在上一篇文章的基础上,聊聊在使用 Rollup 构建函数库的过程中还可以做哪些优化。\n\n# terser 压缩\n\n在上篇文章中,我们掌握了怎么打包 ESM, CJS, UMD,还掌握了怎么生成类型声明文件`d.ts`,但是我们可以发现,我们生成的 UMD 文件`dist/index.js`并没有经过压缩,我们可以尝试给它再压缩一下,这可以用到 Rollup 官方的插件 rollup-plugin-terser。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ad7d8c5b2afb4e629da15874dc5bd65b~tplv-k3u1fbpfcp-watermark.image)\n\n由于压缩版通常是直接通过`script`标签引入用在浏览器环境中,所以打包成 IIFE(立即执行函数表达式)格式就行。我们改造一下`buildBundle`函数。\n\n```\nexport const buildBundle = async () => {\n const bundle = await rollup({\n input: resolve(UTILS_PATH, \'src/index.ts\'),\n plugins: [rollupTypescript()],\n })\n\n await Promise.all([\n bundle.write({\n name: \'VpUtils\',\n format: \'umd\',\n file: resolve(UTILS_PATH, \'dist/index.js\'),\n sourcemap: true,\n }),\n bundle.write({\n name: \'VpUtils\',\n // 考虑到使用场景,输出 iife 格式即可\n format: \'iife\',\n // 生成一个 dist/index.min.js 作为压缩版本\n file: resolve(UTILS_PATH, \'dist/index.min.js\'),\n // 使用了 rollup-plugin-terser 插件\n plugins: [terser()]\n })\n ])\n}\n```\n\n再次打包就会生成这种 IIFE 的压缩代码了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c79bfffc062d4df8a48a832de68bef6e~tplv-k3u1fbpfcp-watermark.image)\n\n# 按需使用子模块时提供类型支持\n\n我们已经支持了生成类型声明文件,所以正常使用`@vue-pro-components/utils`模块时,是有类型支持的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6cce52f591394a5fbd0212816d395540~tplv-k3u1fbpfcp-watermark.image)\n\n可以看到,上面的函数签名都是有的。\n\n但是,当我们按需使用其中一个模块时,会发现 TypeScript 似乎找不到对应的类型声明。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/bb06717c519342c8ae7fb3eae5cc9aa4~tplv-k3u1fbpfcp-watermark.image)\n\n观察上图可以发现,当我们引用其中一个模块的完整路径时,TypeScript 报了错表示找不到类型声明文件。这是为什么呢?明明我们已经生成了`d.ts`,也配置了 package.json 文件中的`types`属性......\n\n实际上,package.json 中的`types`属性只是为简单的包名引用提供了类型声明文件的路径,也就是说`types`只是让`import { xxx } from \'@vue-pro-components/utils\'`有了类型支持。对其他的路径下的模块引用并没有什么帮助。\n\n不慌,在导入`.js`模块时,TypeScript 会自动加载与`.js`同名的`.d.ts`文件,以提供类型声明。我们可以在生产的`es/fullscreen.js`文件的相同目录中放置一个`fullscreen.d.ts`试试(从 types 目录抄过来即可)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/08f89853018b45f8ae66d11fe16758cf~tplv-k3u1fbpfcp-watermark.image)\n\n可以发现已经不报错了,那我们的思路就很清晰了,只要把 types 目录下生成的类型声明文件抄一份到 es 和 lib 目录,就可以保证按需使用模块时的类型支持了。\n\n我们回忆一下整个流程,\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6650dd0f1ea54ac3bf51c732cbaedbda~tplv-k3u1fbpfcp-watermark.image)\n\n不难想明白要抄一份类型声明文件到 es 和 lib 目录,最好的时机就是在并行任务结束之后,再补一个 copy dts 节点。copy 文件在 gulp 里是很容易实现的,不需要借助任何插件。通过 src 取得输入后,可以用两个 pipe + dest 分别 copy 到 es 和 lib 目录中。\n\n```\nexport const copyDts = async () => {\n return src(\"types/**/*.d.ts\", {\n cwd: UTILS_PATH,\n })\n .pipe(dest(resolve(UTILS_PATH, \"es\")))\n .pipe(dest(resolve(UTILS_PATH, \"lib\")))\n}\n```\n\n然后改造一下入口函数`startBuildUtils`,在并行任务结束后,加一个 copyDts 节点。\n\n```\nexport const startBuildUtils = series(\n parallel(buildModules, buildBundle, buildTypes),\n copyDts\n)\n```\n\n效果如下:\n\n![copyDts.gif](https://qncdn.wbjiang.cn/博客素材/de068b74b5e748b29f266d6eab6c4086~tplv-k3u1fbpfcp-watermark.image)\n\n基于此,我们按需使用任何一个子模块都能得到完备的类型支持了。\n\n# 第三方依赖解析和打包问题\n\n当函数库依赖第三方模块时,我们需要考虑打包问题。\n\n比如:打包成 ESM / CJS / UMD / IIFE 模块时,第三方依赖是作为 external,还是将其代码直接打进产物里?\n\n当依赖作为 external 处理时,就代表着函数库的构建产物中不包含对应依赖的代码,打包出来的大小也会相对小一点。\n\n当依赖的代码直接打进产物中,很显然会增大构建产物的大小。\n\n这就需要考虑第三方依赖的性质和大小。如果第三方依赖是某个运行时框架或者依赖的体积很大,那最好作为 external 处理,由调用方提供具体的依赖。反之可以酌情将依赖打进构建产物中,避免调用方在依赖问题花费太多的精力。\n\n为了验证第三方依赖问题,我特意加了一个`date-utils.ts`,这是一个基于`dayjs`的日期函数集合。\n\n针对 ESM / CJS 情况,最好将第三方依赖作为 external 处理,因为除了我的函数库会依赖`dayjs`,项目中也可能会依赖`dayjs`,在构建工具的帮助下,能在 Dependency Graph 中实现复用。\n\n我们将`buildModules`改一改,\n\n```\nconst bundle = await rollup({\n input,\n plugins: [rollupTypescript()],\n // 把依赖作为 external(dependencies 中包含 dayjs)\n external: Object.keys(pkgJson.dependencies),\n})\n```\n\n重新打包会发现报了一个错,\n\n```\n\'dayjs\' is imported by packages/utils/src/date-utils.ts, but could not be resolved – treating it as an external dependency\n```\n\n因为 Rollup 默认的模块解析策略符合 ESM 规范,只有从相对路径上找得到的模块,才能被成功解析。\n\n我们可能已经习惯了`import { ref } from \"vue\"`这种用法,就会想当然认为 Rollup 默认也能理解这种引用第三方依赖的行为,实际上并不能。我们熟悉的这种模块解析策略其实是遵从 Node Resolution Algorithm,它是 NodeJS 的默认行为,并不是 ESM 的默认行为。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/72a181951607415992ffb3a6a769fd75~tplv-k3u1fbpfcp-watermark.image)\n\n这个问题需要借助插件[@rollup/plugin-node-resolve](https://www.npmjs.com/package/@rollup/plugin-node-resolve)来解决。\n\n首先安装一下依赖,\n\n```\nyarn add -DW @rollup/plugin-node-resolve\n```\n\n然后在插件中引用它,\n\n```\nconst bundle = await rollup({\n input,\n plugins: [rollupTypescript(), nodeResolve()],\n external: Object.keys(pkgJson.dependencies),\n})\n```\n\n但我们继续打包还是会遇到一个问题:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9c2b2361f6004a1bab85dc3dbb2ed2b4~tplv-k3u1fbpfcp-watermark.image)\n\n关键信息是:\n\n```\nError: \'default\' is not exported by node_modules/dayjs/dayjs.min.js, imported by packages/utils/src/date-utils.ts\n```\n\n其实这是因为 dayjs 的 package.json 中只给出了`main`入口,而没有配置`module`入口,而`main`入口指定的不是符合 ESM 规范的文件,从而导致这个问题。我当时还[给 dayjs 提了一个PR](https://github.com/iamkun/dayjs/pull/2002)说明了这个问题,希望增加`module`入口优化这个问题,不过 dayjs 团队似乎不太在意这个问题,关闭了这个 PR,建议我改用 v2 alpha 版本,实际上 v1 版本后面也一直在更新和发版。\n\n不过没关系,即便有一些模块不符合 ESM 规范也是合情合理,毕竟 npm 生态中还有很多不支持 ESM 的包,Rollup 自然也考虑到了这一点,给出了插件[@rollup/plugin-commonjs](https://www.npmjs.com/package/@rollup/plugin-commonjs),那我们直接用上它就好了。\n\n```\nexport const buildBundle = async () => {\n const bundle = await rollup({\n input: resolve(UTILS_PATH, \'src/index.ts\'),\n plugins: [rollupTypescript(), nodeResolve(), commonjs()],\n // 如果你觉得第三方依赖体积很大,也可以用 external 拆出来,让调用方提供对应依赖,此时要配合 globals 一起用\n // external: Object.keys(pkgJson.dependencies),\n })\n\n // const globals = {\n // dayjs: \"dayjs\",\n // }\n\n await Promise.all([\n bundle.write({\n name: \'VpUtils\',\n format: \'umd\',\n file: resolve(UTILS_PATH, \'dist/index.js\'),\n sourcemap: true,\n // globals\n }),\n bundle.write({\n name: \'VpUtils\',\n format: \'iife\',\n file: resolve(UTILS_PATH, \'dist/index.min.js\'),\n sourcemap: false,\n plugins: [terser()],\n // globals,\n })\n ])\n}\n```\n\n如上面代码中注释所述,你可以根据实际情况选择是否将 dayjs 等依赖打进 bundle。\n\n如果使用了 external,最好通过文档告知用户应该预先引入哪些依赖,降低用户的心智负担。\n\n# 结语\n\n本文主要介绍了函数库的构建过程中的一些优化方案和注意事项,希望对读者们有所帮助。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2023-02-22 20:34:07', '2024-07-25 03:27:31', 1, 89, 0, '聊聊在使用 Rollup 构建函数库的过程中还可以做哪些优化,希望对读者们有所帮助。', 'https://qncdn.wbjiang.cn/博客素材/7a92559e28ec4a959ac84fa944453cd8~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (248, '在本地和CI/CD中支持npm免登录发布', '专栏上篇文章传送门:[函数库Rollup构建优化](https://juejin.cn/post/7176938419392774203)\n\n本节涉及的内容源码可在[vue-pro-components c8 分支](https://github.com/cumt-robin/vue-pro-components/tree/c8)找到,欢迎 star 支持!\n\n# 前言\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\") 专栏第 9 篇文章【在本地和CI/CD中支持npm免登录发布】,专门分享一下如何在 npm 发包时支持免登录发布,并同时支持在本地和CI/CD中操作发布流程。\n\n在[组件库技术选型和开发环境搭建](https://juejin.cn/post/7153432538046791687#heading-16)这篇文章中,我们简单介绍了怎么把一个包发布到 npm 上,但是执行`lerna publish`之前需要先验证登录,因为`lerna publish`它背后执行的还是`npm publish`,所以首先需要通过 npm 的认证流程。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e2c7fd8b08454a029bee5ffcf51b6301~tplv-k3u1fbpfcp-watermark.image)\n\n一个流程中如果要执行登录流程,那么它的自动化程度就不会很高。如何解决这个问题呢?答案是 token,只要我们把 token 通过某个配置告诉 npm,就等同于告诉 npm 我是谁,所以只要这个 token 代表的是我的身份,自然就没必要输入账户密码登录了。\n\n# 创建 token\n\n我们先在 [npm 网站](https://www.npmjs.com/) 中登录,在用户下拉菜单这里能找到创建 token 的入口。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0873ccb2745f4dcc8f100613eeed9d96~tplv-k3u1fbpfcp-watermark.image)\n\ntoken 有两种,\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6dc8959e61e94b59b17d9c30ad7c4839~tplv-k3u1fbpfcp-watermark.image)\n\n一种是经典的通用 token,就是不限制使用范围,你名下的任何包/组织都能用这个 token 去管理。但是也大概分几种类型。如果要用在自动化流程中,需要避开双因素(2FA)验证,我们就创建 Automation 类型的 token。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ec187464c2d344158be9ea042d3e1741~tplv-k3u1fbpfcp-watermark.image)\n\n还有一种就是更细粒度的 token,可以把权限控制到**过期时间/IP范围/可读可写/包/组织**等。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3775c6439d904a7bbfd4a5f0c44367fc~tplv-k3u1fbpfcp-watermark.image)\n\n> 如果你觉得用界面操作很 Low,也可以选择极客风的命令行。npm 提供了创建 token 的命令行,具体见 [npm token](https://docs.npmjs.com/cli/v9/commands/npm-token)。\n\n# 怎么使用 token?\n\n我们创建 token 主要是为了用于发布 npm 包。这个 token 我们可以配置在`.npmrc`文件中,对应的 key 是`_authToken`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/58598bee9ab647088ca3da8260acbc39~tplv-k3u1fbpfcp-watermark.image)\n\n //registry.npmjs.org/:always-auth=true\n //registry.npmjs.org/:_authToken=your npm token\n\n但是`.npmrc`文件一般是要提交到仓库中的,而 token 又是一个比较私密的数据,就不适合写死放在 .npmrc 中,此时我们可以使用变量替代,改成这样:\n\n //registry.npmjs.org/:always-auth=true\n //registry.npmjs.org/:_authToken=${NPM_TOKEN}\n\n那么这个变量可以从哪里读来呢?我们可以看看 npm 的一篇文档[Set the token as an environment variable on the CI/CD server](https://docs.npmjs.com/using-private-packages-in-a-ci-cd-workflow#set-the-token-as-an-environment-variable-on-the-cicd-server)是怎么说的。\n\n> The npm cli will replace this value with the contents of the NPM\\_TOKEN environment variable.\n\n答案是环境变量。这里要考虑 2 种情况,一个是本地化发布,一个是在 CI/CD 中发布。\n\n首先说后面一种情况,在 CI/CD 中发布 npm 包已经有比较标准的方案了,大部分 CI/CD 平台都支持在 yaml 配置文件中指定环境变量,并且支持加密,没有暴露 token 的风险。上述文档中也有提到,关键配置如下:\n\n steps:\n - run: |\n npm install\n - env:\n NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n\n那么关键还是在于前面那种情况,有时候需要在本地发布 npm 包,此时应该怎么办呢?\n\n我首先尝试添加系统环境变量,但是没有立即成功;\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8453be0ebfc64be1873ced7b87baef6f~tplv-k3u1fbpfcp-watermark.image)\n\n我还尝试了`dotenv`,虽然`dotenv`能加载`.env`文件到环境变量中,不过也不太方便。\n\n如果`.npmrc`中存在变量`NPM_TOKEN`,跑任何`npm scripts`,都会去寻找`${NPM_TOKEN}`,如果找不到就会报错,而我们不可能给所有脚本都加上`dotenv`。\n\n所以如果要在本地发布,一个替代方法是临时手动将`.npmrc`的 token 写死,改成:\n\n //registry.npmjs.org/:_authToken=npm_xxxxxxxxxxxxxxxxxx\n\n但是执行 lerna publish 的时候又需要一个干净的 git 状态,如果有 modified files 也不行(因为临时改了 .npmrc 就会导致 git 工作区不干净了)。啊,真难!最理想的办法还是把环境变量给搞定,同时又不能改太多脚本。\n\n最后我发现加系统环境变量其实是有用的,关键是改了后要重新打开 VSCode(之前没有尝试这一步,导致我以为加系统环境变量没有用),否则终端加载不到最新的环境变量,果然还得是重启大法!所以最佳选择是使用变量`${NPM_TOKEN}`。\n\n# 本地验证 token 是否生效\n\n搞定了环境变量后,我们先试试本地 publish 的场景。\n\n考虑到之前用`npm login`或者`npm adduser`登录过,所以我们需要先退出登录再测试,否则无法确定是否 token 是否真的起了作用。\n\n退出登录命令:\n\n npm logout --registry=https://registry.npmjs.org\n\n接着可以试试`lerna publish`或者`npm publish`,经测试已经不需要登录就能发布 npm 包了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/957b4364f4134e02abf3e395384e4d7c~tplv-k3u1fbpfcp-watermark.image)\n\n# 集成构建和发布流程\n\n在集成构建和发布流程之前,我们参照`@vue-pro-components/utils`的构建流程把`@vue-pro-components/headless`的构建流程搞定,因为它们本质上都是函数库,打包过程不会有太多差异,抄一抄它不香吗?\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/959317ebb367466eb0e8cce90e6f2b65~tplv-k3u1fbpfcp-watermark.image)\n\n同时根据各个包之间的依赖关系,新增一个统一构建的入口`buildBatch`,这样就能通过`gulp buildBatch`一条命令把所有的构建工作都做了。\n\n继而可以得到一条集成构建和发布流程的命令`release`。\n\n \"release\": \"yarn buildBatch && yarn publish:package\",\n\n所以只要我们把代码修改完毕,版本号确定之后,就可以执行`yarn release`进行发布了。\n\n# CI/CD workflow 搭建\n\nGithub 本身也支持 CI/CD,相关的产品是 Github Actions,所以我们可以直接使用它实现自动化构建和发布流程。\n\n> 现在市面上有很多 CI/CD 工具,它们虽然在配置上有些差异,但是架构和理念都是相似的,学会使用一个,其他的参考着文档也基本能看得懂。\n\n使用 Github Actions 主要就是写配置文件,我们可以基于[官方的一些模板](https://github.com/cumt-robin/vue-pro-components/actions/new)来初始化一个配置文件,这个 Publish Node.js Package 模板就比较合适。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2c4818f07a75404bbf7d43bda6699343~tplv-k3u1fbpfcp-watermark.image)\n\n你也可以通过阅读[Github Actions 文档](https://docs.github.com/en/actions/quickstart)来了解更多相关知识。\n\n我们按照模板文件改一改:\n\n```yaml\n# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created\n# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages\nname: Build and Publish Node.js Package\n\non:\n push:\n branches:\n - c*\n\nenv:\n NPM_TOKEN: ${{secrets.NPM_TOKEN}}\n\njobs:\n publish-npm:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v3\n \n - name: Setup Node\n uses: actions/setup-node@v3\n with:\n node-version: 16\n \n - name: Install Dependency\n run: yarn install --frozen-lockfile\n \n - name: Build and Publish\n run: yarn release\n```\n\n因为这里要用到`NPM_TOKEN`变量,我们先到 Settings -> Secrets 中维护好变量。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/563065e5d659498f967394ae284e1a74~tplv-k3u1fbpfcp-watermark.image)\n\n修改一个版本号测试一下,一个简单版本的 CI/CD 这不就有了吗?\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a13a518d1683414b8dbbd3cb1d546f04~tplv-k3u1fbpfcp-watermark.image)\n\n然后可以再加个 Cache 优化一下安装依赖的过程,这可以用到[actions/cache@v3](https://github.com/actions/cache/blob/main/examples.md#node---yarn)。\n\n# 结语\n\n通过阅读和学习本文内容,我们已经能掌握怎么优雅地发布一个 npm 包,并同时支持了在本地和远程 CI/CD 中进行发布操作。但是我们应该注意到,每次发布都会执行完整的`buildBatch`过程,这个有没有必要呢?我想有时候是没有必要的,因为有可能某一个包根本就没修改过,但是每次发布时都执行打包过程就会浪费资源和时间。这里先留个疑问,后面文章接着讲。如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。', '2023-02-23 20:43:29', '2024-11-02 19:39:29', 1, 145, 0, '本文分享一下如何在 npm 发包时支持免登录发布,并同时支持在本地和CI/CD中操作发布流程。一起来学习一下吧。', 'https://qncdn.wbjiang.cn/博客素材/ff8155422ca04c979ef0d7fac86d4875~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (249, 'OpenAI 又放大招了,GPT3.5 API 开放使用,1分钟上手体验!', '上班前日常刷一刷 OpenAI,看看有什么新消息。果然,上来就是一个王炸,**GPT3.5 API** 他来了,没错,**GPT3.5 API**采用与 ChatGPT 聊天界面相同的模型,甚至有了改进。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6fe0e87ca7cc409aaf1fa618a6dbcfc3~tplv-k3u1fbpfcp-watermark.image)\n\n> The ChatGPT model family we are releasing today, `gpt-3.5-turbo`, is the same model used in the ChatGPT product.\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/78cfc9ab5f6846f9baf2dbd8785f1c07~tplv-k3u1fbpfcp-watermark.image)\n\n**gpt-3.5-turbo** 在价格上便宜十倍,我一开始以为我看错了,实际上 **gpt-3.5-turbo** 真的比 davinci 模型便宜十倍!**gpt-3.5-turbo** 的价格是 0.002 美元 / 1000 Token。\n\n> It is priced at \\$0.002 per 1k tokens, which is 10x cheaper than our existing GPT-3.5 models.\n\n集成 API 也不是很复杂,你几乎可以无缝从 GPT3 的 **text-davinci-003** 模型切换到 **gpt-3.5-turbo**。\n\n> It’s also our best model for many non-chat use cases—we’ve seen early testers migrate from `text-davinci-003` to `gpt-3.5-turbo` with only a small amount of adjustment needed to their prompts.\n\n基于 gpt-3.5-turbo 模型,我们不仅可以用来实现 AI 聊天场景,还可以用来干很多事。\n\n使用 OpenAI API,我们可以使用 `gpt-3.5-turbo` 构建自己的应用程序,不局限于做到以下事情:\n\n* 起草电子邮件或其他书面文件。\n* 让 gpt 写点 Python 代码或者其他代码。\n* 让 gpt 回答文档相关的问题。\n* 创建一个智能AI客服。\n* 让我们的应用或者软件支持自然语言处理。\n* 成为某个领域的伪专家。\n* 也可以在游戏中给 NPC 对话。\n\n那么具体怎么用上这个 GPT3.5 的 API 呢?总的来说与 **text-davinci-003** 的使用差异不大。\n\n还记得我在上篇文章[花1块钱让你的网站支持 ChatGPT](https://juejin.cn/post/7176539666210881592)中讲过,使用 **text-davinci-003** 时,最关键的参数就是`prompt`,所有的会话上下文信息都要在这个参数中体现,不算特别优雅。\n\n在 GPT3.5 的 completion 接口中,对此做了优化,可以通过数组形式的`messages`传递会话信息,而且通过语义化的`role`和`content`来体现身份和内容,总的来说体验是更好了!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2f7383bf68fd4d6185ed2cf8c4d8b377~tplv-k3u1fbpfcp-watermark.image)\n\n在身份认证上,采用的是 Bearer Token,需要在请求头中带上 Authorization。\n\n Authorization: `Bearer ${your api token}`\n\n那么 gpt3.5 的生态目前如何呢? 看 OpenAI 文档示例大概能知道,官方 python 包应该是支持了。而 npm 这边的 openai 包似乎还没更新 README,可能还在补齐这块能力,不如我们自己来上手尝试对接一下。\n\n我这里简单封装了一下接口请求,给出了一个 [gpt-node](https://www.npmjs.com/package/gpt-node) 包,已经发布到 npm 上,方便大家尝鲜!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c745f0f873184a9aa2bff70712feefeb~tplv-k3u1fbpfcp-watermark.image)\n\n具体使用也比较简单,只要实例化时把你的 token 带上,然后通过`api.completions`方法调用对话能力即可。\n\n const api = new ChatGPT35(\"your token\")\n \n const result = await api.completions({\n messages: [\n {\n role: \"ai\",\n content: \"hello\"\n },\n {\n role: \"user\",\n content: \"你是谁?\"\n }\n ]\n })\n \n console.log(result)\n\nChatGPT 的体验在不断优化,价格和成本也在不断压缩,这个行业看起来会越来越卷,期待有更精彩的内容出现!\n\n关注[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),一起聊聊技术。', '2023-03-02 12:17:31', '2024-08-13 03:28:22', 1, 162, 0, '上班前日常刷一刷 OpenAI,看看有什么新消息。果然,上来就是一个王炸,GPT3.5 API 他来了!', 'https://qncdn.wbjiang.cn/博客素材/24ce26edde5a4d08b105b98fbfbb3470~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (250, '在monorepo项目中怎么组织和优化前端研发流程?', '专栏上篇文章传送门:[在本地和CI/CD中支持npm免登录发布](https://juejin.cn/post/7178664168197718072)\n\n本节涉及的内容源码可在[vue-pro-components c9 分支](https://github.com/cumt-robin/vue-pro-components/tree/c9)找到,欢迎 star 支持!\n\n本文是 [基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\") 专栏第 10 篇文章【你知道怎么组织和优化前端研发流程吗?】,前面几篇都在说函数库开发的相关内容,所以本文接着围绕这块说,主要是把研发流程梳理清楚,方便后续更多内容的铺开。\n\n# 梳理研发流程\n\n我们先粗略整理一下函数库的主要研发流程。\n\n1. 写代码,不限于需求/缺陷/优化等内容。\n2. 做一次 commit。\n3. 修改版本号。\n4. 生成 changelog。\n5. 本地打包发布 + 提交到 github;或者是,提交到 github + CI/CD 打包发布。\n\n以上是主要流程,其他的辅助事项可以按需穿插,比如提交代码前是不是经过 husky, eslint, prettier, stylelint, commitlint 等。\n\n# 版本号处理\n\n第一步显然是跟工具无关的,纯粹是开发者自己写代码。\n\n先说重点,第三步是修改版本号,我们来分情况讨论一下。\n\n如果是单包工程,其实只有一个版本号要管理,第一种方式是手动改版本号,不借助任何工具,就相当于把**第三步的修改版本号**与**第一步的写代码**放在一起做了。第二种方式是让工具去决定版本号,但工具怎么知道你期望的版本号是什么呢?这就必须先有规范。\n\n首先要有版本号的规范,有了版本号规范才能知道下个版本号有哪些选择,这对应 [Semver](https://semver.org/lang/zh-CN/)(Semantic Versioning)规范。\n\n接下来还要有一套规范,能根据用户的输入或者操作推导出下一个 Semver 版本号。\n\n一种做法是使用 npm version 命令,它支持 **major/minor/patch** 等版本更新操作,还支持通过钩子把 changelog 和后续的自动化流程全部做了,我之前有写过一篇[前端自动化部署的深度实践](https://juejin.cn/post/6844904056498946055#heading-7)中有提到,大家可以参考着看看。但是这还是需要我们自己决定到底是 major/minor/patch 的哪一种版本更新,无法完全自动化。\n\n还有一种做法是基于 Git Commit 来实现自动化推导版本号,只要我们的 commit 符合 [Conventional Commits](https://www.conventionalcommits.org/) 规范,通过分析两个版本之间的所有 commit 信息,就有机会推导出下一个版本号。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9da91caf759244308c95fdd1f17637be~tplv-k3u1fbpfcp-watermark.image)\n\n按照上图中提供的信息,我们可以知道,fix 类型的 commit 关联着 **patch** 位的版本号更新,feat 关联着 **minor** 位的版本号更新,Breaking CHANGE(具体实现是在 type(scope) 后接`!`,或者在 message footer 中使用`Breaking CHANGE: `)关联着 **major** 位的版本号更新。\n\n基于此,一些自动化工具也应运而生,比如基于 Conventional Commits 生成 changelog 的底层 API —— [conventional-changelog](https://www.npmjs.com/package/conventional-changelog),以及一些上层工具 [standard-version](https://www.npmjs.com/package/standard-version), [semantic-release](https://github.com/semantic-release/semantic-release),还有我们相对熟悉的 [commitizen](https://github.com/commitizen/cz-cli) + [cz-conventional-changelog](https://github.com/commitizen/cz-conventional-changelog) + [husky](https://typicode.github.io/husky/#/) + [commitlint](https://github.com/conventional-changelog/commitlint) + npm version + [conventional-changelog-cli](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-cli) 组合拳。\n\n* standard-version 专注于 version bump、生成 CHANGELOG.md、打 tag 等事项,支持生命周期钩子,可以做一些自动化流程。\n* semantic-release 除了上述能力,还会执行 git push,npm publish 等操作。\n* commitizen 提供了 git cz 命令,可以提供交互式命令行操作,用于替代 git commit 操作,按照 git cz 流程提交的 commit 就是比较规范的。\n* cz-conventional-changelog 则是 commitizen 家族的一份子,作为适配器的角色,用于实现 AngularJS\'s commit message convention,Angular 的 git 提交规范也算是业界扛把子了。\n* husky 是一款 git hooks 工具,支持 git 的所有钩子,我们可以用它来校验 commit message,也可以用来触发 eslint 等校验。\n* commitlint 对 git commit 信息做校验,因为你不能保证大家都守规矩,每次都会乖乖地用 git cz 提交,那么至少要校验 git commit 的输入信息是大致符合规范的。commitlint 也支持 configuration。\n* npm version 命令可以进行 version bump,但是需要你做出选择 major/minor/patch。\n* conventional-changelog-cli 则是最终用来生成 CHANGELOG.md 文件的。\n\n在单包工程中,适当选择以上部分工具已经足够自动我们推导出下一个版本号了。而在 monorepo 工程中会存在多个子包,多个子包的版本号如何确定呢?\n\n以 lerna 为例,有两种版本策略,具体见[组件库技术选型和开发环境搭建](https://juejin.cn/post/7153432538046791687#heading-14)文中相关介绍。如果我们采用 Fixed Mode,也就是 monorepo 工程中各个子包都共用一个版本号,那事情就简单得多,因为这跟单包工程没什么差别,只要根据 git commit message 简单推导出下个版本号即可。\n\n如果我们选择 Independent Mode,也就是各个子包采用独立的版本号,那么 version bump 这件事情就变得复杂起来,因为我们在一次 commit 中可能不止修改了一个子包(毕竟是人为操作),产生耦合的几率比较大,版本界限不是很清晰。一次 commit 到底对应哪个子包的版本,谁都不好说清楚,因为我们得分析每次 commit 到底修改了哪些文件才能得出结论。\n\n还好 lerna version 已经支持这个能力,只要我们执行下面的命令:\n\n lerna version --conventional-commits --yes\n\nlerna 就会遵循 Conventional Commits 规范,自动帮我们进行 version bump,生成相关的 CHANGELOG.md 文件。\n\n# husky + lint\n\n说完最重要的版本号问题,我们再回到第二步,第二步是 commit,commit 环节可以穿插一些工具。\n\n我们先补齐一些代码校验脚本,便于在合适的时间调用。\n\n代码校验主要是通过 eslint 和 stylelint 完成,prettier 则是以插件的形式存在,被 eslint 和 stylelint 调用。\n\n```json\n\"lint\": \"eslint packages --cache --ext .js,.mjs,.jsx,.ts,.tsx,.vue\",\n\"lint-fix\": \"eslint packages --cache --fix --ext .js,.mjs,.jsx,.ts,.tsx,.vue\",\n\"lint-style\": \"stylelint packages/**/src/**/*.{vue,css,less} --cache\",\n\"lint-style-fix\": \"stylelint packages/**/src/**/*.{vue,css,less} --cache --fix\"\n```\n\n主要脚本如上所示,其中`lint`只负责 lint,不进行 fix;`lint-fix`会在 lint 时顺手修复问题;`lint-style`和`lint-style-fix`同理。\n\n我们期望在提交代码前进行代码质量校验,这需要用到 git hooks 中的 [pre-commit](https://git-scm.com/docs/githooks#_pre_commit) 钩子,在 pre-commit 钩子中可以执行 eslint 等 lint 命令。\n\nhusky 对 git hooks 进行了良好的封装,我们根据指引安装一下。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/1eda53491fb64be39e69661ebe39b49a~tplv-k3u1fbpfcp-watermark.image)\n\n // 由于我们当前使用的是 Yarn 1,所以可以执行以下命令安装\n npx husky-init && yarn\n\n按道理,我们只要新增一个 pre-commit 钩子,执行相关的 lint 命令即可。但是,每次 commit 都 lint 整个工程的文件是比较浪费时间的,所以我们可以再引入一个 [lint-staged](https://www.npmjs.com/package/lint-staged) 进行优化,lint-staged 只会 lint 进入了 staged 状态的文件,这样效率就比较高。\n\n // 安装依赖\n yarn add -DW lint-staged\n\nlint-staged 通过配置文件决定具体要对哪些文件执行哪些脚本,我们新建一个`lint-staged.config.js`配置文件。\n\n```javascript\nmodule.exports = {\n \"packages/**/src/**/*.{js,mjs,jsx,ts,tsx,vue}\": \"eslint --cache --fix\",\n \"packages/**/src/**/*.{css,less,vue}\": \"stylelint --cache --fix\",\n};\n```\n\n接着把`.husky/pre-commit`文件的内容改为:\n\n```shell\n#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx lint-staged $1\n```\n\n所以,完整的逻辑是:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4dc56d8bc1d14bf98494ac3ff0f8784c~tplv-k3u1fbpfcp-watermark.image)\n\n# 规范 commit\n\n首先安装一下 commitizen 及相关依赖:\n\n```shell\nyarn add -DW commitizen cz-conventional-changelog\n```\n\n然后在`package.json`中加入以下配置:\n\n \"config\": {\n \"commitizen\": {\n \"path\": \"cz-conventional-changelog\"\n }\n }\n\n接着就可以正常使用`git cz`命令了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e65d3dceaea443278f0ac45fa879fca9~tplv-k3u1fbpfcp-watermark.image)\n\n但是,即便引入了 commitizen,我们也不能保证开发者一定会使用 git cz 来规范自己的行为,所以我们可以再利用 git 的 commit-msg 钩子,再配合 commitlint 验证开发者提交的 commit 信息。\n\n```shell\nyarn add -DW @commitlint/config-conventional @commitlint/cli\n```\n\n新增一个配置文件:\n\n```shell\necho \"module.exports = {extends: [\'@commitlint/config-conventional\']};\" > commitlint.config.js\n```\n\n接着在`.husky`目录下新增一个`commit-msg`钩子。\n\n #!/usr/bin/env sh\n . \"$(dirname -- \"$0\")/_/husky.sh\"\n \n npx --no-install commitlint --edit $1\n\n此时不规范的 commit 就是无法通过的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/08c15d82ebbd4b1a926f460180d829d2~tplv-k3u1fbpfcp-watermark.image)\n\n# 回顾流程\n\n我们再来回顾和梳理一下流程:\n\n1. 开发代码\n2. git cz 交互式 commit\n3. husky + pre-commit + lint-staged 进行必要的 linter 校验\n4. husky + commit-msg + commitlint 进行 commit 校验\n5. 通过 lerna version 进行 version bump,并生成 changelog 和 github release,最后 push 到 github。\n6. 在 github actions 中执行打包和发布流程。\n\n2 \\~ 4 都是提交代码时触发的,针对第 5 步可以单独写个 script,比如:\n\n```json\n\"bump-version\": \"lerna version --conventional-commits --create-release github --yes\",\n```\n\n第 6 步是通过 github actions yaml 文件配置的,执行的主要脚本就是打包构建以及发布到 npm。\n\n```json\n\"release:ci\": \"yarn buildBatch && yarn publish:package\",\n```\n\n针对 github actions 的触发条件,我优先考虑的是 github release 的创建。\n\n```yaml\non:\n release:\n types: [created]\n```\n\n> `lerna version --create-release github` 命令的执行会触发 github actions workflow。\n\n但是在使用的过程中,我也发现一个问题,lerna version 不仅会修改真正发生内容变化的子包的版本号,还会修改 workspaces 中引用了这个子包的其他子包的版本号。\n\n这样说可能也不好理解,举例说明一下。\n\n假设我在一次开发过程中仅仅给`@vue-pro-components/utils`加了一个功能,在执行 lerna version 命令时,它的版本号`minor`位会加 1,这合情合理;\n\n由于`vue-pro-components`以及`@vue-pro-components/headless`这两个包都引用了`@vue-pro-components/utils`,所以它们俩的`package.json`中的依赖`@vue-pro-components/utils`的版本号也会升一级,因此它们俩自身的版本号也会随之更新。\n\n此时会生成三个 tag,并发布三个 github release,分别是`@vue-pro-components/utils@x.x.x`, `@vue-pro-components/headless@x.x.x`, `vue-pro-components@x.x.x`。\n\n其实创建三个 release 也没啥问题,因为我采用的是 lerna 的 Independent Mode,各个子包版本号独立;但是考虑到我的 github actions 的触发条件是**release 的创建**,这也意味着三个 release 会触发三次 github actions workflow,虽然三次 workflow 执行完的结果是一样的,但是完全没必要做重复的工作,按需使用是我们的宗旨。\n\n目前,lerna version 这个行为还没有参数可以用来控制开关,但是并不是说这是 lerna 的问题,也许我们可以改进自己的流程来规避这个问题。\n\n# 改进流程\n\n## idea 1\n\n接上面,我的第一个想法是,在不同的 release 对应的 workflow 中取出 release name,release name 中有包名的信息,自然就可以基于此按需打包发布。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a1d618d70a29442496d7b9f483935062~tplv-k3u1fbpfcp-watermark.image)\n\n但是,这也存在一个问题,实际上,包之间是有依赖关系的,也就意味着在某些工序上可能有先后顺序。\n\n如果所有子包都各自独立打包,其实是有问题的,比如当多个 release 对应的 workflow 同时进行时,如果包 A 依赖的某个包 B 还没打包并发布到 npm registry,就有可能导致 A 打包出错。\n\n所以最好的办法还是按依赖关系决定的顺序,放在一起打包发布。\n\n## idea 2\n\n我的第二个想法是:执行 lerna version 的时候不要创建 release,也就是不带`--create-release`参数。接着再通过其他脚本或工具给整个工程打个 tag 和 release。这样一来,一次发布过程就只会产生一个 release,因此也只会执行一次 github actions workflow,看起来还比较符合我的心意。\n\n我们考虑引入 release-it,用它来重新组织流程。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4e71d4a28120446cbabbdbf3bbca09a8~tplv-k3u1fbpfcp-watermark.image)\n\n我们这里用到了一个插件 [@release-it/conventional-changelog](https://github.com/release-it/conventional-changelog),它很重要。\n\n我们再理一遍流程:\n\n1. 首先还是写代码。\n2. 接着通过 git cz 做一次 commit。\n3. 经过必要的钩子检查。\n4. 开始执行 release-it,我们先利用 release-it 的`before:init`钩子执行`packages-bump-version`命令,`packages-bump-version`命令对应:\n\n\n\n lerna version --conventional-commits --no-private --yes\n\n其实就是在原来的基础上去掉了`--create-release github`。执行这条命令会更新 packages 目录下各个包的版本号,并为各个子包更新 CHANGELOG.md 文件。\n\n5. 接着 release-it 根据 git log 确定一份 changelog 信息,用于辅助后续过程。\n6. 由于插件 @release-it/conventional-changelog 实现了`getChangelog`和`getIncrementedVersionCI`方法,结合起来决定了下一个版本号,具体到内部逻辑,其核心是用到了 [conventional-recommended-bump](https://www.npmjs.com/package/conventional-recommended-bump) 这个包,它能基于 conventional commits 规范给出建议的 releaseType(对应 major, minor, patch 等),再结合 [semver.inc](https://www.npmjs.com/package/semver),就能得到下个版本号。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ff57c2feda6040c3873de382326a0101~tplv-k3u1fbpfcp-watermark.image)\n\n7. 接着就是执行 release-it 插件的各个钩子,以及收尾的`release`和`afterRelease`钩子。其中核心插件 npm 执行了关键的`bump`钩子,通过`npm version`更新了 package.json 文件中的 version 字段;插件 @release-it/conventional-changelog 用到了`beforeRelease`钩子来生成 CHANGELOG.md,其中用到了我们在上面提到的 conventional-changelog 这个基础包。\n\n# CHANGELOG.md 不符合直觉\n\n试用了上面的流程之后,总体感觉还好,没什么明显问题,但是我发现根目录下 CHANGELOG.md 的生成不符合我的直觉。\n\n由于我在 0.2.0 版本中提交了一个 feat 类型的 commit,相关的 Features 记录应该要体现到 CHANGELOG.md 中,但是结果并没有。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a87ac143a6734d34a94fb783e7a1a73d~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/783f48902a3a4e51a2a240707e123201~tplv-k3u1fbpfcp-watermark.image)\n\n我发现这是因为 lerna version 虽然去掉了`--create-release`参数,没有再创建 release,但是 tag 还是打出来了。这就会导致 release-it 在对比 `0.2.0` 和 `@vue-pro-components/headless@0.2.4` 两个 tag 的差异时,找出的 commits 只是 chore 类型的 release 说明,比如:\n\n chore: release v0.2.0\n\nchore类型的 commit 记录不足以体现到 CHANGELOG.md 中,这与 [Conventional Changelog Configuration Spec](https://github.com/conventional-changelog/conventional-changelog-config-spec/blob/master/versions/2.1.0/README.md) 有关。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/cf25baf0a8fa4e38b5d2e012eb9bad06~tplv-k3u1fbpfcp-watermark.image)\n\n所以要想办法去掉 lerna version 创建 tag 的行为。\n\n我查了一下 lerna version 的文档,发现有一个参数`--no-git-tag-version`看起来比较贴合我的需求,用了一下发现,它的行为是既不提交 commit,也不打 tag。而不做 commit 就会导致 git 工作区不是 clean 状态,这会导致后续的 release-it 流程无法继续。release-it 也有个配置项`git.requireCleanWorkingDir`可以关闭 git 工作区 clean 的检查,不过我暂时不打算这么做。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/320bfd33121a47bb845be9a23be4abf3~tplv-k3u1fbpfcp-watermark.image)\n\n我的思路是:由于我的目的还是去掉 lerna version 创建 tag 的行为,所以还是要使用 `--no-git-tag-version`这个参数,但是我紧接着会自行执行一次 commit,用于保持 git 工作区的 clean 状态。所以我把关键脚本改为下面这样了:\n\n \"packages-bump-version\": \"lerna version --conventional-commits --no-git-tag-version --no-push --no-private --yes\",\n \"commit-packages-version-info\": \"git add . && git commit -m \\\"chore: bump packages version\\\"\",\n \"determine-packages-version\": \"yarn packages-bump-version && yarn commit-packages-version-info\",\n\nrelease-it 的`before:init`钩子执行的脚本变成:\n\n \"before:init\": \"yarn determine-packages-version\",\n\n这就对应我上面说的思路,把一个完整的脚本拆成两个,第一个还是调用 lerna version,第二个变成调用我自己定义的 git add 以及 git commit 命令,基于此绕过创建 tag 的行为。\n\n按这个流程工作,根目录下生成的 CHANGELOG.md 变得正常,但是......\n\n# 顾此失彼\n\n当我以为万事大吉时,却发现,按照这个方案实践时,虽然根目录的 CHANGELOG.md 正常了,但是各个子包中的 version bump 以及 CHANGELOG.md 都变得不正常了,我们来分析一下 lerna version 为什么会出问题。\n\n由于我加上了`--no-git-tag-version`参数,这就会导致 lerna version 不会为各个子包打上特殊的类似于`@vue-pro-components/utils@0.0.1`的 tag,这会引起一些问题。为了搞清楚问题原因,我们来分析一下流程。\n\n经过 debug 发现,lerna version 会经过一些关键的节点。\n\n* @lerna/version/index.js 中的`getVersionsForUpdates`以及`recommendVersions`。\n* @lerna/conventional-commits 中的`recommendVersion`。\n* conventional-recommended-bump 中的`gitSemverTags`。\n* git-raw-commits 中的`gitRawCommits`。\n* conventional-commits-parser 中的`conventionalCommitsParser`。\n\n其中`gitSemverTags`决定了程序会查询哪些 tags,这里有一个关键的`lernaTag`函数,执行完`lernaTag`函数后,`tags`的值是一个空数组,这是因为我们放弃了打特殊 tag。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c36433dd0aa440548882567e6ced6a84~tplv-k3u1fbpfcp-watermark.image)\n\n如果`tags`是一个空数组,就会影响后面的`gitRawCommits`函数的执行,导致`from`参数是空的,这就意味着程序会读取整个 git log。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3b0d6d4bbacd43d890ef4a8cd2fda63f~tplv-k3u1fbpfcp-watermark.image)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/420c22b6007e49f8bfab33639b755b4d~tplv-k3u1fbpfcp-watermark.image)\n\n这就意味着:**不管我最近一次改的是什么内容,只要 git log 的历史记录中有 Breaking Change,下个版本号就会是大版本更新,同理,只要 git log 中有 feat 类型的 commit,下个版本号就会更新 minor 位。同时子包每个版本的 CHANGELOG.md 都是“大而全”**。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a04080f5c1a740a2ad0b01157bb1d57a~tplv-k3u1fbpfcp-watermark.image)\n\n这基本上就崩盘了,版本号都不对了。看来针对各个子包的特殊 tag 还是不能少,否则 lerna 也无法正确分析出下个版本号,所以`--no-git-tag-version`还是不能加,但是去掉又会发生上一节说的问题,怎么想办法解决一下呢?\n\n我的思路是:release-it 能不能只分析`0.0.0`这种格式的 tag 之间的差异,因为这种格式的 tag 是 release-it 针对整个工程打的,分析这两个 tag 之间的 commit 肯定是能够正确反映出整个工程的版本更新情况的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a64a574c9d5646a0a5de6f9fef347033~tplv-k3u1fbpfcp-watermark.image)\n\n经过调试发现,核心的问题在于`getChangelogStream`方法中,`latestTag`的值为 lerna version 针对某个子包打出的 tag,能不能想办法让他变成 2.1.0 呢?\n\n> 2.1.0 是调试时整个工程的版本号。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/87849bccc9c74e57a433db792cf45137~tplv-k3u1fbpfcp-watermark.image)\n\n首先是要找到`latestTag`是在哪里被赋值的,自然是优先在 release-it 的一些核心插件中去找,很快能找到目标模块 GitBase.js,其中的`getLatestTagName`方法决定了什么样的 tag 会作为候选目标。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2b21b14a79214c69aab20bed1e919d3c~tplv-k3u1fbpfcp-watermark.image)\n\n这里有一行很关键的代码:\n\n git describe --tags --match=${match} --abbrev=0\n\n`--match`是我们能通过参数`tagMatch`控制的,我们参照配置清单[release-it.json](https://github.com/release-it/release-it/blob/master/config/release-it.json)把`git.tagMatch`配置好,仅匹配数字开头的 tag 即可。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/71e1075b8e9b4ced90fe983245f74bb8~tplv-k3u1fbpfcp-watermark.image)\n\n经过这波优化,根目录和子包中的 CHANGELOG.md 都能正确地生成,也算是成功地把 lerna 和 release-it 结合起来了!\n\n# 遗留问题\n\n踩过上面几个坑后,咱们总结出来的流程基本上能应付简单的 monorepo 使用场景,但是也并非说就没有问题了。我遇到的一个很高频的问题就是:由于创建 release 的过程需要多次与 github 交互,这就涉及到国内比较经典的网络问题,可能会出现 lerna version 成功了,但是 release-it 的某个步骤与 github 失联的情况。release-it 会在失败后执行一些回滚操作,而 lerna version 脚本是在钩子中被执行的,release-it 并不会回滚这部分自定义的脚本,这就会导致回滚不彻底。\n\n不过这也是后话了,后面再说说怎么解决这个问题。\n\n# 细节\n\n在 debug 的过程中还学到了一些细节。\n\n## 主版本号为0,BREAKING CHANGE 无效\n\n当主版本号为 0 时,所有的变更都认为是不稳定的,此时即便是我们在 commit 信息中包含了 BREAKING CHANGE,lerna version 也不会为我们修改 major 版本号,具体请看下图:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4d94e6cb0ac3406fb757efd667ee7f6f~tplv-k3u1fbpfcp-watermark.image)\n\n> the transition from 0.x to 1.x must be explicitly requested by the user.\n>\n> Breaking changes MUST NOT automatically bump the major version from 0.x to 1.x.\n\n所以,如果你遇到的问题符合上述情况,请不必怀疑自己,0.x 版本到 1.x 版本的变更必须由你自行操作,工具不负责这个场景。没有实践过还真不知道这个细节!\n\n# 结语\n\n通过本文的学习,我们不仅能掌握如何组织起经典的前端研发流程,还能认识到,优秀的工具也不是拍脑袋想出来的,一定是先有规范,再根据规范出上层工具,所以制定规范是一件很重要的事情。另外一点就是,不要局限于开源工具提供的能力,可以自己适当地去想办法优化或者改造,以达到自己的目的。\n\n当然,文中所述流程不一定适合所有场景,仅供读者参考!\n\n如果您对我的专栏感兴趣,欢迎您[订阅关注本专栏](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\"),接下来可以一同探讨和交流组件库开发过程中遇到的问题。\n\n> 技术交流&闲聊:[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg)', '2023-03-08 21:29:39', '2024-10-04 10:31:29', 1, 424, 0, '笔者分享了自己在函数库研发流程方面的实战经验和改进思路,循序渐进,希望与读者们一同探索更好的前端研发流程。', 'https://qncdn.wbjiang.cn/博客素材/f4c3c7613d5e4bbc97f9c1bf0317ff48~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (251, '昨天发的npm包,却因为 registry 同步问题无法安装使用', '用过 HBuilderX 云打包的都知道,云上面的 Android 环境很有限,其实并不能覆盖 uniapp 生态所有的版本,甚至说只能覆盖最新的一两个版本。\n\n如果你需要用到 HBuilderX 安卓云打包,就必须及时跟进 HBuilderX 的版本更新,否则可能会因为编译时和运行时版本不一致而在APP运行时爆出提示,如果这个提示被用户看见,那就有点尴尬。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/runtime%E4%B8%8D%E5%8C%B9%E9%85%8D.jpg)\n\n但有时候我们也不想一直跟进最新的版本,此时只能基于 uniapp 提供的 SDK 自行搭建安卓离线打包环境(这不是本文关注的内容)。\n\n今天是 2023年8月18日,打开 HBuilderX 时提示我更新 3.8.12.20230817 版本,我没有犹豫先进行了更新。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/3-8%E7%89%88%E6%9C%AC%E6%9B%B4%E6%96%B0.png)\n\n当我打开另一个通过 cli 方式安装 uniapp 开发环境的项目时,则需要通过`npx @dcloudio/uvm`更新相关编译和运行时环境。因为 cli 方式的项目,它的编译器是跟着项目走的,而不是采用 HBuilderX 内置的编译器。\n\n但是当我执行`npx @dcloudio/uvm`命令时,报错了。\n\n Invalid version: 3.8.12.20230817\n\n于是我先找到`@dcloudio/uvm`中的`version.js`相关源码查看,发现它的基本更新步骤是:\n\n1. 默认情况,优先去分析 HBuilderX 官方的一个版本号相关的 json 文件,拿到最新的版本号。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%88%86%E6%9E%90version%E9%80%BB%E8%BE%91.png)\n\n2. 然后去 registry fetch 相关包的信息。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/fetch-registry.png)\n\n3. 根据自定义的一些规则去匹配版本号,找到相关的 npm 包的具体版本再下载。\n\n分析了基本过程,再考虑问题是:\n\n Invalid version: 3.8.12.20230817\n\n那么关键点肯定是没找到这个版本号对应的某些 npm 包的版本。\n\n我首先确定了一下`@dcloudio/uvm`采用的 registry,默认是 cnpm,也就是国内的 npmmirror。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/registry%E7%A1%AE%E8%AE%A4.png)\n\ndebug 后发现 npm 找不到其中一个包`@dcloudio/vue-cli-plugin-uni`的最新版本:\n\n No matching version found for @dcloudio/vue-cli-plugin-uni@2.0.2-3081220230817001\n\n于是我到 npm 核实了一下,这个包的`2.0.2-3081220230817001`版本实际上是存在的。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%A0%B8%E5%AE%9Enpm%E5%8C%85.png)\n\n那么是哪里出了问题呢?我想大概率是 registry 的问题,在国内环境,我们通常会用到 npmmirror,如果镜像站和源站的资源信息不同步,就有可能会出现这个问题。\n\n我们发布 npm 包,都是发布到**registry.npmjs.org**。而为了快速下载安装 npm 包,我们又会选择使用**registry.npmmirror.com**。所以今天遇到的问题是由于两个 registry 不同步导致的。\n\n我们找到 npmmirror 镜像站,发现它提供了一个同步按钮,这真的是太棒了。否则大概率只能焦急地等待镜像站在某个时间点触发同步操作;或者考虑更换 registry,那么 lock 文件也得变,风险太大。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/npm%E9%95%9C%E5%83%8F%E7%AB%99.png)\n\n点击这个同步按钮则会从 npmjs 源站进行同步,几分钟后问题就得到了解决。\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%90%8C%E6%AD%A5%E6%97%A5%E5%BF%97.png)\n\n以上问题,特此记录。\n', '2023-08-18 22:20:53', '2024-11-04 11:45:19', 1, 241, 0, '在国内环境,我们通常会用到 npmmirror,如果镜像站和源站的资源信息不同步,就有可能会出现一些奇怪的问题。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8F%92%E7%94%BB/question.png', 0, 0);
-INSERT INTO `article` VALUES (252, '关于 pnpm monorepo 项目中 TS2742 Error 的 workaround', '最近我在使用 pnpm 作为包管理器开发一个 monorepo 项目,从个人体验来说,在 monorepo 项目中,pnpm 确实要比 yarn classic 用得舒心,最让我欣喜的是 pnpm 对 workspace 协议的支持度很好;另外感受比较明显的一点就是,开发过程中感知到的由于依赖层级导致的 bug 也变少了。\n\n但是任何事情都不可能是完美的。果不其然,一个关键的 bug 就在等着我。我在这个 pnpm monorepo 项目中尝试为一个子包生成 d.ts 类型声明文件时,出现了一个 TS2742 错误。\n\n error TS2742: The inferred type of \'default\' cannot be named without a reference to \'.pnpm/@vue+runtime-core@3.3.4/node_modules/@vue/runtime-core\'. This is likely not portable. A type annotation is necessary.\n\n从错误信息最后一句话看,是需要加一个类型注解,但是从我的使用场景来看,相关类型应该是能够自动推导出来的,不需要画蛇添足。感觉很奇怪!\n\n第一反应是检查下 .pnpm 目录下的对应的文件是不是都正常。经检查,一切文件结构和软链接都正常。\n\n然后就想着是不是代码引入 vue 相关的依赖时有问题。看了报错处,都是很正常的一些引用,比如:\n\n import { defineComponent } from \"vue\"\n\n但是从报错信息来看,似乎是找不到 vue 内部的子包的相关类型,难道是通过 vue 找内部包的时候出问题了?\n\n另外很奇怪的一点是:VSCode 表现正常,鼠标悬停类型提示正常,面板也没有报出任何关于类型的错误。\n\n无奈,只能拿着错误信息去 google 搜索,确实找到了 github 上一些关联度很高的 issue,issue 来源包括 typescript, pnpm 等仓库。\n\n[microsoft/TypeScript#42873](https://github.com/microsoft/TypeScript/issues/42873)\n\n[microsoft/TypeScript#47663](https://github.com/microsoft/TypeScript/issues/47663)\n\n这些 issue 在21年,22年就提出了,但是目前也还没有 Close。我试了 issue 讨论中提到的一些方法,包括修改`preserveSymlinks`,在项目根目录安装对应依赖,设置 tsconfig.json 中的`paths`配置辅助 TypeScript 找到对应依赖的位置等,但是最后都没有奏效,可能是我的解决姿势不正确,最终困扰了几天,在 google, stackoverflow, github 上一无所获,也尝试过 debug 去分析代码执行过程,也没看明白。\n\n## 解决方法1:node-linker=hoisted\n\n于是我在想是不是 pnpm 的依赖结构导致的,如果放弃 symlink 这种方式会不会奏效。结果还真的行,虽然这不是我想要的解决方式,因为这样是完全放弃了 pnpm 的重要优势。\n\n具体做法:\n\n1. 在 .npmrc 文件中配置`node-linker=hoisted`\n2. 删除 node\\_modules 和 pnpm-lock.yaml\n3. pnpm i 重新构建依赖\n\n![pnpm](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/pnpm_hoisted.png)\n\n相关链接:[node-linker](https://pnpm.io/npmrc#node-linker)。\n\n## 解决方法2:依赖提层级 + paths 配置\n\n在使用 node-linker=hoisted 后,我仍然不死心,还是希望能够找到一个更好的方法,能解决问题的同时兼顾 pnpm symlink 的重要特性。\n\n说来也是缘分,前几天,一位圈内好友也遇到了类似问题,并且看到了我在 TypeScript issue 中的 comment,就找到了我讨论这个问题,并分享了他的解决方案。\n\n最终我的解决方法是,将`@vue/shared`这个包同时安装到 pnpm monorepo 项目的根级 node_modules 下。\n\n pnpm add -Dw @vue/shared\n\n再通过配置 tsconfig.json 中的 `paths` 配置项,辅助 TypeScript 能够找到对应的依赖。\n\n```\n\"paths\": {\n \"@vue/shared\": [\"./node_modules/@vue/shared\"]\n}\n```\n\n![](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/dep_structure.png)\n\n经测试,这个做法必须配置`moduleResolution`为`Node16`及以上。\n\n这个解法涉及到的一些关键点其实在一些 issue 中也有提到,不过我之前只是单独采用了 issue 中某一解决方法,而没有把这些方法结合起来尝试,最终导致我没有及时地解决掉这个问题。\n\n具体过程和原因就不分析了,如果有遇到相同问题的朋友,希望能对你有所帮助!\n', '2023-10-18 13:47:33', '2024-11-11 15:53:30', 1, 593, 0, 'error TS2742: The inferred type of \'default\' cannot be named without a reference to \'.pnpm/@vue+runtime-core@3.3.4/node_modules/@vue/runtime-core\'. This is likely not portable. A type annotation is necessary.', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%88%86%E7%B1%BB%E5%9B%BE/pnpm.png', 0, 0);
-INSERT INTO `article` VALUES (253, '解决 prettier/prettier 和 indent 冲突问题和一点简单思考', '用过 prettier 的都知道,经常会遇到 prettier 与 eslint 的某些规则冲突的情况。在[之前的一篇文章](https://juejin.cn/post/7160549169566842893#heading-1)中,我简单地描述过怎么搭建起应用 eslint/prettier 的基本配置,也提到了怎么解决 prettier 与 eslint 的一些冲突问题。\n\n其中有这么一段话,我直接引用过来:\n\n项目中要不要使用 Prettier 取决于个人,没有强制的要求,毕竟没有 Prettier 之前,大家也活得挺好。做这个决定前要搞清楚 Prettier 和 ESLint / StyleLint 这类 Linter 扮演的角色分别是什么。简单说就是 **Prettier 负责代码风格,而 Linter 负责代码质量**。\n\n> 引用官方文档的一句话:**Prettier for formatting** and **linters for catching bugs!**\n\n读过 Prettier 的这篇[文档](https://prettier.io/docs/en/integrating-with-linters.html)你就可以知道,Prettier 和 Linters 会有一些功能交叉和规则冲突。功能交叉指的是 Linter 除了负责代码质量外,本身也可以定义规则约束代码风格,这就有可能会与 Prettier 的代码风格产生冲突。这个时候,就需要通过 Linter 体系中的一些插件配置关掉一部分与 Prettier 有冲突的规则,尽量在风格上以 Prettier 为准,比如 [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) 和 [stylelint-config-prettier](https://github.com/prettier/stylelint-config-prettier)。\n\n这虽然解决了大部分问题,但是我偶尔在一些 tsx 的使用过程还是会遇到一些问题,比如 prettier/prettier 与 indent 的冲突问题。具体表现为:\n\nprettier 和 indent rule 交替生效,进入死循环。\n\n![prettier_indent冲突.gif](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/prettier_indent%E5%86%B2%E7%AA%81.gif)\n\n刚开始一直想不明白,明明已经使用 eslint-config-prettier 关闭冲突了,为什么还会有这种问题。想不明白总得解决问题,当时就只是通过加上 eslint-disable 先关闭出现问题的那个文件的 prettier/prettier 规则。\n\n这个问题也并不是在所有 tsx 文件中都会出现,大概率会出现在三元运算符的缩进场景下。\n\n终于,前几天在写一个新的 tsx 文件时,又遇到了这个问题,这次我实在是受不了了,也不想将就,就只能找找有没有新的讯息。\n\n幸运的是,还真的让我找到了,之前翻看 eslint-config-prettier github 的时候似乎还没找到这一段,可能是太多用户遇到了这个问题,现在 README 都有这个案例了。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/eslint-config-prettier%E8%AF%B4%E6%98%8E.png)\n\nit cannot touch `\"rules\"`! (That’s how ESLint works – it lets you override configs you extend.)\n\n所以根本问题在于 rules 的优先级很高,即便是 extends 里用了 eslint-config-prettier 也没法优先于 rules 中的相关规则。\n\n因此我们只要把 rules 中的 indent 配置删掉即可,把 indent 的控制能力交给 prettier 即可,eslint rules 就别操这个心了!正如前文所说,既然你选择了 prettier,就要明白代码风格由 prettier 管,eslint 主要负责代码质量就好了!\n \n附上 github 链接:[This eslintrc example has a conflicting rule \"indent\" enabled](https://github.com/prettier/eslint-config-prettier/#what-and-why) 。\n\n要说根本原因,可能还是没仔细研究过 eslint 的工作机制,或者说没有一个很深的印象,之前也是知道 rules 的优先级是更高的,不过在遇到这个问题时,根本没往那方面想,emm。\n\n\n', '2023-11-13 17:39:05', '2024-11-13 08:44:19', 1, 241, 0, '缩进问题逼死代码强迫症...', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/eslint-config-prettier%E8%AF%B4%E6%98%8E.png', 0, 0);
-INSERT INTO `article` VALUES (254, '前端如何学会全栈分页开发?源码和思路都在这了', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n## 前言\n\n这是博客系列中一篇讲具体业务的,话题是分页模型和滚动加载。\n\n分页和滚动加载,各位前端大佬们没做一千次也做了一百次了吧。所以光说前端没多大意义,这里是准备结合前后端的视角看看分页和滚动加载的实现,本质上也不难,高手直接略过。如果您对后端或数据库还比较陌生,相信读完本文您会有所收获!\n\n## 为什么要分页?\n\n为什么要做分页,想必大家都很清楚。假设数据库某个表的数据记录很多(成千上万甚至更多),那么在业务设计上不可能一次性把表的数据全部查出来返回给前端展示,这不仅对数据库来说是一种巨大负担,对网络传输、客户端渲染也有较大压力。\n\n所以我们需要用到分页,把数据一页一页地返给前端,像翻书一样,一次只看一页,实现一种按需取用的效果。\n\n瀑布流滚动加载也是同理,只不过是把第一页和后续页的数据拼起来展示。\n\n## 数据分页\n\n那么怎么实现分页呢?源头还是数据库,首先要探究数据库的分页能力。如果数据库层面不能实现分页,而是把数据全部查出返给前端,那么即便前端实现一种视觉上的分页效果,其本质上也是掩耳盗铃,没有太多实际意义。\n\n回到数据库角度,以 MySQL 为例,其分页查询的标准语法为:\n\n```\nSELECT * FROM `table_name` LIMIT offset, row_count \n```\n\n通过关键词`LIMIT`来限制查询的偏移量`offset`和记录数量`row_count`。\n\n举例如下:\n\n- 查询第一页文章,指定一页查10篇文章。\n\n```\nSELECT * FROM `article` LIMIT 0, 10\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/page_query.png)\n\n0 代表没有任何偏移,所以从第一条开始,一共查询 10 条数据。\n\n由于我删除了部分测试数据,所以 id 不是从 1 开始,不必感到疑惑,实际上 id=147 是表里的第一条记录。\n\n- 查询第二页文章,指定一页查10篇文章。\n\n当我们查第二页文章时,offset 应该怎么给出呢?我们可以抽象一下,偏移量其实就是第二页之前的文章数量(此例中就是第一页的数量)。以页码为 pageNo,页大小为 pageSize,则偏移量可以这样算出:\n\n```\nconst offset = (pageNo - 1) * pageSize\n```\n\n当 pageNo 为 2,pageSize 为 10 时,计算出来的 offset 也就是 10,所以我们实际得到的 sql 语句是:\n\n```\n// 偏移10,查10条记录\nSELECT * FROM `article` LIMIT 10, 10\n```\n\n> 假设不传 offset,LIMIT 后代表的就是 row_count,而 offset 也就自然等价于 0,即从第一条记录开始查询。\n\n基于此,我们还可以通过左连接关联作者、分类、标签等信息,结合时间排序、WHERE判断等,给出一个业务上实际需要的文章分页功能。\n\n \n\n## 案例分析\n\n### 确定数据结构\n\n我们先看下[博客首页](https://blog.wbjiang.cn/)的效果,文章列表就是一个分页模型。\n\n我们先观察 UI 上的整体效果,再分析后端需要提供什么数据,以及数据以什么样的结构返回。\n\n \n\n- 首先,分页每页的数据都是一个数组,这个没有太多的疑问。\n- 前端需要知道一共有多少页,或者一共有多少篇文章,才能知道如何展示总页数。\n- 除文章基础信息外,分类/标签/作者等信息需要从其他表关联得来。\n\n根据本项目实现的效果,我们会提供下面这样的数据结构:\n\n```\n{\n \"code\": \"0\",\n \"data\": [\n {\n \"id\": 文章id,\n \"article_name\": \"标题\",\n \"poster\": \"封面图\",\n \"read_num\": 阅读量,\n \"summary\": \"摘要信息\",\n \"create_time\": \"创建时间\",\n \"update_time\": \"修改时间\",\n \"author\": \"作者名\",\n \"categories\": [\n {\n \"id\": 分类id,\n \"categoryName\": \"pnpm\"\n },\n {\n \"id\": 分类id,\n \"categoryName\": \"TypeScript\"\n }\n ],\n \"tags\": [\n {\n \"id\": tag id,\n \"tagName\": \"pnpm\"\n },\n ]\n },\n // ...其他文章\n ],\n \"total\": 文章总数\n}\n```\n\n### 查询主表基本信息\n\n其中`data`就是文章数组,其中的文章基本信息都来源于`article`表,这个可以通过`SELECT`语句查询得来。\n\n```\nSELECT id,\n article_name,\n poster,\n read_num,\n summary,\n create_time,\n update_time\nFROM article\nWHERE private = 0\n AND deleted = 0\nORDER BY create_time DESC\nLIMIT 0, 10;\n```\n\n通过`WHERE`来加上一些限定条件,避免私密文章或者已逻辑删除的文章被查出。\n\n第一页通常是看最新发布的文章,所以我们使用`ORDER BY`和`DESC`实现一个按创建时间降序查询。\n\n最后是使用`LIMIT`做一个偏移和数量限制,本质上也就是分页查询。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/limit.png)\n\n### 分页总数怎么查?\n\n有了列表,就可以在 nodejs 响应中返回 `data` 数组了,但是文章总数`total`怎么来呢?这里提供两种方式,但是性能的对比我就不擅长了,请自行查阅相关资料,毕竟咱不是专业后端开发。\n\n第一种,我们知道 MySQL 提供了 `COUNT` 函数,它是可以提供总数统计的。\n\n```\nSELECT COUNT(*) FROM article;\n```\n\n第二种,利用`SQL_CALC_FOUND_ROWS`和`FOUND_ROWS()`也可以做到同样效果。\n\n```\nSELECT SQL_CALC_FOUND_ROWS\n id,\n article_name,\n poster,\n read_num,\n summary,\n create_time,\n update_time\nFROM article\nWHERE private = 0\n AND deleted = 0\nORDER BY create_time DESC\nLIMIT 0, 10;\n\nSELECT FOUND_ROWS() as total;\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/found_rows.png)\n\n那么到底用哪种方式性能更好呢?其实我心里也没底,之前也没有过多关注这个问题,因为脱离实际情况的性能优化都是扯淡。今天写到这里时,顺手查询了一下 MySQL 官方手册,发现 MySQL 推荐我们使用 `COUNT(*)`。\n\n这,,,我好像第一版实现就是用的 `COUNT(*)`,后面看了一些相关博客,才改成了`FOUND_ROWS`,这就有点尴尬了,哈哈哈。此问题具体见[The SQL_CALC_FOUND_ROWS query modifier and accompanying FOUND_ROWS() function are deprecated](https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_found-rows)。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/mysql_found_rows_deprecated.png)\n\n但是我仔细想了一下,`COUNT(*)` 有一点不好的在于,当查询语句带了 `WHERE` 限定条件时,前后语句的条件必须得一致,如果漏了条件就容易出事!\n\n举例,当我们只查询 id 大于 200 的分页数据时,使用 `COUNT(*)` 很容易忘记写条件,而使用 `FOUND_ROWS()` 就不用太过于担心,因为它与 `SQL_CALC_FOUND_ROWS` 修饰符一起保证了前后是一致的。\n\n针对 `COUNT(*)` 的这种问题,可能就需要对 SQL 语句的调用做封装了,避免人为出错,或者是不是通过 ORM 等工具解决这个问题。我目前还是裸写 SQL 比较多,后续再考虑上 ORM。\n\n### 分页过程的关联表信息\n\n拿到了文章主表的基本信息后,我们还需要展示分类、标签、作者等信息,而这些信息是存储在其他表中,关联关系是靠外键或者关系表维护起来的。\n\n我们先看作者信息,在设计数据库时,我考虑的是一篇文章只有一个作者,所以文章和作者的关系是一对一,而一个作者可以有多篇文章。针对这种关系,我们使用外键约束即可,在文章表中使用外键`author_id`去引用用户表的主键`id`。\n\n在查询作者信息时,通过`LEFT JOIN`就能带出作者名。\n\n```\nSELECT SQL_CALC_FOUND_ROWS\n a.id,\n // ......省略部分 article 表字段\n a.update_time,\n u.nick_name AS author\nFROM article a\nLEFT JOIN user u ON a.author_id = u.id\nWHERE a.private = 0\n AND a.deleted = 0\nORDER BY a.create_time DESC\nLIMIT 0, 10;\n\nSELECT FOUND_ROWS() as total;\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/mysql_left_join.png)\n\n针对文章分类信息,因为一篇文章可能属于多个分类,而一个分类下也能有多篇文章,这是一种多对多关系。这里采用的是关系表作为中间表来维护关系。我们继续用`LEFT JOIN`来查出分类名称。\n\n```\nSELECT SQL_CALC_FOUND_ROWS\n a.id,\n // ......省略部分 article 表字段\n a.update_time,\n u.nick_name AS author,\n c.category_name\nFROM article a\nLEFT JOIN user u ON a.author_id = u.id\nLEFT JOIN article_category a_c ON a.id = a_c.article_id\nLEFT JOIN category c ON a_c.category_id = c.id\nWHERE a.private = 0\n AND a.deleted = 0\nORDER BY a.create_time DESC\nLIMIT 0, 10;\n\nSELECT FOUND_ROWS() as total;\n```\n\n分类数据是关联出来了,但同时我们也发现了一个问题,部分同一个`id`值的文章(也就是同一篇文章)出现了两次以上。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/row_double_show.png)\n\n这是因为有的文章关联了2个以上的分类,通过左连接查询自然就会出现多条记录。此时我们要用到分组,也就是 `GROUP BY`;同时为了将合并后的分类信息作为一列展示,我们还需要用到 `GROUP_CONCAT()`。\n\n```\nSELECT SQL_CALC_FOUND_ROWS\n a.id,\n // ......省略部分 article 表字段\n a.update_time,\n u.nick_name AS author,\n GROUP_CONCAT(DISTINCT c.category_name SEPARATOR \",\") AS categoryNames\nFROM article a\nLEFT JOIN user u ON a.author_id = u.id\nLEFT JOIN article_category a_c ON a.id = a_c.article_id\nLEFT JOIN category c ON a_c.category_id = c.id\nWHERE a.private = 0\n AND a.deleted = 0\nGROUP BY a.id\nORDER BY a.create_time DESC\nLIMIT 0, 10;\n\nSELECT FOUND_ROWS() as total;\n```\n\n这样我们就离想要的结果越来越近了。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/row_group_by.png)\n\n类似地,我们可以把分类 id,标签 id,标签 name 等信息也关联出来。用到分类 id,主要是为了方便提供分类页面的链接,这样就可以实现点击分类名称跳转到分类的详情页面,标签也是同理。\n\n数据库部分设计大概就讲到这里了,后端 nodejs 代码主要就是对以上逻辑的封装,不再展开叙述,具体可以 clone 源码查看。\n\n### 分页的前端呈现\n\n前端部分大家都比较熟悉了,不太需要深入分析。分页模型中,前端列表永远只展示当前页的数据,也就是 data 返回什么,就展示什么,不存在拼接数据问题。\n\n### 滚动加载的前端呈现\n\n滚动加载与分页模型最大的不同在于,数据是需要拼接起来的,每查到一页新数据,都需要通过`concat`等手段将数组拼接起来。\n\n随着不断滚动呢,数据会越来越多,如果为了性能考虑,可能还会出现虚拟滚动等需求;而为了视觉美观效果,则会出现不定高自适应瀑布流的需求。不过这些,都不在本文研究范围之内,仅引出一些拓展的话题!\n\n## 小结\n\n本文主要分享了我在设计分页和瀑布流业务时的一些思考,主要讲的也是核心的数据设计思路,而业务代码部分则没有选择重点叙述,感兴趣的朋友可以简单看看源码,链接都附在文章开头了。\n\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2023-11-22 10:59:08', '2024-08-05 15:04:08', 1, 185, 0, '从全栈视角聊聊分页模型和瀑布流的设计,希望对主攻前端的小伙伴有所帮助...感兴趣的朋友可以简单看看源码,链接都附在文章开头了。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8F%92%E7%94%BB/post.png', 0, 0);
-INSERT INTO `article` VALUES (255, '一键到顶和侧边弹射效果制作,复习巩固“切图仔”基本技能', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n## 前言\n\n2023 年再写一键到顶和侧边菜单栏弹射效果显得过于简单,不过既然是目录里规划好的一篇内容,咱还是按计划把它完成。\n\n首先通过两个动图看看具体效果,再来研究怎么实现!\n\n- 一键到顶:\n\n![scroll_to_top.gif](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/gif/scroll_to_top.gif)\n\n- 侧边菜单栏弹射效果:\n\n![aside_in.gif](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/gif/aside_in.gif)\n\n## 一键到顶\n\n回到网页顶部本质上就是修改`scrollTop`的值,所以最简单粗暴的方法就是直接修改滚动元素的`scrollTop`。\n\n一般来说,滚动元素就是网页的`body`,所以我们会习惯于修改`body`元素的`scrollTop`,将它的值设置为`0`。\n\n```javascript\ndocument.body.scrollTop = 0\n```\n\n但是有时候,我们会发现修改`body`的`scrollTop`并不会生效,滚动条还是在原地没动。这是因为:\n\n- 当页面具有 DOCTYPE,或者说指定了 DOCTYPE 时,使用`document.documentElement.scrollTop`。\n- 当页面不具有 DOCTYPE,或者说没有指定了 DOCTYPE 时,使用`document.body.scrollTop`。\n- 为了兼容各种情况,建议同时使用这两种写法。\n\n所以为了保险,两种都写上就行:\n\n```javascript\ndocument.documentElement.scrollTop = 0\ndocument.body.scrollTop = 0\n```\n\n虽然回到网页顶部的功能实现了,但是网页是一瞬间回到顶部的,缺失了过渡效果。如果我们希望有一个过渡的效果,就需要另外想办法了!\n\n### scroll-behavior\n\n第一种方法是设置目标元素的 CSS 属性`scroll-behavior`,将其值设置为`smooth`即可。\n\n```css\nhtml {\n scroll-behavior: smooth;\n}\n```\n\n此时再通过 JS 操作`scrollTop`就可以得到平滑的滚动效果了,而滚动的缓动效果和时长是由浏览器自身实现决定的。\n\n这个方法也是最简单的,只需要多加一行 CSS 属性即可!\n\n兼容性参考:\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/scroll-behavior%E5%85%BC%E5%AE%B9%E6%80%A7.png)\n\n可以发现 IE 是完全不支持这个属性的,而 iOS 15 以下的 Safari 支持度也几乎可以认为是没有的。在实际项目的生产环境中使用时稍微注意一下即可,不过我们这个是个人项目,随便用也无伤大雅!\n\n### window.scrollTo\n\n既然 CSS 可以提供平滑滚动的效果,那么 JS 行不行呢?答案是肯定的!使用 BOM 提供的`window.scrollTo`也可以达到 smooth 的效果,其中`behavior`参数也支持`smooth`。\n\n```javascript\nwindow.scrollTo({\n top: 0,\n behavior: \"smooth\"\n});\n```\n\n是不是也很简单,用这个 API 调用的方式来替代直接修改`scrollTop`属性也是一个不错的选择!\n\n那么`behavoir: \"smooth\"`的兼容性怎么样呢,可以说和 CSS 的`smooth`差不太多,甚至更差!\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/scrolltop_options_behavior%E5%85%BC%E5%AE%B9%E6%80%A7.png)\n\nSafari 明确表示不支持`smooth`:\n\n> Safari does not have support for the `smooth` scroll behavior.\n\n### 自定义缓动效果\n\n既然上面两种方法的兼容性不是很好,那么我们可以尝试自己用 JS 实现一下。得益于 JS 的灵活性,整个滚动行为的缓动效果和时长我们都能很好地控制。\n\n这里有两个问题要考虑,一个是总时长,一个是缓动效果。\n\n假设网页当前滚动的距离是`1000px`,计划`0.5`秒完成滚动到顶部的效果,假设是匀速滚动,那么相当于每个`px`需要花`0.5 / 1000`秒(也就是`0.5`毫秒)的时间滚动。\n\n但是`0.5`毫秒其实人眼是感知不到的,JS 定时器也不能处理低于 16 毫秒的逻辑。所以我们换个角度去思考,把 1 秒换算成 60 帧,那么 0.5 秒就是 30 帧,所以这个动画总共就是 30 帧,只要我把`1000px`的滚动分成 30 帧去实现即可。\n\n如果是匀速,是不是意味着 1 帧滚动`(1000 / 30)px`,约等于`33.33px`。这样一分解,问题就简单了。\n\n如果不希望是匀速呢?也就是每一帧可能滚动的距离都是不一样的,对于这种缓动效果,我们一般会用到贝塞尔曲线。不懂贝塞尔曲线的数学原理不要紧,我们只要调试到一个自己感觉舒适的效果,把曲线的值保留下来即可。\n\n打开[cubic-bezier.com](https://cubic-bezier.com/)在线调试贝塞尔曲线,拖动控制点调整,直到得到自己满意的曲线为止。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/easing.png)\n\n简单观察后我们能发现,ease 是先快后慢;linear 就不必说了,是匀速。感觉 easy 或者 ease-in-out 是比较适合一键到顶这个场景的。\n\n拿到合适的贝塞尔曲线后,就是要将它变成代码了,这里先安装[bezier-easing](https://www.npmjs.com/package/bezier-easing)这个库。\n\n首先初始化它,\n\n```javascript\nimport BezierEasing from \"bezier-easing\";\n\nconst easingFunc = BezierEasing(0.42, 0, 1, 1);\n```\n\n得到的`easingFunc`是一个函数,它的输入是 0 ~ 1 的数值,代表在 0% ~ 100% 的各个位置应该得到的结果值。换句话理解,假设输入 0.5,则能得到在经过一半的滚动时长时,`scrollTop`应该是什么值,也就是说每个时间点的值都可以随着输入的比例算出来。\n\n```javascript\nconst scrollTop = easingFunc(0.5)\n```\n\n那么每一帧的逻辑怎么做呢,有两个方法可以参考:\n\n- 利用[window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame),它是和屏幕的刷新率有关系的,一般是达到 60 FPS 的效果。\n- 在`requestAnimationFrame`没有出现之前,`setTimeout`使用 16.67ms 也是一个常见的选择。\n\n具体实现:\n\n- 根据总时长 duration(单位为秒)算出总帧数 total,也就是 duration * 60\n- `requestAnimationFrame` 中根据当前是第几帧(step),再结合贝塞尔曲线函数得到当前应该滚动到的位置,修改`scrollTop`,只要 step 没有达到总帧数 total,就可以使 step 加 1,递归调用上述逻辑。\n\n代码参考:\n\n```javascript\nfunction animateSetScrollTop({ target = document.documentElement, start, end, stepNo = 1, stepTotal }: StepOptions) {\n const next = getNextScrollTopValue(start, end, stepNo, stepTotal);\n window.requestAnimationFrame(() => {\n setElementScrollTop({\n target,\n value: next,\n });\n if (stepNo !== stepTotal) {\n const nextStepNo = stepNo + 1;\n animateSetScrollTop({\n target,\n start,\n end,\n stepNo: nextStepNo,\n stepTotal,\n });\n }\n });\n}\n```\n\n## 侧边菜单栏弹射\n\n说完一键到顶,接着说第二个效果,侧边菜单栏的弹射效果。这个效果实现起来的关键在于:\n\n- 菜单栏弹出时,有一个将内容主体区域推出去的效果,而非菜单栏直接盖在内容区域之上。\n- 弹出和收回时,不能出现滚动条,否则会显得比较突兀。\n- 菜单栏展示时,滚动鼠标滚轮时不能发生滚动行为。\n\n设计布局时可以想象一下这个过程。\n\n在菜单隐藏时,其实菜单就是排布在视野之外的。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/menu-aside-layout_description.png)\n\n在菜单出现时,菜单和内容区域整体往右边推出一段距离。\n\n最开始设计时,想过利用 flex 布局,右边内容区域占据剩余宽度,左边菜单在弹出的过程中慢慢从`0px`变成实际的宽度。但是操作的过程中因为有宽度的变化,很容易出现内部元素的布局变化,比如文字换行之类的。\n\n最后还是决定右侧的内容区域占据整屏的宽度,左侧菜单则是用绝对定位贴在内容区域的左侧。\n\n```css\nposition: absolute;\ntop: 0;\nwidth: 230px;\nheight: 100%;\nbackground: #222;\n// 保证向左侧再平移一个菜单的身位,正好消失在视野外\ntransform: translate3d(-100%, 0, 0);\n```\n\n菜单弹出的过程就是把整个容器往右平移`230px`,也就是菜单的宽度,这个过程是采用动画还是过渡效果都是可以实现的。\n\n针对**弹出和收回时,不能出现滚动条**,主要是在`translate`的过程中保证`overflow-x`方向的`hidden`即可。\n\n针对**菜单栏展示时,滚动鼠标滚轮时不能发生滚动行为**,只需要把`body`的`overflow`设置为`hidden`即可。\n\n总的来说,样式的调试需要大量的实践去检验和调整,最终得到想要的效果,并且解法也不是唯一的。\n\n## 小结\n\n本文内容说难不难,说简单也有一些值得思考的地方,看到最后的朋友就当温习一下相关知识点吧。\n\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-01-23 09:42:08', '2024-08-09 07:13:45', 1, 201, 0, '2023 年再写一键到顶和侧边菜单栏弹射效果显得过于简单,不过既然是目录里规划好的一篇内容,咱还是按计划把它完成。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8F%92%E7%94%BB/dev.png', 0, 0);
-INSERT INTO `article` VALUES (256, '怎么在 push 代码到 github 时用上小飞机?', '最近很多朋友都提到 github 很难打开,开了小飞机能访问 github 网站了,但是在 push 代码时又没办法 push 上去。\n\n遇到的报错日志大概是这样的:\n\n```\nfatal: unable to access \'https://github.com/your-user-name/your-repo.git/\': Failed to connect to github.com port 443 after 21057 ms: Timed out\n```\n\n很明显,是网络超时了。并且重试很多次也没有什么起色。\n\n我们心里可能会想,明明我已经科学上网了,为啥 github 还 push 不上去?这是因为 git 默认是不会使用系统网络代理的,即便我们的某猫工具已经被设置为系统网络代理,还是解决不了往 github push 代码的问题。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/system_proxy.png)\n\n那么怎么在 push 代码到 github 时用上小飞机呢?\n\n其实只要用到网络请求的,基本上都可以设置代理,git 也不例外。\n\n首先,我们找到代理工具的网络端口,以某猫为例,一般是 7890 端口。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/clash_port.png)\n\n然后,通过 git 提供的配置能力设置 http 和 https 代理。\n\n我们可以先查看一下之前有没有设置过代理,\n\n```\ngit config --global http.proxy\ngit config --global https.proxy\n```\n\n如果执行上述命令后没有任何输出,说明没有设置过代理,此时可以执行以下命令:\n\n```\ngit config --global http.proxy 127.0.0.1:7890\ngit config --global https.proxy 127.0.0.1:7890\n```\n\n此时再 push 代码到 github 就显得轻而易举了。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/push_with_proxy.png)\n', '2024-01-23 10:15:52', '2024-01-23 16:20:05', 1, 4, 0, '最近很多朋友都提到 github 很难打开,开了小飞机能访问 github 网站了,但是在 push 代码时又没办法 push 上去。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/clash_ui.png', 1, 0);
-INSERT INTO `article` VALUES (257, '用 Coze 挖掘出疑似的掘金标题党,纯娱乐', '最近 AI 的热度居高不下,Sora 横空出世又在 AI 话题上掀起了一个新的高潮。虽然每天还是会时不时的用一些平台的大模型工具帮助自己打开思路、提高工作效率,但是像 [Coze](https://www.coze.cn/) 这样的 AI 应用一站式平台,我之前确实没使用过。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/coze.png)\n\n## 认知的转变\n\n在认识 Coze 之前,我们站在一个大模型萌新用户的视角回顾下我们对大模型的认知。\n\n大模型是经过大量的数据和计算训练出来的深度学习模型,它擅长的是根据用户的提示词给出回答结果,这个结果可能是非常专业的,也可能会与我们的预期结果相去甚远。\n\n除了一些日常的知识问答、答疑解惑,我们可能会想用大模型达成一些稍微复杂的目标,比如期望大模型能够帮我们完成一些日常工作。\n\n假设我是掘金的运营,搞了一个运营活动,让作者们写文章参加活动,但是活动结束时掘友们提交的文章数量可能成千上万,要选出最符合活动要求的文章也是非常费劲的,那么有没有可能让大模型帮我们完成这件事情呢?\n\n站在人的角度思考,我们这项工作的思路是这样的:\n\n1. 基于活动时间范围、文章关联的话题或者文章的首尾内容特征,筛选出参加活动的文章,这个功能在运营后台肯定是有的。\n2. 逐个审核,选出一批大概符合要求的文章,淘汰掉与活动要求相去甚远的文章。\n3. 经过多轮审核筛选和淘汰,得到结果。\n4. 复审结果,公示。\n\n但是我们如果把这个思路作为提示词告诉大模型,它能给出我们想要的结果吗?\n\n很显然,大概率不能。大模型的泛化能力很强,结果很难预测。\n\n另外,这项工作还是比较复杂的,步骤很多,中间还涉及实时数据的获取和处理,这可能不是大模型擅长的事情。\n\n我还记得最开始使用 GPT 的时候,我问过今天的天气情况,今天的热点新闻之类的问题,GPT 都不能给出答案,它会回复“抱歉,我只是一个语言模型,无法获取最新的天气情况”之类的话术。即便我告诉 GPT,你可以调用这个接口去获取天气或者新闻,它也回复做不到,至少目前做不到。\n\n所以上述运营工作中获取数据这一步,不应该由大模型来负责。我们应该拆解上述工作,只把评估文章的工作交给大模型,让大模型做自己擅长的事情。\n\n基于此,相比于日常使用中随便问大模型几句话,我们得到了运用大模型的一个新的思路,是不是可以把工作流和大模型结合起来呢?把一个复杂的工作分解成多个步骤节点,大模型是工作流中的一个节点,可以接受其他节点处理的结果作为输入,分析并输出我们要的结果。这也是目前 Coze 这类 AI 应用平台的重要功能之一。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/workflow.png)\n\n## Coze 实战\n\n掘金的运营是怎么分析一个活动的数据并得到结果的,其实我也不是很清楚,所以我们换个掘友们熟悉的概念来实战 Coze,怎么让 Coze Bot 告诉我们今天掘金热榜文章中谁的标题党嫌疑最大。\n\n我发布了一个叫做[“前端砖家”](https://www.coze.cn/store/bot/7340569855348817930)的 AI Bot,大概的效果是这样的。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/bot-preview.png)\n\n> 正如标题所言,这完全是纯娱乐的,不必认真。\n\n我们慢慢来研究下这个 AI Bot 是怎么做出来的。\n\n首先,我们注册 Coze 后,系统会引导我们创建一个 Bot,即便我们什么都不做,它也有基本的大模型能力。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/coze-guide.png)\n\n但是你要问它今天掘金热榜里谁最标题党,它显然是回答不了的。因为这个问题跟上文中说的运营工作类似,也是一个复杂的工作流。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/can-and-cant.png)\n\n那么我们开始尝试用工作流完成这件事。\n\n新建一个工作流之后,工作流中就只有开始和结束两个节点,对于要分析出标题党这个工作,我也是一脸懵逼。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/start-workflow.png)\n\n不过看到左侧面板,我决定先拉个大模型节点出来看看。我给出的提示词是“掘金热榜文章中,哪一篇标题党嫌疑最大”,试运行的结果看着挺像回事的...\n\n> 运行结果:\n>\n> 标题党是指在网络上故意使用夸张、吸引眼球的标题来吸引读者点击,但内容往往与标题不符或质量不高的行为。在掘金热榜文章中,标题党嫌疑最大的一篇文章是《从 0 到 1 实现高并发的秒杀系统(方案+代码)》。 这篇文章的标题使用了“从 0 到 1”、“高并发”、“秒杀系统”等吸引眼球的词汇,但文章内容仅仅是介绍了一些秒杀系统的基本概念和实现方案,并没有提供具体的代码实现。因此,这篇文章的标题存在一定的标题党嫌疑。 需要注意的是,标题党的行为不仅会影响读者的阅读体验,也会对文章的质量和可信度造成负面影响。因此,建议作者在撰写文章时,不要过分追求标题的夸张和吸引眼球,而应该注重内容的质量和价值。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/workflow-v1.png)\n\n实际上说到的文章在掘金热榜中根本不存在,也就是说它是杜撰的。掘友们应该都被 GPT 骗过吧?\n\n所以我们应该给大模型提供数据支持,给它真实的数据,再让它判断。\n\n我们找到左侧的插件Tab,发现正好有掘金热榜的插件。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/coze-plugins.png)\n\n这个插件可以提供掘金热榜的数据。\n\n我们来认识一下什么是 [Coze 插件](https://www.coze.cn/docs/guides/plugin),说白了它可以执行 API 请求,你可以认为是执行 HTTP 请求,给定输入,能给你返回输出结果。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/plugin-use.png)\n\n这种标准的 application/json 格式应该很熟悉了吧。\n\n有了这个插件提供的结果,那还不直接完事了?掘友们别急,事实上可能没有这么简单,接着看。\n\n我们将插件的输出作为大模型节点的输入,修改提示词后试运行,发现结果也不是特别理想。\n\n> 此时的提示词:\n>\n> {{list}}\n> \n> 以上是一个热榜文章数组,数组中每个元素都是一个对象,对象的content.title字段是文章的标题。\n> \n> 基于以上信息,请分析文章数组中标题党嫌疑最大\n\n插件输出没什么毛病,数据完全正确,但是大模型的输出似乎又有点胡扯。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/workflow-v2.png)\n\n于是我想着肯定是大模型无法理解我上面这段话的结构,尝试修改后有了更好的结果。\n\n> 优化后的提示词:\n> \n> const list = {{list}}\n> \n> list是一个热榜文章数组,数组中每个元素都是一个对象,对象的content属性下的title属性是文章的标题。\n> \n> 基于文章标题分析list中标题党嫌疑最大的是哪一项,只输出title属性的值即可。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/ai-output.png)\n\n但是输出的结果也不是我要求的格式,我只想要一个标题就行了。另外,这个大模型节点运行一次会耗费多达2W多的 token,有点恐怖。\n\n必须再做点什么了,因为我们只需要大模型分析标题内容,所以不必将整个数组灌输给大模型,我们加一个前置的节点,把插件结果进行处理,提取出更简练的标题数组。\n\n针对以上需求,只需要加一个代码节点即可。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/code-node.png)\n\n试运行之后,发现结果是我想要的了,并且大模型运行一次的 token 耗费降低到了 1K 级别。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/workflow-v3.png)\n\n最后,只需要让大模型补充它评选标题党的理由即可。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/workflow-v4.png)\n\n这基本上就是[“前端砖家”](https://www.coze.cn/store/bot/7340569855348817930)这个 AI Bot 关于掘金热榜标题党的成品效果了。\n\n以上纯属娱乐,不代表真实的标题党评估,因为我们只分析了标题,还没分析文章的内容,这个交给下一步实现了,下次再见!', '2024-03-12 12:12:35', '2024-11-14 05:59:23', 1, 284, 0, '重新认识AI大模型,从快速搭建一个AI应用开始!', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/coze/coze.png', 0, 0);
-INSERT INTO `article` VALUES (258, '一篇博客如何来到用户面前,分享前端也能看懂的文章详情页全栈设计', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n根据费曼学习法,把知识传递给别人并让ta懂,你才是真的学会了,好的,不偷懒,继续更新!\n\n## html or markdown ?\n\n本节接着讲具体业务的实现。在我们搭建博客站点时,最核心的部分也就是文章内容。从用户视角看,文章内容就是图文甚至视频等元素的组合;从开发者角度看,文章内容的呈现形式就是一段渲染好的 DOM。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/xiaoguo.png)\n\n但是从数据存储的层面看,我们应该存储什么信息呢?一般来说,会考虑存储 html 文本或者 markdown 文本。\n\n如果我们是在为公司做一个新闻公告类的功能,通常这个功能的使用者并不是程序员,而是运营等其他岗位的人,他们通常在编辑新闻时需要所见即所得的效果,因此我们在编辑器这块会优先选择富文本编辑器,存储的内容自然也就是 html 文本。\n\n但是对程序员博客或者文档网站来说,通常会选择 markdown。对我个人来说,我比较在意以下几点:\n\n1. markdown 是更轻量的标记语言,源文本格式更清晰易阅读,通常不会携带样式信息产生干扰。\n2. 从写博客的体验上来说,markdown 的效率也更高,因为博客通常是图文,只要记住简单的标题、图片、链接、加粗等常用语法,写出来的内容就是结构化很清晰的。\n3. 渲染样式可以在呈现页或者组件中再去控制,非常容易自定义!\n4. 非常容易维护,各平台通用,这是核心!!!\n\n那我们接下来就介绍一下如何基于 markdown 来实现文章的编辑、存储、查询、展示等功能。 \n\n## markdown 编辑\n\nmarkdown 的编辑功能是需要一个编辑器去支撑的,比如我们在掘金等平台上写文章用的编辑器,就支持 markdown 模式。\n\n但是开发一个编辑器的成本比较高,我不推荐在自己的博客中去完整实现一套编辑器功能,这需要花费大量的时间去实现工具栏、交互、输入、渲染等功能。用 typora 或者 notion 它不香吗?\n\n我更倾向于去使用市面上优秀的编辑器(无论是在线的或者是离线的)去编辑内容,写好了再复制或导入到我们自己的博客系统中,只在博客系统中加入预览功能即可,这样可以节约我们很多时间。\n\n关于预览,本质上是通过 markdown 引擎将 markdown 文本解析为 html 文本,然后通过`innerHTML`等方式去渲染出来。有下面这样一个效果就足够了!\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/editor.png)\n\n## 保存文章\n\n除了 markdown 之外呢,文章还有一些重要的信息需要保存,比如标题、封面图、作者、摘要、分类、标签等。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/save.png)\n\n标题、封面图、作者、摘要这些信息都是附属于文章的,所以放在 article 表中即可。分类和标签这些都要靠关系表去维护。文章和分类是多对多的,标签同理。\n\n所以我们大概会用到这些圈起来的表。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/table.jpg)\n\n其中主表 article 设计这么一些字段即可,如果你 clone 下来认为有哪里不合适的,可以另外去修改或扩展。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/article_table.jpg)\n\n整个新建文章的过程其实涉及到关键几步:\n\n1. 插入文章表记录。\n2. 判断是否插入新的分类、新的标签。\n3. 关系表 article_category, article_tag 插入。\n\n那么我们插入文章记录就是用到 INSERT INTO 语句。\n\n```\nINSERT INTO article (article_name, article_text, summary, author_id, poster) values (?, ?, ?, ?, ?)\n```\n\n可以看到我们只插入了 article_name, article_text, summary, author_id, poster 这些信息,其中 author_id 也是外键去关联的 user 表的主键。create_time 我们也不需要处理,MySQL 设置该字段 DEFAULT CURRENT_TIMESTAMP 即可。\n\n剩余的分类标签信息就是用关系表去做的。\n\n针对分类,我们要看这篇文章是否引入了新的分类,如果引入了新的分类,就先创建分类表的记录,再去插入关系记录。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/category_and_tag.jpg)\n\n标签的处理也是同样的,但是在前端,我们并没有完全把所有标签都展示出来提供选择的能力,而是只提供了输入的能力,通过输入再去匹配数据库里有没有对应的标签,从而判断是否需要插入新的标签记录。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/tag.jpg)\n\n由于这里涉及多个步骤,我们需要用事务来处理,具体可以下载源码再去了解。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/code.jpg)\n\n## 文章详情页的展示\n\n由于分页在这一篇[《Vue3+TS+Node打造个人博客(分页模型和滚动加载)》](https://juejin.cn/post/7301242196888387593)文章中已经讲过了,这里我们接着将文章详情页的实现。\n\n在详情页中,主要就是取出 detail 数据来展示,也就是查询出 id 对应的文章记录来展示。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/detail.png)\n\n另外就是把上一篇下一篇这种导航式信息查询出来,方便读者继续浏览!\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/prev_next.jpg)\n\n还有文章的评论,这一部分留到后面单独介绍!\n\n我们先看怎么取出详情数据,主体就是一个入门的 SELECT 语句,但是要把分类等关联信息带出来,还会用到一点连接查询。语句大概是这样的:\n\n```\nSELECT a.*, u.nick_name AS author, GROUP_CONCAT(DISTINCT c.id SEPARATOR \" \") AS categoryIDs, GROUP_CONCAT(DISTINCT c.category_name SEPARATOR \" \") AS categoryNames, GROUP_CONCAT(DISTINCT t.id SEPARATOR \" \") AS tagIDs, GROUP_CONCAT(DISTINCT t.tag_name SEPARATOR \" \") AS tagNames FROM article a\n LEFT JOIN user u ON a.author_id = u.id\n LEFT JOIN article_category a_c ON a.id = a_c.article_id\n LEFT JOIN category c ON a_c.category_id = c.id\n LEFT JOIN article_tag a_t ON a.id = a_t.article_id\n LEFT JOIN tag t ON a_t.tag_id = t.id\n GROUP BY a.id\n having a.id = ?\n```\n\n我们来拆解下这个过程,首先查主表信息:\n\n```\n// id 取实际的 id 即可\nSELECT * FROM article a WHERE id = 147\n```\n\n运行后我们发现文章的基本信息都有了,\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/select_basic_info.jpg)\n\n接着我们引入比较简单的作者信息,作者来源于 user 表,做个左连接查一下。\n\n```\nSELECT a.*, u.nick_name\nFROM article a\nLEFT JOIN user u ON u.id = a.author_id\nWHERE a.id = 147\n```\n\n此时作者名字信息已经有了,来源于 user 表的 nick_name。\n\n感觉好像已经完成了一大半,实际上可能五分之一的工作量还没做完。在我之前的 UI 设计上,是在详情页加入了分类和标签的,虽然现在去掉了这部分,但是通常来说,后端还是要能返回这些信息。\n\n我们试着通过左连接把分类信息加进来,考虑到文章和分类多对多的场景,我们换一篇关联了多个分类的文章试试。\n\n```\nSELECT a.*, u.nick_name, a_c.category_id, c.category_name\nFROM article a\nLEFT JOIN user u ON u.id = a.author_id\nLEFT JOIN article_category a_c ON a_c.article_id = a.id\nLEFT JOIN category c ON c.id = a_c.category_id\nWHERE a.id = 255\n```\n\n这里是先连接关系表,再去连接分类表。\n\n我们发现结果有两行,这很正常,因为这篇文章关联了两个分类嘛。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/two_rows.jpg)\n\n但是我们肯定只想让结果以一行的形式出来,因为我们是查询一篇文章的详情嘛,所以我们做个分组再聚合,这用到了 GROUP BY 和 GROUP_CONCAT。\n\n```\nSELECT a.*, u.nick_name, GROUP_CONCAT(a_c.category_id) as categoryIds, GROUP_CONCAT(c.category_name) as categoryNames\nFROM article a\nLEFT JOIN user u ON u.id = a.author_id\nLEFT JOIN article_category a_c ON a_c.article_id = a.id\nLEFT JOIN category c ON c.id = a_c.category_id\nWHERE a.id = 255\nGROUP BY a.id\n```\n\n这大概拿到了我们期望的效果。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/group_by_results.jpg)\n\n同理,我们把文章所属的标签信息也关联进来。\n\n做了这些之后,文章详情数据就显得比较饱满了,基本能满足一个博客文章详情的展示了。\n\n## 上一篇下一篇是怎么得到的?\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%96%87%E7%AB%A0%E8%AF%A6%E6%83%85%E9%A1%B5%E5%AE%9E%E7%8E%B0/prev_next_big.jpg)\n\n那么上一篇和下一篇这种邻接关系是怎么查询出来的呢?\n\n我们知道,相邻的文章,id 是有联系性的,以 id=10 为例的一篇文章来说,通常它的上一篇文章 id 应该是 9,下一篇文章 id 应该是 11。还有些情况要考虑:\n\n1. 上一篇或者下一篇不存在的情况,因为 id=10 也可能是第一篇文章,也可能是最后一篇文章。\n2. id 并不连续,可能 id=9 的文章已经被删除了。id=11 同理。\n\n所以以 id=10 为例,我们的策略是:\n\n1. 找出比 10 小的 id,同时按 id 降序排列,用 LIMIT 1 限制只取出一条,这一条就是对应着上一篇文章的 id\n2. 找出比 10 大的 id,同时按 id 升序排列,用 LIMIT 1 限制只取出一条,这一条就是对应着下一篇文章的 id\n\n有了 id,查询出文章标题之类的信息还不是手到擒来?相信难不倒大家了,更深一步了解不妨 clone 源码瞧瞧!\n\n## 小结\n\n本文主要分享了博客文章创作和渲染这部分内容,由于本系列文章主要受众是前端开发,剖析前端的实现细节也显得不合适(你们肯定比我厉害),因此本文重在分析整体功能实现的思路以及数据库层面的设计,希望对大家有用,早日进阶!\n\n有问题可以私我。\n\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-05-21 17:14:28', '2024-09-04 06:29:19', 1, 29, 0, 'markdown 还是富文本,sql 语句怎么写?', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8F%92%E7%94%BB/mobile_content.png', 0, 0);
-INSERT INTO `article` VALUES (259, '评论系统的全栈设计思路,学会自己也能快速上手搭建', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n> \n> 前端走向全栈,从这个项目开始准没错!\n\n## 前言\n\n[上篇文章](https://juejin.cn/post/7363454217191325733)讲完了文章详情页的整体实现思路,但是唯独没有讲到评论的实现,因为我认为评论这个功能的实现用几百到一千的文字根本讲不清楚,必须要单独抽离出来,而且文章评论和留言板又有很多相通之处,或者说本质上是一样的!\n\n## 实现方案\n\n评论系统可以直接集成第三方的,在还没纯自研博客之前,我也搭建过基于 hexo 的博客站点,当时在评论系统的选型上也尝试过几种,其中大概可以按照是否需要身份认证来分类。\n\n不需要进行身份认证的典型有 Valine,非常适合无后端的纯静态博客。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/valine.jpg)\n\n只需要填写上昵称、邮箱、网址和你的评论内容,就可以进行评论了,隐私性保护非常好,用户很容易接受这种方式!\n\n细心的读者可能发现了,咱们明明没留下隐私信息,为什么评论区会出现头像?\n\n这是因为 Valine 集成了 Gravatar,只要用户填写的邮箱和 Gravatar 账号的邮箱信息对应上,在评论区就能展示出该用户在 Gravatar 上设置的头像。(当然前提是用户注册过 Gravatar)\n\n需要进行身份认证的第三方评论系统有 Gitment、livere、畅言等,集成了这些评论系统后,用户在发表评论前都需要先进行第三方登录或授权,评论数据会存储到第三方平台中,像 Gitment 类的评论系统是基于 github issue 实现的评论。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/gitment.jpg)\n\n这类要授权的评论系统往往会因为网络或隐私性等问题劝退用户。\n\n评论系统也可以纯自研实现,这通常需要耗费一些精力和时间,但是这给予了你足够大的自由度,你可以 DIY 出自己想要的任何效果!本开源博客项目也是采用的自研方式实现评论系统,我们一起来看看!\n\n## 评论的要素分析\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/comment_area.jpg)\n\n我截取了一篇文章的评论区作为案例,可以看到,评论涉及到人和内容两大块,其中人的信息包括昵称、头像、链接(通常是社交主页),内容信息包括评论内容、评论的关联信息(评论的是哪一篇文章,该评论是否是针对某条评论的回复)。\n\n针对人的信息,首先要考虑是否需要用户进行登录认证。\n\n如果需要登录认证,我们就得开发用户模块的相关功能,用户登录后可以进行评论,也可以查看自己的历史评论数据。\n\n如果不需要登录认证,我们可以参考 Valine 的实现,让用户留下一些昵称、邮箱、网址之类的非强制绑定的信息,就能进行评论。用户留下的不是真实的邮箱也没关系,因为我们不太在意这些信息,更多的是在意评论内容,如果这个用户 ta 真的想和你互动,想必也会留下真实的邮箱。\n\n我是比较倾向于选择第二种实现方式的,用户比较容易接受!\n\n## 用户信息\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/comment_user_info.jpg)\n\n由于我们不需要在服务端保存和验证用户信息,直接将用户信息存储在前端即可,可以考虑存储在 localStorage 中,方便用户第二次评论时自动带上。\n\n同时给用户提供修改个人信息的能力。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/user_info_edit_entry.jpg)\n\n## 评论的数据模型\n\n评论内容部分我们怎么去设计呢?先观察评论的成品效果,我们知道,评论是一个瀑布流,本质上是数组,一个评论会有下级的回复,回复也是一个数组,回复可以是直接回复评论,也可以是对某条回复的回复。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/comment_list.jpg)\n\n那么在表的设计上,我是将评论和回复看做两个实体,通过单独的表 comment 和 reply 去维护。\n\n评论除了基本的内容、状态等字段外,还会有一个`article_id`用于外键关联文章,以便我们在查询时,能查询某篇文章下的评论数据。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/comment_table_design.jpg)\n\n而回复,它是依附于评论存在的,所以除了基本信息外,还会通过一个外键`comment_id`关联到评论。\n\n正如前文所言,回复可以是直接对评论进行回复,也可以是针对某条回复的回复,这需要我们设计一个`parent_id`来找到其关联的回复。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/reply_table_design.jpg)\n\n## 撸码实现\n\n实体的关系建立了之后,业务实现就会更加清晰,我们一起来看看!\n\n> 以文章 id=234 为例说明\n\n我们先从最简单的开始,先查询一篇文章所有的评论数据。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/select_all_comment.jpg)\n\n```\nSELECT * FROM comments WHERE article_id = 234\n```\n\n就是这么简单粗暴!但是一篇文章的评论可能会很多,所以这里也要用到分页技术。\n\n基于前面文章实战中的一些积累,分页对我们来说已经不是很难了,我们照猫画虎借鉴一下之前的实现。\n\n我在语句中加了`approved = 1`和`deleted = 0`,approved 是审核状态,我们的评论还是要经过审核的,不能随便评论就展示出来。\n\n在删除业务中,通常会分为物理删除和逻辑删除,`deleted`就是一个逻辑删除标志位了,这是一个很常见的做法,防止数据记录被删除。\n\n```\nSELECT SQL_CALC_FOUND_ROWS * FROM comments\nWHERE article_id = 234 AND approved = 1 AND deleted = 0\nORDER BY create_time DESC\nLIMIT 0, 2;\nSELECT FOUND_ROWS() AS total;\n```\n\n第一条语句返回分页数据:\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/select_page_data.jpg)\n\n总共执行了两条语句,第二条语句返回了这篇文章下的评论总数:\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/total.jpg)\n\n此时我们已经有了评论数据,但是评论下的回复,我们还没拿到。\n\n针对评论下的回复,我们可以循环评论,再基于外键去查询关联这条评论的回复。\n\n回复这里要注意的是:回复是由两部分组成的,一部分是评论下的一级回复,另一部分是评论下针对回复进行的回复。我们分开来查询这两部分数据,然后做一个联合。\n\n首先查询评论下的一级回复,一级回复有个特点,`parent_id`是空值 null,我们用条件外键查询来简单实现一下。\n\n```\nSELECT * FROM reply WHERE comment_id = 178 AND approved = 1 AND parent_id IS NULL\nORDER BY create_time ASC\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/first_level_reply.jpg)\n\n另外一部分的实现可以参考评论,循环一级回复查询它的子级回复。\n\n另一种方法是直接用连接查询写出来。reply 表连接 reply 表,条件是 parent_id 等于另一张表的 id。\n\n```\nSELECT a.*, b.nick_name AS reply_name FROM reply a\nLEFT JOIN reply b\nON a.parent_id = b.id\nWHERE a.comment_id = 178 AND a.parent_id IS NOT NULL AND a.approved = 1\nORDER BY create_time ASC\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/nested_reply.jpg)\n\n接着,我们把以上两种情况用 UNION 联合起来。\n\n```\nSELECT *, NULL as reply_name FROM reply WHERE comment_id = 178 AND approved = 1 AND parent_id IS NULL\nUNION\nSELECT a.*, b.nick_name AS reply_name FROM reply a\nLEFT JOIN reply b\nON a.parent_id = b.id\nWHERE a.comment_id = 178 AND a.parent_id IS NOT NULL AND a.approved = 1\nORDER BY create_time ASC\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/union_reply.jpg)\n\n以上就是 id=178 的评论下的所有回复数据,我们只要按照数组的形式进行展示即可(实际上其中的部分回复是有上下级关系的)。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/replys_in_comment.jpg)\n\n## 留言板的实现\n\n了解了文章评论的实现后,实现留言板就比较简单了。\n\n既然评论数据中 article_id 对应文章 id 代表着这篇文章下的评论数据,那我偷个懒,article_id 为 null 就代表是留言板的评论,这不就直接实现了一个留言板功能吗?简直是太机智了!\n\n```\nSELECT SQL_CALC_FOUND_ROWS * FROM comments\nWHERE article_id IS NULL AND approved = 1 AND deleted = 0\nORDER BY create_time DESC\nLIMIT 0, 10;\nSELECT FOUND_ROWS() AS total;\n```\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/select_message_board_data.jpg)\n\n## 思路扩展\n\n假设评论这个功能不仅仅在文章和留言板中出现,也会出现在其他业务中,应该怎么优化我们的设计呢?\n\n目前,我们在 comments 表中通过`article_id`来区分文章评论或者是留言板评论,`article_id`有值代表是文章评论,空值 null 代表是留言板评论。这使得评论这个功能只能用于这两个场景,无法扩展。\n\n为了扩展到更多的场景,我们可以舍弃掉`article_id`这种字段,新增`biz_type`和`biz_id`字段,具体用法:\n\n- 当 biz_type = \"article\" 时,comments 记录为文章评论,biz_id 就是文章的 id。\n- 当 biz_type = \"board\" 时,comments 记录为留言板评论,biz_id 可以不填。\n- 扩展举例:当 biz_type = \"community\" 时,comments 记录为社区评论,biz_id 可以是社区帖子的 id。\n\n## 小结\n\n本文主要分享了我在设计评论功能时的一些思路和实现过程,不仅仅介绍了文章评论和留言板评论,还进行了一点思维拓展,希望对大家有帮助!\n\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-05-25 19:34:59', '2024-09-03 01:53:54', 1, 51, 0, '上篇文章讲完了文章详情页的整体实现思路,但是唯独没有讲到评论的实现,因为我认为评论这个功能的实现用几百到一千的文字根本讲不清楚,必须要单独抽离出来,而且文章评论和留言板又有很多相通之处...', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E6%8F%92%E7%94%BB/comment.png', 0, 0);
-INSERT INTO `article` VALUES (260, '别背八股文了,WebSocket 是什么,我劝你花几分钟让面试官惊艳!', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n>\n> 前端走向全栈,从这个项目开始准没错!\n\n## 前言\n\n作为前端工程师,我们几乎每天都在使用 ajax / fetch 请求与后端进行数据交互,这种基于**请求-响应**的通讯模式,我们再熟练不过了,无论是C端产品或者是B端产品,都离不开这种通讯模式。但是像即时通讯IM类场景,通常不会选择这种“你来我回”的通信模式,而是会选择 WebSocket 这类的全双工通信模式。\n\n本文会带您全方位去了解一下 WebSocket 的本质,方便您搞清楚“Connection: Upgrade 是什么意思,为什么是它?”、“Upgrade: WebSocket 又是什么意思?这就可以双向通信了?”、“WebSocket 和 HTTP/TCP 到底有什么关联?八股文背了还是不理解”之类的问题,帮助您无论面试或工作时被问到 WebSocket 都能有更多细节可以聊,妥妥的一个加分项!\n\n最后通过一个在线聊天室实战案例带大家熟悉下 WebSocket 的全栈使用,可点击[在线聊天室](https://blog.wbjiang.cn/chat)进行体验。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/chat.jpg)\n\n## 认识 WebSocket\n\nWebSocket 是一种网络通信协议,它建立在 HTTP 之上,提供了在单个 TCP 连接上进行全双工通信的能力。这意味着服务器和客户端可以互相发送和接收消息,而不需要每次都重新建立连接。WebSocket 最初由 HTML5 规范定义,具体可以参考[WebSockets Living Standard](https://websockets.spec.whatwg.org/)。而现在,WebSocket 已被广泛支持并应用于各种应用,包括实时聊天、多人在线游戏、股票交易系统等需要实时数据更新的场景。\n\n## WebSocket 的兼容性如何?\n\n作为前端开发,对 API 的兼容性还是非常敏感的,我们先来看看 WebSocket 的兼容性怎么样。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/compat.jpg)\n\n可以看到,主流浏览器都支持了 WebSocket,IE10 及以上版本也对 WebSocket 提供了完备的支持,所以我们可以大胆地使用起来!\n\n## WebSocket 的前端用法\n\n浏览器 JS 运行时提供了 [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) 这个 API,可以用来创建和管理 WebSocket 连接。\n\n使用也非常简单,构造实例后只有几个简单的方法调用,一看就会。\n\n```\n// 创建一个新的 WebSocket 连接\nconst socket = new WebSocket(\'ws://your-websocket-server-url\')\n\n// 监听连接打开\nsocket.onopen = (e) => {\n console.log(\'WebSocket is connected.\')\n // 连接打开后,你可以发送消息\n socket.send(\'Hello Server!\')\n}\n\n// 监听消息\nsocket.onmessage = (e) => { console.log(\'Message from server: \', e.data) }\n\n// 监听关闭\nsocket.onclose = (e) => { console.log(\'Connection closed.\') }\n\n// 监听错误\nsocket.onerror = (err) => { console.error(\'WebSocket Error: \', err) }\n\n// 如果你想主动关闭连接,可以调用 close 方法\n// socket.close()\n```\n\n> wss:// 是 ws:// 的 TLS 加持版,可以类比于 https:// 和 http://\n\n## Nodejs 原生支持 WebSocket 吗?\n\nWebSocket 前端的使用非常简单,我自然会联想到:如果我做全栈开发,用 Nodejs 实现 WebSocket 服务端,有原生的模块可以支持吗?\n\n经过查询了解到,Node 原生模块中并未直接支持 WebSocket 服务端的开箱使用,一个比较流行的库是 [ws](https://www.npmjs.com/package/ws)。\n\n那么 ws 这个库是怎么实现 WebSocket 服务端的呢?怎么才能和浏览器的 WebSocket 实现对接上?\n\n直接读源码肯定是看不懂的,即便看懂了一些过程调用,也是懵逼的,我们往下看。\n\n## WebSocket 协议概览\n\n我们知道,通讯是基于协议的,WebSocket 也有它的专属协议。ws 的实现它也是要遵循这个协议,才能和客户端实现匹配上,完成通讯。\n\n这个协议我们去哪里看呢?根据 wikipedia 的介绍,我们知道,WebSocket 的标准化是基于[IETF 的 RFC 6455 WebSocket Protocol](https://datatracker.ietf.org/doc/html/rfc6455)。大致浏览后,我圈出了协议里一些值得关注的内容。阅读这类协议时,我们可以先挑重点看,对协议有一个基本的认识即可。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/protocol1.jpg)\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/protocol2.jpg)\n\n我们了解到一些关键词:\n\n1. 连接握手\n2. 怎么建立连接\n3. 数据发送和接收,数据帧\n4. 关闭握手\n5. 插件,相关 HTTP 头部字段\n\n虽然有了一些关键词在脑海中,但我们对整个通讯过程肯定还有一连串疑问。带着疑问,我们继续往下看协议的具体内容。\n\n1. 我们会了解到 WebSocket 出现的背景,它是为了解决什么,很显然,普通的 HTTP 请求不适合一些双向通信场景,比如聊天、股票、游戏等。\n2. 即便普通 HTTP 请求能通过一些业务设计满足双向通信需求,性能问题也很大,TCP 连接的开销等问题都要考虑在内。\n3. WebSocket 就是希望在一个 TCP 连接上,开辟双工通道,实现全双工实时通信。\n4. 之所以选择在 HTTP 协议的基础上去实现 WebSocket,也是一种权衡和取舍,可能会牺牲一些性能,但是也极大地复用了已有的网络基础设施,包括协议、安全、代理、认证等。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/reuse_tcp.jpg)\n\n## WebSocket 协议 - 连接握手\n\n再往下翻一翻协议,我们能翻到最关键的部分,这也是面试里能和面试官吹的内容,请仔细看!\n\n很多人面试被问到 WebSocket,就说 WebSocket 可以双向通信,这是和 HTTP 最大的不同。讲道理,这种回复面试官已经听腻了。\n\n如果你能告诉面试官,WebSocket 的协议涉及到以下几个 HTTP 头部字段,并简述一下各个字段的简单含义,我相信你的面试绝对加分!\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/websocket_http_headers.jpg)\n\n我们先看请求头:\n\n- WebSocket 请求一定是 GET 类型的。\n- Origin: 浏览器客户端会带上,包含 Origin 是为了安全考虑,充分利用浏览器的同源策略。\n- Connection: Upgrade 和 Upgrade: websocket。这是客户端告知服务端需要升级协议,并且升级的协议为 websocket。\n- Sec-WebSocket-Key:由客户端(比如浏览器)随机生成,16位随机数经过 base64 编码后得到。响应头 Sec-WebSocket-Accept 是与它搭配使用的,用来确保请求的有效性和安全性。\n- Sec-WebSocket-Protocol:不是必选的。用来约定应用层面的子协议,使得客户端和服务器能够灵活地协商并选择一个双方都能理解的协议来进行通信。如果服务端选择使用某个子协议通信,则会在响应头中返回。\n\n再看响应头:\n- 服务端返回 101 Switching Protocols,代表握手成功,协议切换到 WebSocket。\n- Connection: Upgrade 和 Upgrade: websocket,用于告知客户端可以升级为 websocket 协议。\n- Sec-WebSocket-Accept:基于 Sec-WebSocket-Key 处理得到,处理公式如下,其中`\"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\"`是一个固定的字符串:\n\n```\nbase64-encode(sha1(Sec-WebSocket-Key + \"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\"))\n```\n\n引用 wikipedia 的一张图,可以看得更清楚!\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/websocket_headers_table.jpg)\n\n## WebSocket 协议 - 数据帧\n\n建立了连接握手后,WebSocket 就可以发送和接受消息数据了,消息是由一个或多个帧组成的。我们先看看这两张图了解一下数据帧的结构。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/data_frame.jpg)\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/data_frame_table.jpg)\n\n其中 Opcode 是操作码,具体见下表。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/opcodes.jpg)\n\n由于存在数据比较大的可能,这时需要切片传输,WebSocket 消息数据支持分片传输。\n\n- 当 FIN = 1 且 OpCode ≠ 0 时,代表这一帧数据不是分片处理的。\n- 当 FIN = 0 时,如果 OpCode = 0,代表这一帧数据是某个分片的中间数据帧;如果 OpCode ≠ 0,代表这一帧数据是一个分片数据中的起始帧。\n\nWebSocket 能发送文本数据或二进制数据,这个是体现在 OpCode 上。如果起始帧的 OpCode 是 1,则代表是文本数据;如果起始帧的 OpCode 是 2,则代表是二进制数据。\n\n## WebSocket 协议 - 关闭连接\n\n我们看看规范中,关闭握手这部分是怎么说的。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/closing_handshake.jpg)\n\nWebSocket 任何一端都可以发起关闭连接。\n\n当一方准备关闭连接时,应该发送 Close Frame 开始关闭握手,之后不应该再发送任何数据;\n\n另一方收到 Close Frame 时,需要回复 Close Frame,并且准备释放资源,同时也应该丢弃后续从这个连接上可能接收到的数据。\n\n发起方收到 Close Frame 控制帧后,关闭连接释放资源,不再接收数据。\n\n这种 WebSocket 关闭握手机制也是在 TCP 握手机制上的一种补充,更好地保证端到端通信的可靠性!\n\n有的朋友可能会考虑到这个问题:当客户端发送 Close Frame 后,服务端正常接收到,并且回复 Close Frame,但是由于网络问题客户端没有服务端响应的 Close Frame,这种情况是怎么关闭 WebSocket 连接的?\n\n实际上,TCP 连接也有它的超时和重试机制,当一段时间内没有数据传输时,也会断开连接。所以我们无需担心这一点。\n\n当这种没有成功关闭握手但是关闭了 TCP 连接的情况发生时,`onclose`事件回调中收到的错误码应该是 1006,这一点可以在上面的表格中找到。\n\n> 正常关闭是 1000。\n>\n> 在实际业务实现上,还会通过 ping-pong 之类的心跳检测机制来保证可靠性。\n\n## 回顾 ws 关键源码\n\n有了这些知识储备后,再来看 ws 的实现源码,可能就会有头绪一点。\n\n当你看到这部分,你会知道它在校验头部字段是否符合协议要求,准备升级协议...\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/handleupgrade.jpg)\n\n当你看到这个 **101** 状态码,你会恍然大悟:“哦,原来是在这里完成了协议的升级!”虽然还有些细节看不懂,但是无伤大雅!\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/completeupgrade.jpg)\n\n当你看到这里时,你会知道,如果客户端尝试通过普通的 HTTP 请求来连接 WebSocket 服务,服务端应该返回 426 Upgrade Required 告诉客户端,“你该升级协议再跟我对话!”\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/426.jpg)\n\n本文不是源码解读,点到为止,我们往下看。\n\n## 为什么选择 Socket.IO \n\n实际将 WebSocket 运用到生产环境时,我们一般不会直接使用 ws 这种协议实现库,而是会选择在应用层面进行了一些封装的库,比如 [Socket.IO](https://socket.io/)。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio.jpg)\n\n这是因为在 WebSocket 实际使用过程中,还有很多问题要考虑,比如心跳检测、优雅降级、房间隔离、命名空间隔离、API 的易用性等。而这些,Socket.IO 已经开箱支持。\n\n准确说,**Socket.IO 并非是一个 WebSocket 实现,而是一个事件驱动的低延迟双向通讯方案**。\n\n它的底层通讯不一定是基于 WebSocket 的,可能会根据情况选择 HTTP 长轮询、[WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API)。\n\n> WebTransport 是一个基于 HTTP/3 的通讯技术,可实现可靠通信和不可靠通信。HTTP/3 底层基于 Google 的 QUIC 协议,而 QUIC 协议是基于 UDP 的。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_overview.jpg)\n\nSocket.IO 有它的约定和规则,或者叫协议,只要遵循这个协议,就能完成客户端和服务端的实现,所以你会看到,它也有多语言的实现,甚至在客户端还有小程序的实现。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_implements.jpg)\n\n这个协议其实也就对应着[Socket.IO 的底层引擎 Engine.IO](https://socket.io/docs/v4/how-it-works/#engineio)。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/engineio.jpg)\n\n虽然现在大部分浏览器都支持了 WebSocket,但是也不排除某些远古项目的存在,它必须运行在“古董”浏览器版本之上。Socket.IO 考虑到了这一点,它的自动优雅降级完美解决了这一问题。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/features.jpg)\n\nSocket.IO 的心跳检测机制和自动重连也是实际业务中必不可少的!\n\n更多的特性还有:\n\n- 对话回调\n- 广播\n- 房间\n- 命名空间多路复用\n- ...\n\n## Socket.IO 的通讯过程\n\n当我们打开一个 Socket.IO 的客户端页面时,会发现 Network 里发出了多个请求,在 101 websocket 连接建立之前,有 4 个 xhr 请求,其中还有一个是 POST 请求。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_requests.jpg)\n\nSocket.IO 在升级机制中解释了这一点,直接建立可靠可用的 WebSocket 连接并非一件很轻松的事情,通常从 HTTP 开始平滑升级到 WebSocket,对连接的可靠性和用户体验来说是更好的。\n\n升级协议会经历这么一些步骤,对应着我们在上面看到的几个 Network 请求。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_requests_explain.jpg)\n\n> 我这个项目开始得很早,所以EIO=3,代表协议版本号是3,目前 Engine.IO 已经升级到版本4了。\n\n在 Socket.IO 的 HTTP 长轮询模式中,使用长时间运行的 GET 请求接收数据,使用短期运行的 POST 请求发送数据。\n\n了解了这些机制,并且查看 API 用法后,就可以开始运用了,一些高级用法可以在使用过程中再去探索!\n\n## 聊天室的全栈实现\n\n基于以上理解,我们开始搭建博客项目中的聊天室功能,我们会实现这些主要能力:\n\n- 成功创建 Socket.IO 连接\n- 展示聊天室的系统通知信息(涉及到单播和广播)\n- 聊天对话功能(广播)\n\n我们首先把依赖安装好,客户端使用[socket.io-client](https://www.npmjs.com/package/socket.io-client),服务端使用[socket.io](https://www.npmjs.com/package/socket.io)即可。\n\n### 服务端开启 WebSocket 服务\n\n第一步是把 WebSocket 服务启动。由于本项目开始较早,socket.io 版本是 2.5,大家对照文档的时候按 2.x 文档看就好。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_attach_server.jpg)\n\nsocket.io 可以利用已经存在的 HTTP 服务,由于项目是用 Express 搭建的,我们直接与 Express 共享一个 HTTP 服务即可。\n\nio 实例化后,监听到 connection 事件就代表有客户端过来了,可以开始干活了。我这里是把聊天室相关的逻辑都放在了 chatroom 这个命名空间下。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/chatroom_handle.jpg)\n\n> of 的作用就是初始化并使用命名空间。\n\n### 客户端服务端建立连接\n\n第二步是建立连接。首先引入依赖。\n\n```javascript\nimport io from \"socket.io-client\";\n```\n\n再进行实例化,得到一个 socket 实例。\n\n```javascript\nthis.socket = io(process.env.VUE_APP_SOCKET_SERVER + \"/chatroom\");\n```\n\n有了这个 socket,我们就能监听各种事件了。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_client_on.jpg)\n\n### 聊天室的系统通知信息\n\n当一个客户端连接上服务器时,服务器会发送一条消息,“hello,欢迎您加入在线聊天室!”这是通过单播实现的,只要拿着`socket`对象,调用其`emit`方法就行。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/server_greet.jpg)\n\n除了对这个客户端打招呼外,还需要告诉其他用户,有新人加入了。这是通过`socket.broadcast`广播实现的。\n\n```\nsocket.broadcast.emit(\'broadcast\', param);\n```\n\n当有人退出聊天室,会触发 disconnect 事件,此时我们可以广播通知其他人。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/server_broadcast_exit.jpg)\n\n这就是我们在前端页面看到的效果:\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/exit_ui.jpg)\n\n### 聊天对话功能\n\n由于我们做的是聊天室,相当于一个群聊,就不涉及到单播聊天了,直接用广播就行。\n\n用户在客户端发聊天消息时,是用到`socket`对象的`emit`进行发送。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/socketio_client_emit_chat.jpg)\n\n这个`chat`事件是在客户端连接上服务端时开始监听的,在这个回调里,我们需要把内容广播给除发送者之外的其他用户,子事件名是`new_chat_content`。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/server_transfer_chat.jpg)\n\n而其他用户则会通过客户端监听广播事件中的`new_chat_content`子事件拿到聊天数据,最终呈现到界面上。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/other_clients_listen.jpg)\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/websocket%E8%81%8A%E5%A4%A9%E5%AE%A4/chat2.jpg)\n\n以上都是通讯上的设计,了解了这个机制,UI 的展示就非常简单了,毕竟 UI = Render(data),不做更多介绍!\n\n## 小结\n\n本文中,我首先分享了我对 WebSocket 协议的一些理解,希望对还不太理解这块的朋友起到一点帮助作用。面试里,WebSocket 是一个常问的考点,如果你回答的仅仅是“全双工通信”,可能并不能起到一个很好的效果,把文中小知识甩面试官脸上吧,哈哈哈!\n\n最终通过一个实际案例,带大家理解一个聊天室功能的设计思路,在实际落地的过程中夯实对 WebSocket 协议的理解。\n\n码字分享不易,多多点赞关注,项目给个 star,多谢啦,宝子!\n\n- 开源地址:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-06-04 14:06:43', '2024-10-05 11:07:11', 1, 70, 0, '面试官提问WebSocket,你还在简单地回复“全双工通信”吗?本文会带您全方位去了解一下 WebSocket 的本质,看懂协议,并通过一个聊天室实战案例稳扎稳打。', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E9%A6%96%E5%9B%BE/message.jpg', 0, 0);
-INSERT INTO `article` VALUES (261, '前端轻松拿捏!最简全栈登录认证和权限设计!', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n> \n> 前端走向全栈,从这个项目开始准没错!\n\n## 前言\n\n你或许作为前端的课代表,开发了很多高级的登录功能,比如扫码登录、手机号一键登录、第三方授权登录、生物认证登录等,对 OAuth、JWT、双 Token 等关键词也能脱口而出,但是你可能还**从未以一个全栈的身份完整地开发过一个登录功能**。没关系,本文的目的就是解决你的这份忧虑,从一个极简的全栈登录认证功能入手,打破这个枷锁!\n\n前几篇文章提到过,在博客系统中,我们需要后台管理功能去维护我们的文章、留言评论等内容。既然是这样,后台管理的安全问题也得考虑,登录和权限设计是非常有必要的。\n\n## 登录认证\n\n常规的认证方式就是**基于 Session 会话的认证**,用户的身份信息是与 Web Session 绑定在一起的,这是至今依然流行的认证方案,经得起时间的考验!我们来梳理一下。\n\n首先,**HTTP 是无状态的**,HTTP 协议本身并不保存客户端与服务器之间交互的状态信息。每当客户端向服务器发出请求时,服务器都会独立处理这个请求,不会考虑上下文关系。\n\n而基于 HTTP 的 Web 应用,通常有着身份认证类需求,通过身份信息去识别用户,这其实是有状态的。\n\nCookie 的设计就是为了解决 HTTP 无状态的问题,通过头部字段 Cookie 和 Set-Cookie 来携带一些状态信息,这些状态信息与服务端的 Session 结合在一起,就可以让 HTTP 请求变成有状态的请求。\n\n基于此,我们可以完成登录认证等功能。\n\n以本项目为例,使用的数据库是 MySQL,user 表中设计了 token 这个字段用于用户会话身份的识别。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/user_table_token_field.jpg)\n\n由于 Session 有唯一性,可以**唯一标识一个客户端会话**,Session 中有唯一的 Session ID,这个 Session ID 可以与数据库 user 表的 token 字段结合起来使用,如果二者是匹配的,就找到了对应的用户。\n\n因此,我们只要在用户登录认证的时候,把这个 Session ID 写入到 token 字段中,后续鉴权时就可以依据 Session ID 查询用户表。\n\n> 当然也不一定非得将 Session ID 作为 token,只要是能和 Session 联系起来的唯一值,理论上都可以作为 token。\n\n## 权限\n\n再来说权限,我们知道,为了实现一个产品功能,背后可能会设计很多个后端接口,前端会调用这些接口,进行逻辑和数据处理,最终呈现出产品界面效果。\n\n但是,有的功能并不希望开放给普通用户使用,比如发布文章、审核评论等,这应该是博主才能使用的功能。\n\n这就需要权限的控制,**权限的控制会涉及到前端和后端**。\n\n比如发布文章这个功能,如果你仅仅是在前端拦截了用户进入发布文章界面,也并非是安全的,因为有意要攻击系统的人会尝试直接调接口攻击后端,甚至是入侵数据库。\n\n既然可以直接攻击后端和数据库,那么前端的防护还有意义吗?有的,防不了小人,先防君子。除此之外,逻辑上理应如此,该拦截的还是得拦截,用户体验也会好一点。\n\n举个例子,假设一个普通用户通过一个链接就进入了你的后台管理系统,虽然接口没调成功,但是是不是也很尬?用户会觉得你的前端开发很水。\n\n具体怎么做呢?我们来看具体实现。\n\n## 登录的实现\n\n先看登录是如何实现的。由于博客系统暂时不对外开放注册,只需要一个管理员账号即可,直接插入到用户表中即可,不需要增加业务控制器来维护。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/insert_admin_account.jpg)\n\n我们使用传统的账号密码登录方式,用户名对应 user 表中的 user_name 字段,密码对应 password 字段,是经过了 SHA256 HASH 得到,保证密码不容易被逆运算破解。\n\n为了提高安全性,我们还需要引入一个图片验证码的功能。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/login_ui.jpg)\n\n图片验证码的基本原理是:\n\n1. 当用户请求验证码时,后端在指定的字符序列中生成一串随机字符,并依据其内容生成一张验证码图片,图片中包含生成的随机字符。\n2. 将随机字符串保存在 Session 中,并把验证码图片返回给前端展示。\n3. 前端只能拿到验证码图片,并不知道验证码的值,需要通过输入去校验,所以一定程度上是安全的。\n4. 用户观察验证码,手动输入内容,最后将账号、密码、验证码一起发送到后端进行认证。\n5. 后端将客户端发送过来的验证码和 Session 中的验证码进行对比,一致后才验证账密的有效性。\n\n```mermaid\ngraph TD\n A[\"用户请求验证码\"] --> B[\"后端生成随机字符\"]\n B --> C[\"生成验证码图片\"]\n C --> D[\"保存随机字符串到Session\"]\n D --> E[\"返回验证码图片给前端\"]\n E --> F[\"前端展示验证码图片\"]\n F --> G[\"用户观察并输入验证码\"]\n G --> H[\"前端发送账号、密码、验证码\"]\n H --> I[\"后端验证验证码\"]\n I --> J[\"验证码一致?\"]\n J --是--> K[\"验证账密有效性\"]\n J --否--> L[\"验证码错误,重试\"]\n K --> M[\"认证成功/失败\"]\n L --> F\n```\n\n那么验证账号密码的流程是什么样的呢?\n\n1. 首先通过账号密码查询数据库 user 表中是否有对应的记录。\n2. 如果没有记录,则有两种可能,一种是用户不存在,另一种是账号或密码输错了。但是为了不给用户留下猜测的空间,错误提示语都用统一的“用户名或密码输入有误”。\n3. 如果有记录,说明账号密码正确,此时更新这条 user 记录,用 Session ID 更新 token 字段。\n4. 将脱敏的用户信息返回给前端,同时 Set-Cookie 更新 cookie 中的 token 信息。\n\n```javascript\nres.cookie(\n \'token\',\n req.session.id,\n {\n expires: expireTime,\n httpOnly: true,\n sameSite: \'lax\',\n secure: true\n }\n);\n```\n\n```mermaid\ngraph TB\n A[\"用户输入账号密码\"]\n A --> B{查询user表记录}\n B --无记录--> C[\"返回:用户名或密码输入有误\"]\n B --有记录--> D[\"账号密码正确\"]\n D --> E[\"更新user记录token字段为Session ID\"]\n E --> F[\"返回脱敏用户信息\"]\n F --> G[\"Set-Cookie更新cookie中的token信息\"]\n G --> H[\"流程结束\"]\n```\n\n## 权限设计的具体实现\n\n我们知道,有的接口是需要鉴权的,只有管理员能访问,而有的接口是开放的,人人都能访问,这就需要权限的设计。\n\n我们的博客系统中暂时还只有一个管理员账号,按这个设定,我们可以认为:如果 Cookie 中的 token 校验通过,就认为这个用户是管理员账号,可以访问需要鉴权的接口。\n\n但是这并非一个优雅的设计,RBAC(Role-based access control)是一种更容易扩展的方式。我们还是预留了用户/角色/权限的关系。\n\n通过用户的 role_id,我们能关联查询到这个用户的角色,基于角色,再去找到这个用户的权限。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/roleid.jpg)\n\n由于目前,博客系统中用到的权限管理只涉及到接口权限,这里我就没有过度设计了,直接把接口权限和角色的关系写死在后端代码里。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/authmap.jpg)\n\n> PS: 这里用 Map 还是用什么数据结构不是很重要,能对应上角色和权限的关系更重要...\n\n## 访问链路分析\n\n知道角色和权限的关系后,我们再来完整看看**不需要鉴权的接口**和**需要鉴权的接口**走过的链路分别是什么样的?\n\n我们来到后端代码的 base.js 控制器,这是验证权限的入口。一个接口是否要验证权限,暂时是维护在前面提到的 authMap 中,如果以 req.path(也就是接口请求路径)作为 key 能在 authMap 中找到值,说明这是一个需要验证权限的接口。\n\n如果在 authMap 中找不到对应的值,说明这个接口是不需要鉴权的,base 控制器直接放行请求即可。\n\n如果一个接口需要验证权限,会首先取出 Cookie 中的 token,查数据库 user 表。\n\n如果没找到记录(也就是下图中的 results.length === 0 这个条件),可以考虑返回前端“授权已过期”或者“未授权”之类的信息。\n\n如果 token 是有效的,还需要验证 user 信息中的 role_name 是否和该接口限制的角色一致。\n\n如果不一致,说明该用户不具备这个接口的访问权限,返回前端“抱歉,您没有权限访问该内容”这样的信息即可。\n\n如果一致,说明该用户有这个接口的访问权限,把执行权交给后续中间件即可。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/auth_code.jpg)\n\n博客后台管理类接口只有管理员能访问,这些是需要鉴权的。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/backend_ui.jpg)\n\n首页的文章分页数据是所有访问者都能查看的,这就是一个典型的不需要鉴权的接口。\n\n![image.png](https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E5%8D%9A%E5%AE%A2%E7%B3%BB%E5%88%97/%E6%9E%81%E7%AE%80%E9%89%B4%E6%9D%83/home_ui.jpg)\n\n## 小结\n\n博客系统中,对于游客而言,可以查看文章、留言等开放性的数据;对于管理员而言,还需要管理后台功能来维护文章、审核评论等,这就需要一个登录认证的能力。在 Web 项目中,常用的认证方案就是基于 Cookie + Session 的认证。有了登录认证后,其实还应该根据角色去区分用户的权限,虽然本系统中只有一个管理员存在,但是我们还是预留了 RBAC(Role-based access control) 的能力。\n\n码字分享不易,多多点赞关注,项目给个 star,多谢啦,宝子!\n\n- 开源地址:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-06-17 10:43:21', '2024-11-02 19:32:29', 1, 66, 0, '你或许作为前端课代表,开发了很多高级的登录功能,比如扫码登录、手机号一键登录、第三方授权登录、生物认证登录等,对 OAuth、JWT、双 Token 等关键词也能脱口而出,但是可能从未以全栈的身份...', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/%E9%A6%96%E5%9B%BE/auth.jpg', 0, 0);
-INSERT INTO `article` VALUES (262, '你每天都在用element, antd,但你知道组件库要对外提供什么吗?', '本文是[基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045)专栏第 11 篇,坚持就是胜利!\n\n最近突然悟到:自己写文章太执着于在一篇文章中把一个事情从头到尾写清楚,这样就导致虽然我把事情讲完了,但是对读者来说不是很友好,因为大家很难有耐心看完几千字甚至更多文字,这样对你对我来说都不好,我得不到反馈,你看不到重点。\n\n所以,接下来我打算改掉这个坏习惯,尽量能把一个问题拆解得简单化,通过一个总分(总)的结构分篇叙述问题,这样的写作过程我个人感觉也会更加轻松。\n\n“写组件并不难,难的是打包!”这句话应该大部分人都深有体会,这里的打包并不仅仅是字面上的意思,而是**从组件编写完毕到交付到用户手中能正常使用**的所有工程化内容,这也是本专栏的核心之一。\n\n这部分内容一篇文章说不清,所以咱们拆着慢慢看。\n\n# 组件库要交付什么?\n\n对于大部分前端来说,我们每天都在与组件库打交道,但你有没有思考过一个问题,假设让你做一个组件库,你要对外提供些什么?\n\n我这里列举了一些关键内容:\n\n* 符合模块规范的组件模块,这个是核心,必须有。\n* 组件样式,组件一般都包含样式,这个也基本上有。\n* 类型声明,如果要提供 TS 支持,这个也少不了,除非你不需要支持 TS。\n* README,不算特别关键,愿意给就给,一般可能用官方文档网站的形式替代了。\n* scripts,有的组件库可能会提供一些脚本,用于做一些自动化的辅助工作,这个按需提供,一般没有。\n\n其中符合模块规范的组件模块,说白了就是一堆 JS,一堆符合 ESM/CJS/UMD 等模块规范的 JS,通常是每个组件有一组 JS 模块,整个库还有入口 JS。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3bb88b5d535249f8abf57c7ae5273ad2~tplv-k3u1fbpfcp-watermark.image)\n\n样式要交付哪些内容呢?仅仅是提供 .css 文件吗?其实不然,考虑到调用方对工程能力的定制诉求,可能还会交付 .less/.scss/.stylus 等 CSS 预处理文件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/6b5f78e670f74a81bc4121f3b391e912~tplv-k3u1fbpfcp-watermark.image)\n\n类型声明就是把 d.ts 这种文件交付给用户,这样人家才能用得爽,快速上手,配合 IDE 的类型支持,能马上知道这个组件支持哪些属性。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2145ae4dda5c48ae9d60e44cb242820d~tplv-k3u1fbpfcp-watermark.image)\n\n# 怎么交付?\n\n在前面一些文章,我已经介绍了怎么构建函数库,比如[在发布组件库之前,你需要先掌握构建和发布函数库](https://juejin.cn/post/7171792173984612366)这篇文章,其实就是为了做铺垫,因为做函数库比较单纯,主要就是处理 TS 和 JS。\n\n而 UI 组件就复杂一点,除了 JS/TS,还涉及样式或者 DSL 的处理,是一个更复杂的工程。\n\n以 Vue 为例,我们组件源码写的是 .vue,或者是 .jsx/.tsx。但是最终要交付出 js, css, d.ts 等格式的文件,这要怎么做呢?真让人头大,我们下篇文章接着讲。\n\n> 技术交流&闲聊:[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg)', '2024-06-18 19:03:49', '2024-10-14 15:34:38', 1, 39, 0, '对于大部分前端来说,我们每天都在与组件库打交道,但你有没有思考过一个问题,假设让你做一个组件库,你要对外提供些什么?', 'https://qncdn.wbjiang.cn/%E5%8D%9A%E5%AE%A2%E7%B4%A0%E6%9D%90/77c6cd69fb6143d5bf910630558705a9~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (263, '.vue 怎么变成 .js,我们来试一试!看完会更懂 Vue 吗?', '本文是[基于Vite+AntDesignVue打造业务组件库](https://juejin.cn/column/7140103979697963045 \"https://juejin.cn/column/7140103979697963045\")专栏第 12 篇,坚持就是胜利!\n\n接着上篇说,交付一个 vue 组件不仅需要解析 DSL,还要处理 JS/TS,样式,类型声明等内容。我们先研究一下将 .vue 转换成 .js 的问题,这个是关键。\n\n# 谁能处理 .vue 文件?\n\n要想把 .vue 变成 .js,首先要知道什么工具能处理 .vue 文件,是不是很容易想到我们熟悉的 Webpack 和 Vite?\n\n但是 Webpack 它本身也不能处理 .vue 文件,背后起作用的是 [vue-loader](https://vue-loader.vuejs.org/)。\n\n同样地,Vite 也不是天生支持 vue,它能处理 .vue 文件是因为有了 [@vitejs/plugin-vue](https://github.com/vitejs/vite-plugin-vue/tree/main/packages/plugin-vue) 和 [@vitejs/plugin-vue2](https://github.com/vitejs/vite-plugin-vue2) 这两个插件,前者提供 Vue3 支持,后者提供 Vue2 支持。\n\nvue-loader, @vitejs/plugin-vue, @vitejs/plugin-vue2 三者都依赖了一个核心模块 [vue/compiler-sfc](https://github.com/vuejs/core/tree/main/packages/compiler-sfc)。\n\n这里要注意 vue/compiler-sfc 和 @vue/compiler-sfc,前者相当于是一个调用入口和版本锁的作用,保证 vue 和 compiler-sfc 的版本一致性;后者是真正的代码实现。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/560d0871eb5e4e8f862fc60d7c8c94d5~tplv-k3u1fbpfcp-watermark.image)\n\n@vue/compiler-sfc 才是真正具备处理 .vue 文件能力的源头,毕竟这种底层能力还是得看官方。@vue/compiler-sfc 具备两大能力,一个是 parse,一个是 compile。\n\n# .vue 被 parse 成了什么?\n\n由于本文的焦点不是源码分析,所以也没必要逐行去分析 @vue/compiler-sfc 的源码,只要把握好 parse 的主干流程即可。\n\n我们把 Vue 的源码下载下来。\n\n git clone https://github.com/vuejs/core.git\n\n然后运行 pnpm install 把依赖装好,保证一些依赖代码,TS 类型能看得更清楚。\n\n我们打开 compiler-sfc 的 parse.ts 源码瞧一瞧,这里有一个很显眼的`parse`方法。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/3323438144754df1bb7e1a94f321ebc1~tplv-k3u1fbpfcp-watermark.image)\n\n了解一个函数的作用,最直接的就是看它的输入和输出,我们可以直接观察入参类型和返回值类型,做到窥一斑而知全豹。\n\n`parse`的入参有两个,一个是 string 类型的`source`,它代表 .vue 文件的源码字符串;第二个参数类型是`SFCParseOptions`,是一些条件参数,先不用关注。\n\n`parse`函数的返回值类型是`SFCParseResult`,它下面有一个属性`descriptor`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c139f41131c34580a6125bd6646d8933~tplv-k3u1fbpfcp-watermark.image)\n\n`descriptor` 描述了 `parse` 的结果,比如 .vue 的 template 部分(也就是模板部分)被 parse 成`SFCTemplateBlock`了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ba6756154819492294f45b9e004bf3f2~tplv-k3u1fbpfcp-watermark.image)\n\n`SFCTemplateBlock`继承了`SFCBlock`,并且有`ast`属性,`ast`就是抽象语法树,可以用结构化的数据描述模板的结构。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/96111013f7e9436095d71f87b4c81898~tplv-k3u1fbpfcp-watermark.image)\n\n.vue 的 script 部分分为两块来看,一个是不带`setup`属性的常规`script`,一个是带`setup`属性的 `scriptSetup`,`script` 和 `scriptSetup` 的 AST 结构是不一样的。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/0272aa487a044414abf7862de29e0734~tplv-k3u1fbpfcp-watermark.image)\n\n.vue 的 style 部分则是被 parse 成一个数组`styles`,它的类型是`SFCStyleBlock[]`。为什么 style 的 parse 结果会是一个数组呢?这是因为我们可以在 .vue 文件中写多个 style 块。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a5b38863a25344b8a5a12c4956ced9c1~tplv-k3u1fbpfcp-watermark.image)\n\n`SFCStyleBlock`包含了两个标记类的属性`scoped`, `module`,分别代表有没有启用 scoped 和 css module 特性。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/8df1e6958b7f424ea86d7c1c8f552850~tplv-k3u1fbpfcp-watermark.image)\n\nparse 过程还会得到`cssVars`,它通过分析样式中的`v-bind()`得到。\n\n`slotted`代表你有没有使用 scoped 样式块,并且在这个 scoped 样式块中用了 slot 样式穿透语法`:slotted()`。\n\n了解了大致结构后,我们可以动手测试一下,加深印象。\n\n我们准备一个简单的 test.vue 作为测试的源文件。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c0eea08fa4164587be754c40757450d7~tplv-k3u1fbpfcp-watermark.image)\n\n然后新建一个 test-compiler-sfc.js 用于编写测试逻辑,主要就是读取 test.vue 文件内容,然后调用`parse`函数得到解析结果。可以断点调试,浏览一下`parseResult`到底是什么样的数据,包含了哪些内容。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/29860b1ccf474126923f244b08f235b9~tplv-k3u1fbpfcp-watermark.image)\n\n# compiler 主要做了什么?\n\n@vue/compiler-sfc 中有几个以 compile 开头的函数,分别是`compileScirpt`, `compileTemplate`, `compileStyle`,我们挑一些重点看一看。\n\n## compileScript\n\n`compileScript`负责编译脚本,它的第一个参数是`descriptor`,这是因为 Vue3 支持同时写 script 块和 setupScript 块,编译时需要把两个 block 整合,而 parse 得到的`descriptor`中可以拿到这些信息。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/84d2789af6e34ac0940f4e9a76a1501b~tplv-k3u1fbpfcp-watermark.image)\n\n由于 script 不仅支持`lang=\"js\"`,还支持 ts, jsx, tsx,所以`compileScript`还会根据情况引入相关 babel 插件。\n\n考虑到 Vue3 支持 v-bind in CSS 特性,编译脚本时还要考虑注入`useCssVars`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/dab3581ae59d40c18924e20304711565~tplv-k3u1fbpfcp-watermark.image)\n\n如果用到了 setup 语法糖,则会有更复杂的分析过程,因为 setup 语法糖带来了一些编译时特性(比如编译宏 defineProps 等),这里不再深入研究。\n\n## compileTemplate\n\n`compileTemplate`负责编译模板,先看看它的参数类型和返回值类型。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/597f4be8544f4246a892d871612886fe~tplv-k3u1fbpfcp-watermark.image)\n\n`scoped`是依据 styles 中有没有 scoped 属性来决定的,用于给标签加`data-v-xxx`属性。\n\n`slotted`来源于`parse`过程中得到的`slotted`,这个是跟插槽有关的。\n\n`compileTemplate`通过`preprocessLang`支持模板预处理,基于此可以支持类似 pug 之类的模板语法。\n\ncompileTemplate.ts 代码量不多,其编译的核心工作是交给了 @vue/compiler-dom 和 @vue/compiler-core 处理(这里暂时不讨论 ssr)。\n\n沿着调用链路找,可以观察到整体过程是一个比较标准的 parse -> transform -> generate 三部曲。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/442cf9e3a6944ac38bee5943c4129817~tplv-k3u1fbpfcp-watermark.image)\n\ntemplate 最终是被编译为渲染函数了,所以其实也是 js,应该不会感到意外吧?\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/50b435ab31464d01b22fc12446d1b4a5~tplv-k3u1fbpfcp-watermark.image)\n\n## compileStyle\n\n`compileStyle`是编译样式的,同样地,我们先看看它的参数类型和返回值类型。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/01e261a59fd3408e9318b849bdd0fcf9~tplv-k3u1fbpfcp-watermark.image)\n\n我们注意到`SFCStyleCompileOptions`中有一个 id,它是组件的一个 uuid,可以用[hash-sum](https://www.npmjs.com/package/hash-sum)生成,具体生成时与一些路径和文件名信息有关。这个 id 也是我们在观察 DOM 结构时看到的 data-v-xxxxx 的源头。\n\n![1677031374057.jpg](https://qncdn.wbjiang.cn/博客素材/7a0d0566d2cd4a5cbdea8088a2ccb5f6~tplv-k3u1fbpfcp-watermark.image)\n\n了解了以上关键信息后,我们可以在 test-compiler-sfc.js 中补充一些简单的调用。\n\n```javascript\nexport async function test() {\n const fileName = \"test.vue\"\n\n const filePath = resolve(__dirname, fileName)\n\n const fileContent = await readFileSync(filePath)\n\n const { descriptor } = parse(fileContent.toString(), { filename: fileName })\n\n const rawShortFilePath = relative(process.cwd(), filePath)\n .replace(/^(\\.\\.[\\/\\\\])+/, \'\')\n const shortFilePath = rawShortFilePath.replace(/\\\\/g, \'/\')\n\n const id = hash(shortFilePath)\n\n descriptor.id = id;\n\n const styles = descriptor.styles.map(block => compileStyleBlock(block, descriptor))\n\n console.log(styles)\n\n const script = compileScript(descriptor, { id })\n\n console.log(script)\n\n const hasScoped = descriptor.styles.some((s) => s.scoped);\n\n const template = compileTemplate({ source: descriptor.template.content, filename: descriptor.filename, id, scoped: hasScoped, slotted: descriptor.slotted })\n\n console.log(template)\n}\n```\n\n也可以打断点调试一下过程。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/bc36e1962fdc45c8931ea91416821e51~tplv-k3u1fbpfcp-watermark.image)\n\n有了以上信息,相信你**大概**知道 .vue 源码是怎么变成 .js 文件了,只要合理地将一些编译结果组合起来,缝缝补补,最后加一个`fs.writeFile`写入文件的操作,这应该难不倒你吧?\n\n涉及的源码可在[vue-pro-components c10 分支](https://github.com/cumt-robin/vue-pro-components/tree/c10)找到,欢迎 star 支持!\n\n# 小结\n\n我们大致了解了 @vue/compiler-sfc 做的事情,知道了是 @vue/compiler-sfc 提供了核心的 API,但是把这些 API 真正用到生产实践中还要考虑很多细节,需要合理地串起流程,同时考虑一些特殊场景。这些在 vue-loader 和 Vite 的插件 @vitejs/plugin-vue, @vitejs/plugin-vue2 中也有体现,感兴趣的朋友可以再去研究相关代码,这里不再展开。\n\n我们做组件库,不到万不得已肯定是不会去开发底层工具链的,所以只要知道怎么用这些工具即可。\n\n> 技术交流&闲聊:[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg)', '2024-06-18 19:07:19', '2024-10-14 15:34:40', 1, 38, 0, '交付一个 vue 组件不仅需要解析 DSL,还要处理 JS/TS,样式,类型声明等内容。我们先研究一下将 .vue 转换成 .js 的问题,这个是关键。', 'https://qncdn.wbjiang.cn/博客素材/77c6cd69fb6143d5bf910630558705a9~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (264, '前端上手全栈自动化部署,让你看起来像个“高手”', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n## 前言\n\n作为前端开发,你或许开发过很多酷酷的界面效果,做过很多复杂的产品需求,但是可能对服务器生产部署还不是很清楚。没关系,本文会分享一个开源博客系统是如何将前后端自动化部署到生产环境的,如果你正好缺乏这项技能,相信本文一定会给你带来巨大的价值,系好安全带,准备发车吧!\n\n## 旧事重提\n\n关于自动化部署,我曾有过很多探索和实践,也在公司内部搭建了支撑所有前端项目的 CI/CD 体系,如今也全部升级接入到了 K8S 架构中。\n\n很早之前,我就有写博客介绍过如何写自动化脚本,以及如何使用 gitlab 之类平台支持的 CI/CD 能力。\n\n- [自动化部署的一小步,前端搬砖的一大步](https://juejin.cn/post/6844904049582538760)\n- [前端自动化部署的深度实践](https://juejin.cn/post/6844904056498946055)\n- [花半天时间,轻松打造前端CI/CD工作流](https://juejin.cn/post/6944878021560139783)\n\n借着今天这个开源博客项目,我们再来实操一下自动化部署流程。\n\n## 经典的动静分离部署\n\n我们知道,博客是一个网站,用户都是从域名(本博客是 blog.wbjiang.cn)进来访问的,对静态资源和接口数据的访问从这个域名经过,通常会使用 Nginx 进行规则转发。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/0ad0151d-6c62-401b-9b0d-67c561470c78)\n\n首先看前端部分,不管是 Vue 还是 React 项目,打包后的资源默认都会到 dist 目录下。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/246529a3-562a-42a1-bf8f-bfb1cb353568)\n\n我们只要把这部分文件放在服务器目录上,再通过 nginx 配置访问即可。\n\n```\nserver {\n listen 80 default_server;\n server_name _;\n return 403;\n}\n\nserver {\n listen 80;\n server_name blog.wbjiang.cn;\n rewrite ^(.*)$ https://$server_name$1 permanent;\n}\n\n# https 配置\nserver {\n listen 443 ssl http2;\n server_name blog.wbjiang.cn;\n root /usr/share/nginx/html/vue3-ts-blog-frontend;\n # 省略其他配置...\n}\n```\n\n以上配置的效果是:当用户访问 blog.wbjiang.cn 的时候,如果是通过 http 协议访问的,自动 301 重定向到 https 访问;而 https 访问会去找 nginx root 配置对应的目录下的文件。\n\n当我们访问网站时,能看到每一个前端静态资源文件的请求和响应。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/fbaddd41-a2a0-4bf9-b4f5-a85de04fe8ad)\n\n那么后端接口是怎么访问到的呢?我们可以观察到,接口请求都有一个前缀 /api。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/90c3c7eb-e9fc-46cf-b2d8-19feb180e2bb)\n\n这是一种约定。由于我们的前后端访问都是从一个域名接入的,必须要约定一种访问规则。比如,/api 前缀的请求,就是服务请求,针对这类请求,我们给它转发到 nodejs 服务,其他的请求默认为前端请求,即访问 root 配置的目录即可。\n\n这种约定对应着 Nginx 的规则,是通过 location 来配置的。\n\n```\n#api转发\nlocation /api {\n proxy_pass http://127.0.0.1:8002;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n rewrite ^/api/(.*)$ /$1 break;\n}\n```\n\n以上配置的含义就是:如果一个请求是 /api 开头的,就转发到 8002 端口(8002端口部署着我们的 nodejs 后端服务)。最后还有一行 rewrite 配置,这是因为 express router 并未设置 /api 前缀,通过 rewrite 抹掉 /api 前缀才能被 express router 处理。\n\nnodejs 8002 端口的服务是怎么开启的呢,其实也很简单,与我们在本地启动 express 服务并无太多差异,同样也是启动 pm2,只不过对应的 NODE_ENV 是 production。\n\n```\npm2 start ecosystem.config.js --env production\n```\n\n> 从同一个域名接入前后端还有一个好处,那就是没有跨域问题,这也是反向代理的一个常见应用场景。\n\n以上是服务器部署的最终形态,包括前端和后端部分。但是我们还需要实现自动化部署,这样才能解放我们的生产力,同时也能降低人工部署出事故的可能性。\n\n提及自动化部署时,往往会提及脚本、事件驱动等概念。\n\n我们期望做到的效果是:当一个功能开发完了,或者一个bug修复完了,提交代码后就能自动完成部署工作,不需要人力登录服务器进行手工部署。\n\n写脚本是不可或缺的,写好部署脚本后,我们可以执行脚本,通过脚本完成部署工作,也就是所谓的一键部署。不过这依然需要人来执行脚本,完全的自动化部署可以基于一些 CI/CD 工具来完成。\n\n## github actions 自动化部署 Vue 前端\n\n由于是前后端分离项目,整个部署我们要分两部分来看。我们的项目是在 github 上开源的,可以选择利用 github 的 actions 来实现 CI/CD。\n\nCI/CD 一般都是通过一个 yaml 配置文件来驱动的,各个平台的配置项大同小异,我们只需要抓住一些关键概念:env, secrets, workflow, stage, job, step, run 等,它们可能在不同 CI/CD 平台中的叫法不一样,但是本质上是一样的,这些概念可以在各个 CI/CD 平台的官方文档中查阅。\n\n- [github actions 文档](https://docs.github.com/en/actions)\n- [gitlab CICD 文档](https://docs.gitlab.com/ee/ci/)\n\n也可以先看看下面这篇文章了解下,对 CI/CD 有个基本认识。\n\n- [花半天时间,轻松打造前端CI/CD工作流](https://juejin.cn/post/6944878021560139783)。\n\n在完成配置一个 CI/CD 之前,我们要问问自己,“整个流程是什么样的?”\n\n- 什么时候触发 CI/CD ?\n- 可以手动触发 CI/CD 吗?\n- CI/CD 中要做哪些事?\n\n针对第一个问题,我们可以根据自己的需要进行选择,比如 push 代码时就触发 CI/CD,或者打 tag 时触发 CI/CD,或者是发布 release 时触发 CI/CD。这些都是 git 操作中一些关键事件,所以我称之为“事件驱动”。\n\n当我们希望 push 代码到 main 分支时直接触发 CI/CD,就可以这样配置:\n\n```yaml\non:\n push:\n branches:\n - main\n```\n\n针对第二个问题,github actions 是支持手动触发 CI/CD 的,对应的配置是`workflow_dispatch`。\n\n```yaml\n# Controls when the action will run. Workflow runs when manually triggered using the UI or API.\non:\n workflow_dispatch:\n # Inputs the workflow accepts.\n inputs:\n name:\n # Friendly description to be shown in the UI instead of \'name\'\n description: \'Remarks\'\n # Default value if no value is explicitly provided\n default: \'trigger a release\'\n # Input has to be provided for the workflow to run\n required: true\n```\n\n第三个问题也是最关键的,CI/CD 中我们要执行哪些任务?简单列举一下。\n\n- 检出代码:保证当前工作目录是最新的代码。\n- nodejs 版本指定:保证 nodejs 版本是所期望的。\n- 安装依赖:对应着 npm install 或者 yarn install。\n- 项目打包:使用 build 脚本进行打包。\n- 发送到服务器:将打包好的前端静态资源发送到服务器 nginx 资源目录下。 \n\n其中有些任务可以直接引用社区的 actions,比如检出代码、设置 nodejs 版本、上传/下载构建产物等,具体可以前往[ci_cd.yaml源码](https://github.com/cumt-robin/vue3-ts-blog-frontend/blob/main/.github/workflows/ci_cd.yml)查看。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/47d0d5b0-d381-44e7-a733-96c1fb240a99)\n\n有了这些配置,当代码被 push 到远程 main 分支时,就会触发 CI/CD。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/fd2ed2f5-b191-42fc-9451-32c634f22023)\n\n## pm2 自动化部署后端服务\n\n对于 nodejs 后端服务,我暂时没有将部署过程迁移到 github actions 上,而是直接用 pm2 提供的 deploy 命令实现。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/a6c4eea9-07c7-4507-91a6-0ca2c520abbc)\n\npm2 deploy 命令执行过程中需要一些配置项,这些配置项可以通过 ecosystem.config.js 文件中的 deploy 配置维护。\n\n由于 deploy 配置可能会涉及到一些私密信息,比如你的服务器 ip 和用户名,服务器部署路径等等,这些信息最好不要公开在开源项目中,所以我将 deploy 配置单独抽取了一个文件来维护,也就是 deploy.config.js,并且提供了配置示例。\n\n![image.png](https://github.com/cumt-robin/blogs/assets/30487257/7e5530e8-3c6a-458f-a98b-8c780f0e1765)\n\n此时,只要我们执行 pm2 deploy production 命令,就会开始 nodejs express 服务的部署。\n\n当然,pm2 deploy 也可以放在 github actions 中执行,相关私密信息可以用 secrets 替代。\n\n## 小结\n\n作为一个前端开发,掌握全栈项目自动化部署会对你有极大帮助,你会站在一个“伪架构师”的视角去看待系统全局,知晓一个 Web 项目运行的本质、前后端在服务器上是如何工作和协调的。当你学会使用 CI/CD 后,你会对 git, ssh 等相关知识有更多的理解。\n\n- 开源地址:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-06-18 21:54:37', '2024-08-10 23:06:40', 1, 29, 0, '作为前端开发,你或许开发过很多酷酷的界面效果,做过很多复杂的产品需求,但可能对服务器生产部署还不是很清楚。没关系,本文会分享一个开源博客系统是如何将前后端自动化部署到生产环境的。', 'https://qncdn.wbjiang.cn/博客素材/abf95944c5be4117a47867a1a9a0ff15~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (265, '小程序博客搭建分享,纯微信小程序原生实现', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 小程序源码:[blog-weapp](https://github.com/cumt-robin/blog-weapp)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n## 前言\n\n我是在 2019 年启动这个全栈博客项目的,当时自己的前端技术其实也不怎么样,但是我脑子里就一个想法,必须掌握一点点全栈技术,了解整个系统全栈是怎么运行起来的。于是,我就决定自己动手做一个全栈的博客,当时也还从没在公司做过小程序项目,就想着也做个小程序版的博客练练手。\n\n我学习一个新东西不太喜欢一来就上框架,我会先开始学一点最原始的基础,有了基础后,再换框架做,这样效率高,同时遇到框架问题也不至于由于没有了解过底层基础而无从下手去解决。由于当时我对小程序技术处于一个一无所知的阶段,我决定先用微信小程序原生语法做一遍。\n\n所以可能大家看到的小程序源码水平不是很高,不过没关系,我自认为用它来入门小程序还是没多大问题的。\n\n## 小程序包含的内容\n\n小程序基本是仿照 PC 端的内容去做的,主要还是由首页的文章瀑布流、文章详情、分类、留言板、我的等几个页面组成。\n\n目前微信对未认证的个人小程序限制了被搜索能力,而认证是需要收保护费的,确实挺符合他们家做事风格的。\n\n我的博客小程序被限制搜索能力了,大家可以扫这个小程序码浏览下。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e176eb476bd1449297d52dae54075969~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n> 内心OS:访问量不大,懒得交保护费\n\n下面就挑几个我认为比较重要的点展开聊聊。\n\n## 怎么运行项目?\n\n要了解项目,首先要给它运行起来。\n\n我们 clone 代码后,先照常 npm install 安装依赖。\n\n接着我们打开微信开发者工具,导入项目后,先构建 npm,这是微信小程序中用到 npm 必须执行的一个步骤,这一步执行后会生成一个 miniprogram_npm 目录。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/29dd19c863d94b8a9da9f3d3749db5b1~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n此时需要刷新编译一下,接着就能看到界面效果了。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/88f913866146439486b8799fa279c74f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n## 自定义 tabbar\n\n我们知道,移动端中页面通常分为 tabbar 页面和非 tabbar 页面。tabbar 页面通常用来承载核心页面,切换 tabbar 页面不是以页面栈的形式交互,来回切换不会销毁 tabbar 页面。而非 tabbar 页面是通过页面栈来维护的,页面返回时会销毁当前页面。\n\n当你的微信小程序需要 tabbar 页面时,可以通过 app.json 的 tabBar 配置项管理。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/65e08550da094b76b1556e33f177ad53~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\ntabBar 配置对样式的支持度很低,很难达到完全自定义的效果。因此也衍生出了自定义 tabBar 的需求。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/efe90b27e6564a1bb25aaac79942a6a3~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n微信小程序官方在基础库 2.5.0 以上版本支持了[自定义 tabBar](https://developers.weixin.qq.com/miniprogram/dev/framework/ability/custom-tabbar.html)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e2b6e2a65d404f17854edb8ea10979b9~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n可能是由于时间错开了,当时没有用到自定义 tabBar,转而是用组件的形式承载了几个核心页面。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f4dd3f6bf5c742c6acbc239aff0027af~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n也就是说,目前看到的我的博客小程序其实并不是用 tabBar 页面来组织首页、分类、留言、我的等几个页面,这几个页面是 index 页面中的几个组件。\n\n当然,我不是很推荐用这种方式实现自定义底部 tabBar 样式,虽然看起来视觉效果也一样,但是肯定不如官方推荐的\ncustom-tab-bar 组件。\n\n## 授权开放信息\n\n博客小程序需要拿到用户的开放信息,主要是头像和昵称,用于评论留言功能。\n\n微信对用户信息这块卡得一直很死,从最初可以结合 open-type=\"getUserInfo\" 和 wx.getUserInfo API 做到只需要用户授权一次后续就能静默取得用户信息,到后来只能用 open-data 组件展示开放信息,再到现在,昵称和头像都拿不到了,必须引导用户填写,你就说服不服吧。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/df01c5f949f142349a9f4a1a84924ef8~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n本小程序就是采用的 API 调用用户信息,判断用户是否授权过 userInfo,如果没有授权过,通过 open-type=\"getUserInfo\" 的 button 组件引导用户授权;\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/4d64163ffdd44e7cbefa45b96a311f5f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n如果授权过,就可以调用 wx.getUserInfo 直接取用户信息。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/d08ce6c99d694971a307ba1037e8954c~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n而现在,如果你不进行代码调整,通过上述方式拿到的头像就是灰色默认头像了,而昵称则是“微信用户”这种没有什么用的信息。\n\n更牛的是,现在获取手机号授权也要收费了,一个字,绝!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e52740dc977b4e48b851dd29425037f9~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n![0.gif](https://qncdn.wbjiang.cn/博客素材/9be422d4021b4b2592099d45c9583513~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n## 接口封装\n\n看过博客 PC 端前端源码的应该知道,API 接口可以封装到类里面,然后对外输出一个单例。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/b811ea775c124556ab2147ff6eb3c2ce~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n调用实例的方法就相当于调用后端 API。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/74ec4239778d4840b7a9526b0c6bf787~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n那么小程序端能不能这样封装呢?也可以的,不过我换了一种形式,不是用类封装,而是用函数封装。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9654ef5ea50b4c24affb03aede81c944~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n函数有函数的好处,类有类的好处。\n\n函数的好处在于它对构建工具是友好的,如果一个函数是纯函数,虽然它被定义了,但是没有被引用过,那么在编译阶段可以通过 Tree Shaking 去掉它。\n\n而 Class 实例是一个对象,是一个整体,不能摇树去掉其中的某个方法。但是 Class 有面向对象的优点,调用起来很简单,能免去 import 多个函数的繁琐。\n\n大家可以两种方式都体验一下,根据实际情况再去选择。\n\n前端封装 API 调用的核心还是通过 Promise 封装平台提供的 Request 能力,封装好了之后,对外暴露 Class 实例或者函数,这样可以做到不管是在 Web、还是小程序、甚至桌面应用、跨端 APP,对于业务代码来说,调用体验一模一样,我在公司内部项目中就做了这样的事情。\n\n不同平台提供的 Request 能力不一样,\n\n- Web 端底层是 XMLHttpRequest 或者 fetch,接着可能被 axios 之类的库又封装了一遍,那么我们的封装就是基于 axios 去包装即可。\n- 小程序,以微信小程序为例,它提供的 Request API 是 wx.request,直接对它进行封装即可,不必使用第三方库再进行封装。\n- uniapp,提供的是 uni.request,封装它就行。\n\n封装的范式是什么样呢?简单给个例子:\n\n```\nconst request = () => {\n return new Promise((resolve, reject) => {\n 底层request库请求()\n 如果 success, resolve()\n 如果 fail, reject()\n })\n}\n```\n\n当然,如果用到 axios 这样的库,我们通常在其拦截器中处理。\n\n复杂的封装还会在其中考虑并发,鉴权,业务错误码判定,无感刷新 token,甚至微前端通讯等等。\n\n不过这些对业务调用方来说都是无感知的,内核多复杂都没关系,上层调用要简单化!\n\n## markdown 渲染\n\n我们的文章内容是用 markdown 存储的,在小程序上的文章详情页中应该怎么渲染出来呢?\n\n小程序没有 innerHTML 能力,这意味着 Web 生态的 markdown 引擎在小程序端都不奏效了。小程序要实现 markdown 渲染,应该怎么做呢?用 rich-text 组件行不行呢?理论上可以,但是 rich-text 组件内会屏蔽所有事件,这意味着如果你希望很难与其中节点进行交互。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/dd24ae48972f4fb6a26d4b0d437c06a6~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n如果你希望支持更多的交互能力,就需要将 markdown 解析成结构化数据(本质上是节点树的结构),再遍历这个结构化数据,将其渲染成微信小程序的各种组件(比如 view, text, image, video 等等)。有几个库可以参考,虽然都不算完美,但是提供了一种实现思路。\n\n- [towxml](https://github.com/sbfkcel/towxml)\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/a47bf3730947411e91c0f69b911258b9~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n- [wemark](https://github.com/TooBug/wemark)\n\n## CI 的实现\n\n小程序发布版本流程比较繁琐,其中还需要在微信开发者工具中手动上传代码到微信公众平台。\n\n那么有没有办法把这个过程自动化呢?\n\n嗯,可以的,至少通过脚本上传代码到微信公众平台是可以实现的,官方提供了 [miniprogram-ci](https://www.npmjs.com/package/miniprogram-ci) 来做这件事情。\n\n使用前需要使用小程序管理员身份访问\"[微信公众平台](https://mp.weixin.qq.com/)-开发-开发设置\"后下载代码上传密钥,并配置 IP 白名单,才能进行上传、预览操作。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/78821ef5f10643379b41cfd1df1a3c52~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n具体使用方式可以参考博客源码[ci.js](https://github.com/cumt-robin/blog-weapp/blob/master/scripts/ci.js)。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/7f92acbec1744a01b8aa06cd27d3b9c7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n你完全可以考虑把这个脚本集成到 CI/CD 流程中。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/def6223f6d1743079e3a29ea123c9ddf~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n上传完代码后,还有提交版本审核环节,这一步是不在 miniprogram-ci 支持范围内的。\n\n如果确实有这方面需要,可以考虑用 Puppeteer 或者抓包分析实现,这里就不做延申了。\n\n## 个人感受\n\n写小程序代码和写 html, css, js 三件套本质上没多大区别,各家小程序的使用语法其实跟 Vue Options API 风格也挺像的。小程序最大的不同是:它是双线程的,渲染层和逻辑层是分开的,渲染层基于定制化的 WebView,逻辑层也是一个沙箱环境,很多 API (DOM/BOM相关)都被屏蔽了,由于小程序是双线程模型,这使得很多 API 操作都是异步的,比如你要获取一个元素节点的属性状态,这就是异步的,需要线程间通信。理解了这些,上手小程序开发就不是很困难。\n\n- 开源地址:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-06-20 19:00:12', '2024-10-24 08:00:13', 1, 42, 0, '我是在 2019 年启动这个全栈博客项目的,当时自己的前端技术其实也不怎么样,但是我脑子里就一个想法,必须掌握一点点全栈技术,了解整个系统全栈是怎么运行起来的。于是,我就决定自己动手做一个全栈的...', 'https://qncdn.wbjiang.cn/博客素材/76b0f53d64f44067bcc750bfd914e05b~tplv-k3u1fbpfcp-jj-mark:480:480:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (266, '前端不懂 Docker ?先用它换掉常规的 Vue 项目部署方式', '> 本项目代码已开源,具体见:\n>\n> 前端工程:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n>\n> 后端工程:[express-blog-backend](https://github.com/cumt-robin/express-blog-backend)\n>\n> 数据库初始化脚本:关注公众号[程序员白彬](https://qncdn.wbjiang.cn/%E5%85%AC%E4%BC%97%E5%8F%B7/qrcode_new.jpg),回复关键字“博客数据库脚本”,即可获取。\n\n## 为什么需要容器化\n\n当一个项目越来越复杂时,或者部署的环境越来越复杂时,你可能会考虑使用容器化部署来交付项目。因为你必须要考虑这样一些问题:\n\n- 为什么这个项目在我本地是正常的,部署到生产环境就不正常了?\n- 生产服务器要部署两个 nodejs 项目,但是这两个项目依赖的 nodejs 版本不一样,怎么办?\n- 买了一台新的云服务器,把项目从旧服务器迁移过去就跑不起来了......\n- 服务器部署了两个项目,但是其中一个项目误修改了另一个项目的一些关联文件,导致程序崩溃......\n\n这些都是生产实践中会发生的问题,容器化的出现就是为了解决这些问题。\n\n前端开发不懂容器化很正常,因为我们平时工作很难接触到这些。没关系,我们要创造机会让自己学会它,至少要让自己了解它。\n\n## 简单认识 Docker\n\n学习容器化肯定离不开 Docker,我们首先要对 Docker 的基础概念有所认识,这些可以去看[Docker 官方文档](https://docs.docker.com/)学习,我也花了挺多时间去看文档,这是了解第一手信息最好的方式。\n\n> 我一直推崇通过官方文档学习,因为这几乎就是最权威的资料。有了这些信息,再翻到一些文章或博客时,对一些观点也能有自己的判断力。\n\n- Image\n- Container\n- Network\n- Volume\n\nImage 就是镜像,相当于一个类,或者说是一个模板,Image 是通过 Dockerfile 定义和构建的,Dockerfile 描述了制作 Image 的过程。\n\nContainer 是容器,它是基于镜像实例化得到的。容器是天然隔离的,容器包含了运行应用程序所需的所有东西,比如代码、依赖库、环境变量等。\n\nNetwork 是网络,允许容器之间以及容器与宿主机之间进行通信。\n\nVolume 是数据卷,用于挂载文件,解决容器中文件持久化的问题。\n\nDocker 采用经典的CS架构。我们用 Docker Desktop 也好,用命令行也好,都是相当于一个客户端,我们给出的指令,首先会被 Docker daemon 接收,daemon 就是一个监视器进程,相当于一个守门员,所有的指令都要经过它的调度才能被处理,比如镜像操作、容器操作、网络操作、卷操作等等。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ee2be71f07494fcd8d6127cfbd80bcc8~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n## 将 Vue 项目做成 Docker 镜像\n\n我们在[前端上手全栈自动化部署,让你看起来像个“高手”](https://juejin.cn/post/7373488886461431860)这篇文章中提到过,整个项目的部署架构是这样的:\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e176e7dfa0474ad999c244e9d1ffdad1~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n在我们使用容器化改造全栈博客项目时,可以循序渐进,不必整个前后端全部都容器化,可以先把 Vue 前端部分换掉,也不会影响整个系统架构。我们来试着做一下。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/89b107ba435744b4ba9ae6c671044b6a~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n前端项目一般都会用到框架,需要打包后才能交给服务器部署,所以第一步是打包,打包需要一个 NodeJS 环境。下面就是打包相关的 Dockerfile 配置。\n\n```\n# 使用官方Node.js作为基础镜像\nFROM node:16 as builder\n\n# 设置工作目录\nWORKDIR /app\n\n# 首先复制项目的依赖配置文件\nCOPY package.json yarn.lock ./\n\n# 安装项目依赖,这一步会生成一个独立的层,并且只有在package.json或yarn.lock变化时才会重新执行\nRUN yarn install\n\n# 接着复制项目所有文件,这一步会生成一个新的层\nCOPY . .\n\n# 构建项目,这一步也会生成一个新的层\nRUN yarn build\n```\n\n前端打包后就会得到一堆静态文件,包括 html, js, css, 图片、音频、视频等。\n\n那么怎么让用户访问到网站的这些前端资源呢?这就需要一个 Web 服务器。\n\nNginx 就可以充当这个 Web 服务器,最后需要把端口暴露出去,并且安全组要放行该端口,不然是没法通过网络访问的。\n\n```\n# 使用Nginx镜像来运行构建好的项目\nFROM nginx:latest\n\n# 将构建好的项目复制到Nginx镜像的/usr/share/nginx/html目录下\nCOPY --from=builder /app/dist/ /usr/share/nginx/html\n\n# 暴露端口\nEXPOSE 80\n```\n\n整合上面的 Dockerfile 后,我们就可以执行打镜像的命令了。\n\n```\ndocker build -t vue3-ts-blog-frontend .\n```\n\n最后的`.`号代表上下文路径,Docker 会在这个路径下寻找 Dockerfile 及其他文件,根据 Dockerfile 配置打镜像。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/9ff10021564140e48209a5ea22cda1bf~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n打镜像成功后,可以在本地运行一下这个镜像进行验证。\n\n```\ndocker run -dp 3000:80 vue3-ts-blog-frontend\n```\n\n`3000:80`代表把宿主机的 3000 端口转发到容器的 80 端口,`vue3-ts-blog-frontend`则是我们刚才打出的镜像的名字。\n\n接着我们打开`http://localhost:3000`访问,发现前端界面显示出来了,但是接口访问是404。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/f5e829feab6b456fb12eb3ae3b3993b6~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n而且可以看到,接口的访问路径是这样的:\n\n```\nhttp://localhost:3000/article/page?pageNo=1&pageSize=6\n```\n\n按道理应该有`/api`前缀的,因为我们用环境变量配置了 axios 的 baseURL。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5d70fe36124a4211bd3f14878612c6a9~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n我们知道,webpack 在打包阶段会将`process.env.VUE_APP_BASE_API`的值替换成对应的字符串。本地 yarn build 会得到这样的结果。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/c19045a268d34ac488774eac4fd6ba04~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n这说明是镜像构建的时候出了问题,导致`VUE_APP_BASE_API`没生效。我看了一下容器运行时的文件,找到了对应的 js 文件,发现这个对象里压根没有`VUE_APP_BASE_API`。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ebb887c5118d46aa87296c2fcbf7232e~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n最后费了老大劲发现是 docker 默认模板生成的 .dockerignore 文件把我给坑了,它忽略了 .env,这就导致了打包时找不到 VUE_APP_BASE_API 这个环境变量,真是服了!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ae281eb5fd294df48b56718ac113dfdb~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n修改 .dockerignore 之后重新打镜像,运行容器,/api 的访问路径也正常了。虽然 /api 访问得到的结果是 404 Not Found,但是路径已经对了。后端服务是 404 可以先不管,后面用 nginx 转发一下就行。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/27ff9d857ff242a2ba735da27c812261~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n此时我们还要做一点事情,那就是 404 的 fallback 处理,这个需要通过 nginx 的 try_files 配置实现。\n\n如果不做任何处理,随便输入一个不存在的路由会被 nginx 返回 404,这是 nginx 默认的 404 页面。但是我们通常希望如果用户随便输入一个路由,应该给一个友好的 404 界面,这个工作可以由 nginx 承接,我们可以修改 nginx 默认的 404 页面;这个工作也可以由前端来承接,将流量转发到前端即可,再由前端路由到 /404。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/b8ad240a535f4ccfb805cfc80532d108~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n想要让 /not-found-route 这种找不到的路由最终能被前端处理,就需要一个 try_files 配置。\n\n如果要给 nginx 配置 try_files,就需要覆盖 nginx 镜像的 /etc/nginx/conf.d/default.conf 文件,我们给 Dockerfile 新增一条 COPY 指令。\n\n```\n# 复制自定义的Nginx配置到镜像中,覆盖默认配置\nCOPY nginx/default.conf /etc/nginx/conf.d/default.conf\n```\n\n接着我们在项目中新建一个 `nginx/default.conf` 文件,配置内容也很简单。\n\n```\nserver {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n\n location / {\n try_files $uri $uri/ /index.html;\n }\n}\n```\n\n有了 try_files 配置后,无法匹配 nginx 路由的请求就会被转到 index.html,这个 index.html 自然就是 Vue 项目的入口。如果项目中 vue-router 配置了`pathMatch`,就能将未被定义的路由重定向到 /404 路由。\n\n```\n// 匹配任意路径\nconst FALLBACK_ROUTE = {\n name: \"Fallback\",\n path: \"/:pathMatch(.*)*\",\n redirect: \"/404\",\n}\n```\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/185facd7c61d48ce93e1a1132bca8c07~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n此时这个镜像的准备工作就算是完成了,可以重新打包镜像。\n\n```\ndocker build -t vue3-ts-blog-frontend .\n```\n\n此时可能有朋友脑子里还记得接口访问 404 的问题,没关系,这个会在镜像部署到服务器后解决。我们先接着往下看,不着急。\n\n## 私有镜像仓库的使用\n\n镜像打好了之后,总得有个地方把它存起来,然后服务器上才能去拉取这个镜像进行部署,这就是镜像仓库做的事情。\n\n镜像仓库有私有的,也有公开的,对于个人项目,我们通常希望使用私有镜像仓库。但是 DockerHub 官方对于未付费用户进行了限制,对单账号只提供一个私有仓库名额,这显然是不够用的。\n\nDockerHub 上有一个 registry 镜像,就是用来搭建镜像仓库的。后续抽空单独出一篇文章分享一下如何用这个 registry 镜像搭建一个私有 Docker Registry。\n\n既然先不着急搭建私有镜像仓库,我们就寻找一个免费的可靠的私有镜像仓库提供商,aliyun 就提供了这个能力。\n\n我们打开 aliyun 控制台,在容器-容器服务这里有个容器镜像服务 ACR,我们打开它。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/ee7761f3f6eb4a199bbc195c9f2cf684~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n目前,aliyun 对个人用户提供了 300 个仓库的免费额度,这完全是够用的,我们用它来搭建自己的私有镜像仓库完全足够!\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/5e5353d10ea047c8b5364cac4092089b~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n我们按照指引创建命名空间和仓库后,就可以进入仓库查看了,这里提供了登录 registry、推拉镜像的所有命令示例,操作起来非常简单。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/2291b89106f0410f9a26af0f7bc48ba3~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n私有镜像仓库都是要先登录才能使用的,我们先用 docker login 登录 aliyun registry。\n\n```\ndocker login --username=itaobao8023 registry.cn-hangzhou.aliyuncs.com\nPassword: \nLogin Succeeded\n```\n\n然后使用 docker tag 给前端项目打一个 aliyun registry 专属的 tag。\n\n```\ndocker tag vue3-ts-blog-frontend registry.cn-hangzhou.aliyuncs.com/tusi_personal/blog:2.0.4\n```\n\n接着就可以使用 docker push 推送镜像了。\n\n```\ndocker push registry.cn-hangzhou.aliyuncs.com/tusi_personal/blog:2.0.4\n```\n\n## 服务器部署改造\n\n接着我们来到服务器,在服务器上拉取这个镜像。当然在服务器上也要先登录,才能 pull 镜像,我们先执行上面的 docker login 操作。\n\n接着进行 pull 操作拉取镜像。\n\n```\ndocker pull registry.cn-hangzhou.aliyuncs.com/tusi_personal/blog:2.0.4\n```\n\n镜像拉取成功后,通过 docker run 把容器跑起来。\n\n```\ndocker run -dp 3000:80 registry.cn-hangzhou.aliyuncs.com/tusi_personal/blog:2.0.4\n```\n\n容器的 80 端口映射到了宿主机器的 3000 端口,此时可以通过 curl 测试一下可访问性。\n\n```\ncurl localhost:3000\n```\n\n如果能看到下面的结果,说明容器运行没有异常。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/342e0513bf1544a4b5f3eb7223d030c9~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n不过此时仅仅是把 Vue 项目的 nginx 容器跑起来了,整个博客项目的部署架构还没有调整呢。我们再回顾一下这张部署架构图,然后思考一下怎么改造它。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/e176e7dfa0474ad999c244e9d1ffdad1~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n我们知道,整个博客是通过 nginx 接入的,/api 和 /socket.io 前缀的流量会转发到 express backend 服务,其余流量会到 /usr/share/nginx/html/vue3-ts-blog-frontend 这个目录下,也就是访问前端的静态资源文件。我们要改变这个行为,让除 /api 和 /socket.io 之外的流量全部转发到我们的前端容器中,这个容器目前是通过 3000 端口访问的。\n\n> /api 是用来访问后端 API 接口的,/socket.io 开头的请求是 Socket.IO 相关的。这些都不是前端的范畴,所以要转发到后端。\n\n按照这个部署逻辑,我们修改一下 nginx 配置文件。\n\n```\nserver {\n listen 80 default_server;\n server_name _;\n return 403;\n}\n\nserver {\n listen 80;\n server_name blog.wbjiang.cn;\n rewrite ^(.*)$ https://$server_name$1 permanent;\n}\n\n#博客https\nserver {\n listen 443 ssl http2;\n server_name blog.wbjiang.cn;\n ssl_certificate /etc/nginx/cert/blog.wbjiang.cn.pem;\n ssl_certificate_key /etc/nginx/cert/blog.wbjiang.cn.key;\n ssl_session_timeout 5m;\n ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;\n ssl_protocols TLSv1 TLSv1.1 TLSv1.2;\n ssl_prefer_server_ciphers on;\n location / {\n proxy_pass http://localhost:3000;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n #api转发\n location /api {\n proxy_pass http://blog_pool;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n rewrite ^/api/(.*)$ /$1 break;\n }\n #websocket转发\n location /socket.io {\n proxy_pass http://blog_pool;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n }\n}\n```\n\n注意上面的 location / 规则,它就是把默认流量转发到了 localhost:3000,也就是我们用 docker 运行的前端 nginx 容器。这样一来,整个系统的部署架构就变成了下面这样。\n\n![image.png](https://qncdn.wbjiang.cn/博客素材/56e2cf08e3964a9a958de23b372d7131~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image)\n\n最后只需要重启 nginx 服务即可。\n\n```\nnginx -s reload\n```\n\n## 小结\n\n我们希望在本项目中学习 docker 的使用,但是又不希望破坏整个系统的部署架构,因此做了这样的设计,只针对 vue 前端部分采用了 docker 部署方式。通过实践,我们学会了如何使用 docker 制作镜像、运行容器、上传镜像,也学会了怎么使用私有镜像仓库,最终在完全兼容的前提下成功改造了系统的部署方式。不过,我们还仅仅是跑通了部署流程,未曾修改 CI/CD 配置,这个后续再做分享。\n\n- 开源地址:[vue3-ts-blog-frontend](https://github.com/cumt-robin/vue3-ts-blog-frontend)\n- 专栏导航:[Vue3+TS+Node打造个人博客(总览篇)](https://juejin.cn/post/7066966456638013477)', '2024-06-23 19:48:43', '2024-09-18 09:13:13', 1, 33, 0, '为什么需要容器化 当一个项目越来越复杂时,或者部署的环境越来越复杂时,你可能会考虑使用容器化部署来交付项目。因为你必须要考虑这样一些问题: 为什么这个项目在我本地是正常的,部署到生产环境就不正常了? ', 'https://qncdn.wbjiang.cn/博客素材/feaf9d3bda8a460fb1fb647101bbfb1b~tplv-k3u1fbpfcp-jj-mark:720:720:0:0:q75.avis', 0, 0);
-INSERT INTO `article` VALUES (267, '值得关注的 Vue 生态十大项目', '如果你是一个 Vue 的深度爱好者,那么 2023 年由广大开发者用 Github star 选出来的 Vue 生态十大项目你一定不能错过。今年就业环境不是很好,如果你不知道要学习什么,不妨看看这些项目。\n\n2023 JavaScript Rising Stars 中关于 Vue 生态有提到:\n\n![image-20240624150733570](https://qncdn.wbjiang.cn/博客素材/image-20240624150733570.png)\n\n随着 Vue 2 宣布将在 2023 年底停止维护,今年(2023年)被认为是 Vue 及其社区的一个转折点,许多人开始升级到 Vue 3。\n\n为此,社区做出了许多努力来帮助迁移,生态系统也在不断发展并取得了显著成果!Nuxt 3 的下载量现在已经超过了 Nuxt 2。像 Vuetify 和 PrimeVue 这样的 UI 框架比以往任何时候都更准备好帮助构建大型(和较小的)应用程序。VueUse、Pinia 甚至 TresJS 等库也在不断增长和改进,以更好地支持我们。\n\n与 2022 年一样,开发者体验仍然是首要任务。Vue 3.3 为 `