宫商角徵羽

宫商角徵羽 查看完整档案

上海编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

宫商角徵羽 收藏了文章 · 10月15日

我敢打赌!这是全网最全的 Git 分支开发规范手册

Git 是目前最流行的源代码管理工具。为规范开发,保持代码提交记录以及 git 分支结构清晰,方便后续维护,现规范 git 的相关操作。

分支命名

1、master 分支

master 为主分支,也是用于部署生产环境的分支,确保master分支稳定性, master 分支一般由develop以及hotfix分支合并,任何时间都不能直接修改代码

2、develop 分支

develop 为开发分支,始终保持最新完成以及bug修复后的代码,一般开发的新功能时,feature分支都是基于develop分支下创建的。

feature 分支

  • 开发新功能时,以develop为基础创建feature分支。
  • 分支命名: feature/ 开头的为特性分支, 命名规则: feature/user_module、 feature/cart_module

release分支

release 为预上线分支,发布提测阶段,会release分支代码为基准提测。当有一组feature开发完成,首先会合并到develop分支,进入提测时会创建release分支。如果测试过程中若存在bug需要修复,则直接由开发者在release分支修复并提交。当测试完成之后,合并release分支到master和develop分支,此时master为最新代码,用作上线。

hotfix 分支

分支命名: hotfix/ 开头的为修复分支,它的命名规则与feature分支类似。线上出现紧急问题时,需要及时修复,以master分支为基线,创建hotfix分支,修复完成后,需要合并到master分支和develop分支

常见任务

增加新功能

(dev)$: git checkout -b feature/xxx            # 从dev建立特性分支
(feature/xxx)$: blabla                         # 开发
(feature/xxx)$: git add xxx
(feature/xxx)$: git commit -m 'commit comment'
(dev)$: git merge feature/xxx --no-ff          # 把特性分支合并到dev

修复紧急bug

(master)$: git checkout -b hotfix/xxx         # 从master建立hotfix分支
(hotfix/xxx)$: blabla                         # 开发
(hotfix/xxx)$: git add xxx
(hotfix/xxx)$: git commit -m 'commit comment'
(master)$: git merge hotfix/xxx --no-ff       # 把hotfix分支合并到master,并上线到生产环境
(dev)$: git merge hotfix/xxx --no-ff          # 把hotfix分支合并到dev,同步代码

测试环境代码

(release)$: git merge dev --no-ff             # 把dev分支合并到release,然后在测试环境拉取并测试

生产环境上线

(master)$: git merge release --no-ff          # 把release测试好的代码合并到master,运维人员操作
(master)$: git tag -a v0.1 -m '部署包版本名'  #给版本命名,打Tag

日志规范

在一个团队协作的项目中,开发人员需要经常提交一些代码去修复bug或者实现新的feature。

而项目中的文件和实现什么功能、解决什么问题都会渐渐淡忘,最后需要浪费时间去阅读代码。但是好的日志规范commit messages编写有帮助到我们,它也反映了一个开发人员是否是良好的协作者。

编写良好的Commit messages可以达到3个重要的目的:

  • 加快review的流程
  • 帮助我们编写良好的版本发布日志
  • 让之后的维护者了解代码里出现特定变化和feature被添加的原因

目前,社区有多种 Commit message 的写法规范。来自Angular 规范是目前使用最广的写法,比较合理和系统化。如下图:

Commit messages的基本语法

当前业界应用的比较广泛的是 Angular Git Commit Guidelines

https://github.com/angular/an...

具体格式为:

<type>: <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
  • type: 本次 commit 的类型,诸如 bugfix docs style 等
  • scope: 本次 commit 波及的范围
  • subject: 简明扼要的阐述下本次 commit 的主旨,在原文中特意强调了几点:
  • 使用祈使句,是不是很熟悉又陌生的一个词
  • 首字母不要大写
  • 结尾无需添加标点

body: 同样使用祈使句,在主体内容中我们需要把本次 commit 详细的描述一下,比如此次变更的动机,如需换行,则使用 |

footer: 描述下与之关联的 issue 或 break change

Type的类别说明:

  • feat: 添加新特性
  • fix: 修复bug
  • docs: 仅仅修改了文档
  • style: 仅仅修改了空格、格式缩进、都好等等,不改变代码逻辑
  • refactor: 代码重构,没有加新功能或者修复bug
  • perf: 增加代码进行性能测试
  • test: 增加测试用例
  • chore: 改变构建流程、或者增加依赖库、工具等

Commit messages格式要求

# 标题行:50个字符以内,描述主要变更内容
#
# 主体内容:更详细的说明文本,建议72个字符以内。需要描述的信息包括:
#
# * 为什么这个变更是必须的? 它可能是用来修复一个bug,增加一个feature,提升性能、可靠性、稳定性等等
# * 他如何解决这个问题? 具体描述解决问题的步骤
# * 是否存在副作用、风险?
#
# 如果需要的化可以添加一个链接到issue地址或者其它文档

来源:https://juejin.im/post/684490...

image

查看原文

宫商角徵羽 赞了回答 · 8月7日

npm i 报错 win10在6月份还是7月份更新之后

这感觉是你本地的 git 有问题,没有里没有包含 https 协议的处理.. 你本地可以试着使用 git clone 任意一个 github 仓库地址(https协议的)试试,看看会不会有这个问题

关注 2 回答 1

宫商角徵羽 提出了问题 · 8月7日

npm i 报错 win10在6月份还是7月份更新之后

image

关注 2 回答 1

宫商角徵羽 回答了问题 · 6月10日

angular1和现在最新的angular版本差别大吗

很大,基本上可以说是两种框架,ng1倾向于MVC ng2+倾向于MVVM

关注 3 回答 3

宫商角徵羽 赞了文章 · 5月20日

超级详细的Vue-cli3使用教程

Vue盛行的一个时代,大部分前端开发人员接触的第一个MV*的框架大多全是Vue,当然也有一部分人可能最开始接触的就是React或者AngularVue以详细的中文文档,以及容易上手的API被大家所熟知。

更多使用Vue的开发人员都很少在HTML中直接开发Vue的项目而是使用vue-cli脚手架,简直不要太方便,从Vue-cli2.0开始,笔者也在开始使用,也简单的看过2.0版本的webpack配置,简直不要太优秀,简直可以称之为范本有没有,就在所有人使用Vue-cli2.0如火如荼的时候。官方发布声明要推出Vue-cli3.0版本,掀起一片哗然,所有前端开发者同一个声音:学不动了~

玩归玩闹归闹,别拿职业生涯开玩笑,说正题,虽然Vue-cli3已经发布了很长时间,网上的教程博客也是数不胜数,为什么我还要再写一篇类似的博客呢?我摊牌了,就是为了炒冷饭,哈哈哈~下面开始进入正题。

安装

如果在电脑上已经安装了vue-cli2.0如果想要把其替换成vue-cli3.0的话需要先卸载原有vue-cli2.0的版本。

npm uninstall vue-cli -g

卸载完成之后就直接安装vue-cli3.0就好了

npm install -g @vue/cli

检测是否安装成功

vue --version

通过上面的步骤就可完成vue-cli3.0的安装。

创建项目

在使用vue-cli2.0创建项目的时候,直接使用vue webpack init 项目名称这样工具就可以轻松创建一个项目,vue-cli3.0也是一样的,但是既然版本不同了,那么自然而然的会有一些新的选项。当然安装vue-cli3.0之后还是可以使用vue-cli2.0脚手架的,创建项目方法还是一样的。

vue-cli3.0创建方法的命令是不一样的,需要和vue-cli2.0进行区分,vue-cli3.0使用的命令是:

vue create 项目名称

笔者觉得这样才更加的像一个脚手架,在通过命令创建项目的时候不会显得那么的繁琐。

输入完命令以后在窗口中可以看到有关项目的一些配置选项。

? Please pick a preset: (Use arrow keys)
  default (babel, eslint)   // 默认选项 
  Manually select features  //  手动选择功能

如果选择default则会直接创建项目,创建项目包括babel\eslin这些工具,比如Router/Vuex等其他依赖需要自己手动安装。

如果选择Manually select features(手动安装)则会进入下一步选项:(这里推荐大家进行手动配置)

? Please pick a preset: Manually select features
? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection)
>( ) Babel                              //  代码编译
 ( ) TypeScript                         //  ts
 ( ) Progressive Web App (PWA) Support  //  支持渐进式网页应用程序
 ( ) Router                             //  vue路由
 ( ) Vuex                               //  状态管理模式
 ( ) CSS Pre-processors                 //  css预处理
 ( ) Linter / Formatter                 //  代码风格、格式校验
 ( ) Unit Testing                       //  单元测试
 ( ) E2E Testing                        //  端对端测试

一般项目开发只需要选择BabelRouterVuex就足够了。

下面简单说一下选择不同的配置项会出现的不同的情况:

TypeScript
Use class-style component syntax?

这里询问的是是否使用class风格的组件语法,如果在项目中想要保持使用TypeScriptclass风格的话,建议大家选择y

Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n)

使用BabelTypeScript一起用于自动检测的填充?这里一定要选择y

Router
Use history mode for router? (Requires proper server setup for index fallback in production)

路由是否使用history模式?如果项目中存在要求就使用history(即:y),但是一般还是推荐大家使用hash模式,毕竟history模式需要依赖运维。

CSS Pre-processors css
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): (Use arrow keys)
> Sass/SCSS (with dart-sass)
  Sass/SCSS (with node-sass)
  Less
  Stylus

选择一种CSS预处理类型,这个需要根据各个项目的要求使用那种css编译处理了。

Linter / Formatter
? Pick a linter / formatter config: (Use arrow keys)
> ESLint with error prevention only     //  只进行报错提醒
  ESLint + Airbnb config                //  不严谨模式
  ESLint + Standard config              //  正常模式
  ESLint + Prettier                     //  严格模式
  TSLint (deprecated)                   //  TypeScript格式验证工具

TSLint只有在选择TypeScript时才会存在。

? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)
>(*) Lint on save               // 保存时检测
 ( ) Lint and fix on commit     // 修复和提交时检测

选择校验时机,一般都会选择保存时校验,好及时做出调整,如果代码风格和ESLint校验风格差不多的话,或者比较自信比较帅的情况下,可以考虑选择提交时校验。唯唯诺诺的我,选择了第一项。

Unit Testing
? Pick a unit testing solution: (Use arrow keys)
> Mocha + Chai
  Jest

选择单元测试解决方案,普遍用到最多的时Mocha + chai,这里就不多说了。

E2E Testing E2E(End To End)
? Pick a E2E testing solution: (Use arrow keys)
> Cypress (Chrome only)
  Nightwatch (WebDriver-based)

选择端对端测试的类型。

额外选项
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files     //  存放在专用配置文件中
  In package.json               //  存放在package.json中

选择Babel,PostCSS, ESLint等自定义配置的存放位置。这里建议大家选择第一个,

Save this as a preset for future projects? (y/N)

是否保存当前选择的配置项,如果当前配置是经常用到的配置,建议选择y存储一下当前配置项。如果只是临时使用的话就不需要存储了,根据自己情况而定啦。

选择n之后则会直接开始创建项目了,选择y之后则会输入一个存储当前配置项的名称:

? Save preset as:

下次再创建项目的时候就会看到,自己所存储的这个名字啦。

项目依赖

Vue-cli3.0可以使用npm安装所需要的依赖,出了这个他还提供了一个其他的方法vue add方法。

//  npm
npm install --save axios
//  vue
vue add axioa

既然可以使用npm安装为什么还要使用vue add安装呢?官方文档中是这样说明:

Vue CLI使用了一套基于插件的架构。如果你查阅一个新创建项目的package.json,就会发现依赖都是以@vue/cli-plugin-开头的。插件可以修改webpack的内部配置,也可以向vue-cli-service注入命令。在项目创建的过程中,绝大部分列出的特性都是通过插件来实现的。

基于插件的架构使得 Vue CLI 灵活且可扩展。

通过上面的说明可以看出vue-cli想要让脚手架工具变的更加的灵活,所以为我们添加了vue-cli的插件,这些插件在安装时会修改webpack里面配置(不是所有插件),而且还会在现有项目里面添加一些已经写好的范例文件(当然也是个别),但是有一点需要注意的是,这些命令会更改现有项目里面的内容。尤其是在使用vue add router或是vue add vuex效果还是蛮明显的。

然而使用npm install来安装的项目根本就不会帮我们做这些事情。虽然现在知道了vue官方提供了很多插件,但是应该从哪里看到呢?人性化的vue怎么可能会忽略这个问题呢?

vue ui

当我们在控制台输入上面命令之后稍等一会就会看到浏览器打开了一个新的页面,当然了,我们需要在电脑中找到我们的项目,导入进去。

看到这个页面后点击导入,然后会看到一些文件夹,具有vue的项目会做出特殊的标识,找到对应项目点击进去。

image

找到对应的项目,下面的导入这个文件夹按钮就可以使用了。页面也会同样的发生变化,就会变成下图这个样子

image

插件标签下面展示的是当前项目都安装了哪些插件,依赖标签下则展示的是所有的插件,可以明确的看出,对于vue的依赖还有插件进行细致的划分。

当我们想要新增依赖或者插件的话,进入到对应的页签下面,在右上角点击安装依赖(安装插件),这里就只说明一下安装依赖,插件安装相同。点击按钮后会发现当前所有的依赖,找到对应的依赖点击安装即可。是不是超级舒服。

image

其他页签下面的内容,大家可以自行研究一下,我这里就不多赘述了。

总结

vue-cli3.0虽让已经推出很久了,但是网上一直没有一个比较好的教程,毕竟用vue的人也是蛮多的。vue-cli3.0的推出让vue脚手架更加容易上手了,并且还提供了图形解面以供使用,简直不要太舒服。

今天的文章说明就到这里了,文章中如果有什么问题或者疑问,请在下方留言。我会尽快做出改正和解答。再次谢谢大家。。。

查看原文

赞 17 收藏 14 评论 0

宫商角徵羽 收藏了文章 · 5月20日

超级详细的Vue-cli3使用教程

Vue盛行的一个时代,大部分前端开发人员接触的第一个MV*的框架大多全是Vue,当然也有一部分人可能最开始接触的就是React或者AngularVue以详细的中文文档,以及容易上手的API被大家所熟知。

更多使用Vue的开发人员都很少在HTML中直接开发Vue的项目而是使用vue-cli脚手架,简直不要太方便,从Vue-cli2.0开始,笔者也在开始使用,也简单的看过2.0版本的webpack配置,简直不要太优秀,简直可以称之为范本有没有,就在所有人使用Vue-cli2.0如火如荼的时候。官方发布声明要推出Vue-cli3.0版本,掀起一片哗然,所有前端开发者同一个声音:学不动了~

玩归玩闹归闹,别拿职业生涯开玩笑,说正题,虽然Vue-cli3已经发布了很长时间,网上的教程博客也是数不胜数,为什么我还要再写一篇类似的博客呢?我摊牌了,就是为了炒冷饭,哈哈哈~下面开始进入正题。

安装

如果在电脑上已经安装了vue-cli2.0如果想要把其替换成vue-cli3.0的话需要先卸载原有vue-cli2.0的版本。

npm uninstall vue-cli -g

卸载完成之后就直接安装vue-cli3.0就好了

npm install -g @vue/cli

检测是否安装成功

vue --version

通过上面的步骤就可完成vue-cli3.0的安装。

创建项目

在使用vue-cli2.0创建项目的时候,直接使用vue webpack init 项目名称这样工具就可以轻松创建一个项目,vue-cli3.0也是一样的,但是既然版本不同了,那么自然而然的会有一些新的选项。当然安装vue-cli3.0之后还是可以使用vue-cli2.0脚手架的,创建项目方法还是一样的。

vue-cli3.0创建方法的命令是不一样的,需要和vue-cli2.0进行区分,vue-cli3.0使用的命令是:

vue create 项目名称

笔者觉得这样才更加的像一个脚手架,在通过命令创建项目的时候不会显得那么的繁琐。

输入完命令以后在窗口中可以看到有关项目的一些配置选项。

? Please pick a preset: (Use arrow keys)
  default (babel, eslint)   // 默认选项 
  Manually select features  //  手动选择功能

如果选择default则会直接创建项目,创建项目包括babel\eslin这些工具,比如Router/Vuex等其他依赖需要自己手动安装。

如果选择Manually select features(手动安装)则会进入下一步选项:(这里推荐大家进行手动配置)

? Please pick a preset: Manually select features
? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection)
>( ) Babel                              //  代码编译
 ( ) TypeScript                         //  ts
 ( ) Progressive Web App (PWA) Support  //  支持渐进式网页应用程序
 ( ) Router                             //  vue路由
 ( ) Vuex                               //  状态管理模式
 ( ) CSS Pre-processors                 //  css预处理
 ( ) Linter / Formatter                 //  代码风格、格式校验
 ( ) Unit Testing                       //  单元测试
 ( ) E2E Testing                        //  端对端测试

一般项目开发只需要选择BabelRouterVuex就足够了。

下面简单说一下选择不同的配置项会出现的不同的情况:

TypeScript
Use class-style component syntax?

这里询问的是是否使用class风格的组件语法,如果在项目中想要保持使用TypeScriptclass风格的话,建议大家选择y

Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n)

使用BabelTypeScript一起用于自动检测的填充?这里一定要选择y

Router
Use history mode for router? (Requires proper server setup for index fallback in production)

路由是否使用history模式?如果项目中存在要求就使用history(即:y),但是一般还是推荐大家使用hash模式,毕竟history模式需要依赖运维。

CSS Pre-processors css
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): (Use arrow keys)
> Sass/SCSS (with dart-sass)
  Sass/SCSS (with node-sass)
  Less
  Stylus

选择一种CSS预处理类型,这个需要根据各个项目的要求使用那种css编译处理了。

Linter / Formatter
? Pick a linter / formatter config: (Use arrow keys)
> ESLint with error prevention only     //  只进行报错提醒
  ESLint + Airbnb config                //  不严谨模式
  ESLint + Standard config              //  正常模式
  ESLint + Prettier                     //  严格模式
  TSLint (deprecated)                   //  TypeScript格式验证工具

TSLint只有在选择TypeScript时才会存在。

? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)
>(*) Lint on save               // 保存时检测
 ( ) Lint and fix on commit     // 修复和提交时检测

选择校验时机,一般都会选择保存时校验,好及时做出调整,如果代码风格和ESLint校验风格差不多的话,或者比较自信比较帅的情况下,可以考虑选择提交时校验。唯唯诺诺的我,选择了第一项。

Unit Testing
? Pick a unit testing solution: (Use arrow keys)
> Mocha + Chai
  Jest

选择单元测试解决方案,普遍用到最多的时Mocha + chai,这里就不多说了。

E2E Testing E2E(End To End)
? Pick a E2E testing solution: (Use arrow keys)
> Cypress (Chrome only)
  Nightwatch (WebDriver-based)

选择端对端测试的类型。

额外选项
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files     //  存放在专用配置文件中
  In package.json               //  存放在package.json中

选择Babel,PostCSS, ESLint等自定义配置的存放位置。这里建议大家选择第一个,

Save this as a preset for future projects? (y/N)

是否保存当前选择的配置项,如果当前配置是经常用到的配置,建议选择y存储一下当前配置项。如果只是临时使用的话就不需要存储了,根据自己情况而定啦。

选择n之后则会直接开始创建项目了,选择y之后则会输入一个存储当前配置项的名称:

? Save preset as:

下次再创建项目的时候就会看到,自己所存储的这个名字啦。

项目依赖

Vue-cli3.0可以使用npm安装所需要的依赖,出了这个他还提供了一个其他的方法vue add方法。

//  npm
npm install --save axios
//  vue
vue add axioa

既然可以使用npm安装为什么还要使用vue add安装呢?官方文档中是这样说明:

Vue CLI使用了一套基于插件的架构。如果你查阅一个新创建项目的package.json,就会发现依赖都是以@vue/cli-plugin-开头的。插件可以修改webpack的内部配置,也可以向vue-cli-service注入命令。在项目创建的过程中,绝大部分列出的特性都是通过插件来实现的。

基于插件的架构使得 Vue CLI 灵活且可扩展。

通过上面的说明可以看出vue-cli想要让脚手架工具变的更加的灵活,所以为我们添加了vue-cli的插件,这些插件在安装时会修改webpack里面配置(不是所有插件),而且还会在现有项目里面添加一些已经写好的范例文件(当然也是个别),但是有一点需要注意的是,这些命令会更改现有项目里面的内容。尤其是在使用vue add router或是vue add vuex效果还是蛮明显的。

然而使用npm install来安装的项目根本就不会帮我们做这些事情。虽然现在知道了vue官方提供了很多插件,但是应该从哪里看到呢?人性化的vue怎么可能会忽略这个问题呢?

vue ui

当我们在控制台输入上面命令之后稍等一会就会看到浏览器打开了一个新的页面,当然了,我们需要在电脑中找到我们的项目,导入进去。

看到这个页面后点击导入,然后会看到一些文件夹,具有vue的项目会做出特殊的标识,找到对应项目点击进去。

image

找到对应的项目,下面的导入这个文件夹按钮就可以使用了。页面也会同样的发生变化,就会变成下图这个样子

image

插件标签下面展示的是当前项目都安装了哪些插件,依赖标签下则展示的是所有的插件,可以明确的看出,对于vue的依赖还有插件进行细致的划分。

当我们想要新增依赖或者插件的话,进入到对应的页签下面,在右上角点击安装依赖(安装插件),这里就只说明一下安装依赖,插件安装相同。点击按钮后会发现当前所有的依赖,找到对应的依赖点击安装即可。是不是超级舒服。

image

其他页签下面的内容,大家可以自行研究一下,我这里就不多赘述了。

总结

vue-cli3.0虽让已经推出很久了,但是网上一直没有一个比较好的教程,毕竟用vue的人也是蛮多的。vue-cli3.0的推出让vue脚手架更加容易上手了,并且还提供了图形解面以供使用,简直不要太舒服。

今天的文章说明就到这里了,文章中如果有什么问题或者疑问,请在下方留言。我会尽快做出改正和解答。再次谢谢大家。。。

查看原文

宫商角徵羽 收藏了文章 · 5月19日

vue 路由及按钮权限控制 思路总结

说点儿闲话

本文是作者最近查阅权限控制相关资料的总结笔记

路由权限控制

前端路由是全部都由后端返回,还是后端返回对应角色下的权限,然后前端通过遍历的方式来修改当前路由呢?
引用上面这个问题的采纳答案:

第一种后台返回路由,第二种后台返回权限。
共同点:

两种方法都可以实现需求
前端都要维护一份路由地址与模块文件地址的映射
后段返回的数据一般都要再遍历做二次处理
有关页面内元素(按钮)的权限都要另做处理
技术点都会涉及路由守卫和路由鉴权

差异点:

默认路由列表:方法一只维护home、login等无权限需求路由,其他路由需要后续通过接口和路由api:addRoutes动态添加;方法二需要维护一个全量的路由列表,不需要额外添加路由,通过配置每个路由的access数组来做鉴权。
路由跳转:因为方法一返回的就是该用户权限下的路由,所以不需要再做权限鉴权;方法二需要。
路由的自定义程度:方法一可以通过修改数据库的路由数据来自定义前端的菜单结构,因此也需要做一个实现路由重组的递归函数,拓展性更好;方法二针对的是菜单结构相对稳定的项目,一般不支持结构变动。
返回报文:一般来说,返回报文大小 方法一比方法二要大

总结补充:
第一种是指动态路由,路由是分两部分,一部分是home、login等无权限需求路由,一部分是由后端返回的该用户权限下的路由,当用户登录后得到 roles,前端根据roles 去向后端请求可访问的路由表,从而动态生成可访问页面,之后就是 router.addRoutes 动态挂载到 router 上;前端需要有菜单管理,可以通过修改路由数据来自定义前端的菜单结构,拓展性更好。
第二种是前端配置路由表,后端仅返回权限,前端需要有菜单的权限管理,并且加载路由和菜单时要做权限验证;该方法是针对菜单结构相对稳定的项目,一般不支持结构变动。

按钮权限控制

视图控制

依据权限实现的按钮显隐控制和界面变化:
方法一:v-if

方法二:自定义指令

根据用户权限判断各个按钮的显示与否,方式无非是v-if或自定义指令,而且只要将v-if背后的权限校验逻辑抽象成方法,无论是代码量还是使用形式上都跟自定义指令几乎一样

v-if的特点是它会响应数据变化,因此随着应用的运行会频繁触发权限校验,而权限在应用的整个生命周期内其实只需校验一次。

自定义指令内部仍然是调用全局验证方法,但优点在于只会在元素初始化时执行一次,多数情况下都应该使用自定义指令实现视图控制。

所以,最好是使用自定义指令。

不一定每个操作按钮都会发起AJAX请求,比如编辑按钮本身并不会触发请求,真正触发请求的是另一个保存按钮。

划重点:
让按钮和请求联系起来,比如说按钮涉及一个名称为A的请求,那么权限指令可以这样写:

<btn v-has="A" @click="Fn">按钮</btn>
这里对A的实现可以有多种形式,比如A可以是一个包含两个属性的对象:
const A = {
  p: ['put,/menu/**'],
  r: params => {
    return axios.put(`/menu/${params.id}`, params)
  }
};
//用作权限:
<btn v-has="[A]" @click="Fn">按钮</btn>
//用作请求:
function Fn(){
    A.r().then((res) => {})
}

请求控制

利用axios拦截器实现的,目的是将越权请求在前端拦截掉。在请求拦截器中判断本次请求是否符合用户权限,以决定是否拦截。在请求发起前集中拦截,这时可以直接根据请求方法和请求地址来校验权限。

以axios为例,拦截器大概长这样:

axios.interceptors.request.use(function (config) {
 if(!has(config)){
 //验证不通过
   return Promise.reject({
     message: `no permission`
   });
 }
 return config;
});

参考资料

前端路由是全部都由后端返回,还是后端返回对应角色下的权限,然后前端通过遍历的方式来修改当前路由呢?

权限验证 | vue-element-admin
手摸手,带你用vue撸后台 系列二(登录权限篇)

用addRoutes实现动态路由
基于Vue实现后台系统权限控制
Vue2.0用户权限控制解决方案

查看原文

宫商角徵羽 收藏了文章 · 5月11日

从 0 到 1 搭建前端异常监控系统

本篇文章读后,你将GET的技能:

●收集前端错误(原生、React、Vue)

●编写错误上报逻辑

●利用Egg.js编写一个错误日志采集服务

●编写webpack插件自动上传sourcemap

●利用sourcemap还原压缩代码源码位置

●利用Jest进行单元测试

有没有心动的感觉?赶紧学起来吧!

如何捕获异常

JS异常:

js异常的特点是,出现不会导致JS引擎崩溃,最多只会终止当前执行的任务。

比如一个页面有两个按钮,如果点击按钮导致页面发生异常,这个时候页面不会崩溃。

只是这个按钮的功能失效,其他按钮还会有效☟

上面的例子我们用setTimeout分别启动了两个任务。

虽然第一个任务执行了一个错误的方法。程序执行停止了。但是另外一个任务并没有收到影响。

其实如果你不打开控制台都看不到发生了错误。好像是错误是在静默中发生的。

下面我们来看看这样的错误该如何收集。

try-catch:

JS作为一门高级语言我们首先想到的使用try-catch来收集。

如果在函数中错误没有被捕获,错误会上抛。

image.png

控制台中打印出的分别是错误信息和错误堆栈。

读到这里大家可能会想那就在最底层做一个错误try-catch不就好了吗。

确实作为一个从java转过来的程序员也是这么想的。

但是理想很丰满,现实很骨感。我们看看下一个例子。

image.png

大家注意运行结果,异常并没有被捕获。

这是因为JS的try-catch功能非常有限一遇到异步就不好用了。

那总不能为了收集错误给所有的异步都加一个try-catch吧,太坑爹了。

其实你想想异步任务其实也不是由代码形式上的上层调用的就比如本例中的setTimeout。

大家想想eventloop就明白啦,其实这些异步函数都是就好比一群没娘的孩子出了错误找不到家大人。

当然我也想过一些黑魔法来处理这个问题比如代理执行或者用过的异步方法。

算了还是还是再看看吧。

异常任务捕获

window.onerror:

window.onerror 最大的好处就是同步任务、异步任务都可捕获。

image.png

onerror返回值

onerror还有一个问题大家要注意 如果返回true 就不会被上抛了。

不然控制台中还会看到错误日志。

监听error事件:

文件中的位置☟

window.addEventListener('error',() => {})

其实 onerror 固然好但是还是有一类异常无法捕获。这就是网络异常的错误。

比如下面的例子。

<img data-original="./xxxxx.png">

试想一下我们如果页面上要显示的图片突然不显示了,而我们浑然不知那就是麻烦了。

addEventListener就是☟

运行结果如下☟

Promise异常捕获:

Promise 的出现主要是为了让我们解决回调地域问题。基本是我们程序开发的标配了。

虽然我们提倡使用 es7 async/await 语法来写。

但是不排除很多祖传代码还是存在Promise写法。

new Promise((resolve, reject) => {
  abcxxx()
});

这种情况无论是onerror还是监听错误事件都是无法捕获的。

image.png

除非每个Promise都添加一个catch方法。

但显然,我们不能这样做。

window.addEventListener("unhandledrejection", e => {
 console.log('unhandledrejection',e)
});

我们可以考虑将unhandledrejection事件捕获的错误抛出交由错误事件统一处理就可以了。

async/await异常捕获:

实际上async/await语法本质还是Promise语法。

区别就是async方法可以被上层的try/catch捕获。

image.png

如果不去捕获的话就会和Promise一样,需要用unhandledrejection事件捕获。

这样的话我们只需要在全局增加unhandlerejection就好了。

image.png

小结:

实际上我们可以将unhandledrejection事件抛出的异常再次抛出就可以统一通过error事件进行处理了。

最终用代码表示如下:

前端工程化

Webpack工程化:

现在是前端工程化的时代,工程化导出的代码一般都是被压缩混淆后的。

比如:

setTimeout(() => {
    xxx(1223)
}, 1000)

image.png

出错的代码指向被压缩后的JS文件,而JS文件长下图这个样子。

image.png

如果想将错误和原有的代码关联起来,那就需要sourcemap文件的帮忙了。

sourceMap是什么?

简单说,sourceMap就是一个文件,里面储存着位置信息。

仔细点说,这个文件里保存的,是转换后代码的位置,和对应的转换前的位置。

那么如何利用sourceMap还原异常代码发生的位置这个问题,我们到异常分析这个章节再讲。

VUE 工程

利用vue-cli工具直接创建一个项目。

image.png

为了测试的需要我们暂时关闭eslint 这里面还是建议大家全程打开eslint。

在vue.config.js进行配置

image.png

我们故意在(文件位置☟)

src/components/HelloWorld.vue

这个时候 错误会在控制台中被打印出来,但是错误事件并没有监听到。

errorHandle 句柄:

为了对Vue发生的异常进行统一的上报,需要利用vue提供的errorHandle句柄。

一旦Vue发生异常都会调用这个方法。

我们在src/main.js

image.png

React 工程:

npx create-react-app react-sample

cd react-sample

yarn start

我们用useEffect hooks 制造一个错误:

并且在src/index.js中增加错误事件监听逻辑:

window.addEventListener('error', args => {
    console.log('error', error)
})

但是从运行结果看虽然输出了错误日志但是还是服务捕获。

Error Boundary 组件

错误边界仅可以捕获其子组件的错误。

错误边界无法捕获其自身的错误。

如果一个错误边界无法渲染错误信息,则错误会向上冒泡至最接近的错误边界。

这也类似于 JavaScript 中 catch {} 的工作机制。

创建ErrorBoundary组件

在src/index.js中包裹App标签☟

最终运行的结果:

异常上报如何选择通讯方式

动态创建img标签:

其实上报就是要将捕获的异常信息发送到后端。最常用的方式首推动态创建标签方式。

因为这种方式无需加载任何通讯库,而且页面是无需刷新的。

基本上目前包括百度统计 Google统计都是基于这个原理做的埋点。

new Image().src = 'http://localhost:7001/monitor/error'+ '?info=xxxxxx'

通过动态创建一个img,浏览器就会向服务器发送get请求。

可以把你需要上报的错误数据放在querystring字符串中,利用这种方式就可以将错误上报到服务器了。

Ajax上报:

实际上我们也可以用ajax的方式上报错误,这和我们在业务程序中并没有什么区别。

上报哪些数据:

上报哪些数据:

我们先看一下error事件参数:

其中核心的应该是错误栈,其实我们定位错误最主要的就是错误栈。

错误堆栈中包含了绝大多数调试有关的信息。其中包括了异常位置(行号,列号),异常信息

上报数据序列化:

由于通讯的时候只能以字符串方式传输,我们需要将对象进行序列化处理。

大概分成以下三步:

1、将异常数据从属性中解构出来,存入一个JSON对象

2、将JSON对象转换为字符串

3、将字符串转换为Base64

当然在后端也要做对应的反向操作 这个我们后面再说。

image.png

异常上报的后端服务器

搭建egg.js工程:

异常上报的数据一定是要有一个后端服务接收才可以。

我们就以比较流行的开源框架eggjs为例来演示

# 全局安装egg-cli
npm i egg-init -g 

# 创建后端项目
egg-init backend --type=simple

cd backend
npm i

# 启动项目
npm run dev

编写error上传接口:

首先在app/router.js添加一个新的路由

image.png

创建一个新的:

controller (app/controller/monitor)

image.png

看一下接收后的结果 ☟

image.png

记入日志文件:

下一步就是将错误记入日志。实现的方法可以自己用fs写,也可以借助log4js这样成熟的日志库。

当然在eggjs中是支持我们定制日志那么就用这个功能定制一个前端错误日志好了。

在/config/config.default.js中增加一个定制日志配置

image.png

在/app/controller/monitor.js中添加日志记录:

image.png

最后实现的效果:

image.png

Webpack插件实现SourceMap上传

谈到异常分析最重要的工作其实是将webpack混淆压缩的代码还原。

创建Webpack插件:

/source-map/plugin(文件位置)

加载webpack插件:

webpack.config.js(文件位置)

添加sourceMap读取逻辑:

在apply函数中增加读取sourcemap文件的逻辑

/plugin/uploadSourceMapWebPlugin.js

实现http上传功能:

服务器端添加上传接口:

/backend/app/router.js(文件位置)

image.png

添加sourcemap上传接口:

/backend/app/controller/monitor.js

image.png

最终效果:

执行webpack打包时调用插件sourcemap被上传至服务器。

image.png

解析ErrorStack

考虑到这个功能需要较多逻辑,我们准备把他开发成一个独立的函数并且用Jest来做单元测试:

先看一下我们的需求☟

image.png

搭建Jest框架:

image.png

首先创建一个/utils/stackparser.js文件☟

image.png

在同级目录下创建测试文件stackparser.spec.js

以上需求我们用Jest表示就是

image.png

整理如下:

下面我们运行Jest

npx jest stackparser --watch

image.png

显示运行失败,原因很简单因为我们还没有实现对吧。

下面我们就实现一下这个方法

反序列Error对象:

首先创建一个新的Error对象 将错误栈设置到Error中。

然后利用error-stack-parser这个npm库来转化为stackFrame

image.png

运行效果如下☟

image.png

解析ErrorStack:

下一步我们将错误栈中的代码位置转换为源码位置

image.png

我们再用Jest测试一下☟

image.png

这时我们再看一下结果:

image.png

这样一来测试就通过啦~

将源码位置记入日志:

image.png

记录完成后,我们再来看一下运行效果:

image.png

结束了这一步,我们的ErrorStack工作就完成了。

需要运用的两种开源框架

Fundebug:

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 

自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有阳光保险、荔枝FM、掌门1对1、核桃编程、微脉等众多品牌企业。

Sentry:

Sentry 是一个开源的实时错误追踪系统,可以帮助开发者实时监控并修复异常问题。

它主要专注于持续集成、提高效率并且提升用户体验。

Sentry 分为服务端和客户端 SDK,前者可以直接使用它家提供的在线服务,也可以本地自行搭建;

后者提供了对多种主流语言和框架的支持,包括 React、Angular、Node、Django、RoR、PHP、Laravel、Android、.NET、JAVA 等。

同时它可提供了和其他流行服务集成的方案,例如 GitHub、GitLab、bitbuck、heroku、slack、Trello 等。

目前公司的项目也都在逐步应用上 Sentry 进行错误日志管理。

总结:

截止到目前为止,我们把前端异常监控的基本功能算是形成了一个MVP(最小化可行产品)。

后面需要升级的还有很多,对错误日志的分析和可视化方面可以使用ELK。

发布和部署可以采用Docker。对eggjs的上传和上报最好要增加权限控制功能。

查看原文

宫商角徵羽 收藏了文章 · 4月30日

一份关于vue-cli3项目常用项配置

  1. 配置全局cdn,包含js、css
  2. 开启Gzip压缩,包含文件js、css
  3. 去掉注释、去掉console.log
  4. 压缩图片
  5. 本地代理
  6. 设置别名,vscode也能识别
  7. 配置环境变量开发模式、测试模式、生产模式
  8. 请求路由动态添加
  9. axios配置
  10. 添加mock数据
  11. 配置全局less
  12. 只打包改变的文件
  13. 开启分析打包日志

vue.config.js

完整的架构配置

const path = require('path');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin') // 去掉注释
const CompressionWebpackPlugin = require('compression-webpack-plugin'); // 开启压缩
const { HashedModuleIdsPlugin } = require('webpack');

function resolve(dir) {
    return path.join(__dirname, dir)
}

const isProduction = process.env.NODE_ENV === 'production';

// cdn预加载使用
const externals = {
    'vue': 'Vue',
    'vue-router': 'VueRouter',
    'vuex': 'Vuex',
    'axios': 'axios',
    "element-ui": "ELEMENT"
}

const cdn = {
    // 开发环境
    dev: {
        css: [
            'https://unpkg.com/element-ui/lib/theme-chalk/index.css'
        ],
        js: []
    },
    // 生产环境
    build: {
        css: [
            'https://unpkg.com/element-ui/lib/theme-chalk/index.css'
        ],
        js: [
            'https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js',
            'https://cdn.jsdelivr.net/npm/vue-router@3.0.1/dist/vue-router.min.js',
            'https://cdn.jsdelivr.net/npm/vuex@3.0.1/dist/vuex.min.js',
            'https://cdn.jsdelivr.net/npm/axios@0.18.0/dist/axios.min.js',
            'https://unpkg.com/element-ui/lib/index.js'
        ]
    }
}

module.exports = {

    lintOnSave: false, // 关闭eslint
    productionSourceMap: false,
    publicPath: './', 
    outputDir: process.env.outputDir, // 生成文件的目录名称
    chainWebpack: config => {

        config.resolve.alias
            .set('@', resolve('src'))

        // 压缩图片
        config.module
            .rule('images')
            .test(/\.(png|jpe?g|gif|svg)(\?.*)?$/)
            .use('image-webpack-loader')
            .loader('image-webpack-loader')
            .options({ bypassOnDebug: true })

        // webpack 会默认给commonChunk打进chunk-vendors,所以需要对webpack的配置进行delete
        config.optimization.delete('splitChunks')

        config.plugin('html').tap(args => {
            if (process.env.NODE_ENV === 'production') {
                args[0].cdn = cdn.build
            }
            if (process.env.NODE_ENV === 'development') {
                args[0].cdn = cdn.dev
            }
            return args
        })
        
        config
            .plugin('webpack-bundle-analyzer')
            .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
    },

    configureWebpack: config => {
        const plugins = [];

        if (isProduction) {
            plugins.push(
                new UglifyJsPlugin({
                    uglifyOptions: {
                        output: {
                            comments: false, // 去掉注释
                        },
                        warnings: false,
                        compress: {
                            drop_console: true,
                            drop_debugger: false,
                            pure_funcs: ['console.log']//移除console
                        }
                    }
                })
            )
            // 服务器也要相应开启gzip
            plugins.push(
                new CompressionWebpackPlugin({
                    algorithm: 'gzip',
                    test: /\.(js|css)$/,// 匹配文件名
                    threshold: 10000, // 对超过10k的数据压缩
                    deleteOriginalAssets: false, // 不删除源文件
                    minRatio: 0.8 // 压缩比
                })
            )

            // 用于根据模块的相对路径生成 hash 作为模块 id, 一般用于生产环境
            plugins.push(
                new HashedModuleIdsPlugin()
            )

            // 开启分离js
            config.optimization = {
                runtimeChunk: 'single',
                splitChunks: {
                    chunks: 'all',
                    maxInitialRequests: Infinity,
                    minSize: 1000 * 60,
                    cacheGroups: {
                        vendor: {
                            test: /[\\/]node_modules[\\/]/,
                            name(module) {
                                // 排除node_modules 然后吧 @ 替换为空 ,考虑到服务器的兼容
                                const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]
                                return `npm.${packageName.replace('@', '')}`
                            }
                        }
                    }
                }
            };

            // 取消webpack警告的性能提示
            config.performance = {
                hints: 'warning',
                //入口起点的最大体积
                maxEntrypointSize: 1000 * 500,
                //生成文件的最大体积
                maxAssetSize: 1000 * 1000,
                //只给出 js 文件的性能提示
                assetFilter: function (assetFilename) {
                    return assetFilename.endsWith('.js');
                }
            }

            // 打包时npm包转CDN
            config.externals = externals;
        }

        return { plugins }
    },

    pluginOptions: {
        // 配置全局less
        'style-resources-loader': {
            preProcessor: 'less',
            patterns: [resolve('./src/style/theme.less')]
        }
    },
    devServer: {
        open: false, // 自动启动浏览器
        host: '0.0.0.0', // localhost
        port: 6060, // 端口号
        https: false,
        hotOnly: false, // 热更新
        proxy: {
            '^/sso': {
                target: process.env.VUE_APP_SSO, // 重写路径
                ws: true,   //开启WebSocket
                secure: false,      // 如果是https接口,需要配置这个参数
                changeOrigin: true
            }
        }
    }
}

html模板配置cdn

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <% for (var i in
        htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.css) { %>
    <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="preload" as="style" />
    <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="stylesheet" />
    <% } %>
</head>

<body>
    <noscript>
        <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
            Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
    <% for (var i in
        htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
    <script data-original="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
    <% } %>
</body>
</html>

开启Gzip压缩,包含文件js、css

new CompressionWebpackPlugin({
      algorithm: 'gzip',
      test: /\.(js|css)$/, // 匹配文件名
      threshold: 10000, // 对超过10k的数据压缩
      deleteOriginalAssets: false, // 不删除源文件
      minRatio: 0.8 // 压缩比
})

去掉注释、去掉console.log

安装cnpm i uglifyjs-webpack-plugin -D

const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
new UglifyJsPlugin({
    uglifyOptions: {
        output: {
            comments: false, // 去掉注释
        },
        warnings: false,
        compress: {
            drop_console: true,
            drop_debugger: false,
            pure_funcs: ['console.log'] //移除console
        }
    }
})

压缩图片

chainWebpack: config => {
    // 压缩图片
    config.module
        .rule('images')
        .test(/\.(png|jpe?g|gif|svg)(\?.*)?$/)
        .use('image-webpack-loader')
        .loader('image-webpack-loader')
        .options({ bypassOnDebug: true })

}

本地代理

devServer: {
    open: false, // 自动启动浏览器
    host: '0.0.0.0', // localhost
    port: 6060, // 端口号
    https: false,
    hotOnly: false, // 热更新
    proxy: {
        '^/sso': {
            target: process.env.VUE_APP_SSO, // 重写路径
            ws: true, //开启WebSocket
            secure: false, // 如果是https接口,需要配置这个参数
            changeOrigin: true
        }
    }
}

设置vscode 识别别名

在vscode中插件安装栏搜索 Path Intellisense 插件,打开settings.json文件添加 以下代码 "@": "${workspaceRoot}/src",安以下添加

{
    "workbench.iconTheme": "material-icon-theme",
    "editor.fontSize": 16,
    "editor.detectIndentation": false,
    "guides.enabled": false,
    "workbench.colorTheme": "Monokai",
    "path-intellisense.mappings": {
        "@": "${workspaceRoot}/src"
    }
}

在项目package.json所在同级目录下创建文件jsconfig.json

{
    "compilerOptions": {
        "target": "ES6",
        "module": "commonjs",
        "allowSyntheticDefaultImports": true,
        "baseUrl": "./",
        "paths": {
          "@/*": ["src/*"]
        }
    },
    "exclude": [
        "node_modules"
    ]
}

如果还没请客官移步在vscode中使用别名@按住ctrl也能跳转对应路径

配置环境变量开发模式、测试模式、生产模式

在根目录新建

.env.development

# 开发环境
NODE_ENV='development'

VUE_APP_SSO='http://http://localhost:9080'

.env.test

NODE_ENV = 'production' # 如果我们在.env.test文件中把NODE_ENV设置为test的话,那么打包出来的目录结构是有差异的
VUE_APP_MODE = 'test'
VUE_APP_SSO='http://http://localhost:9080'
outputDir = test

.env.production

NODE_ENV = 'production'

VUE_APP_SSO='http://http://localhost:9080'

package.json

"scripts": {
    "build": "vue-cli-service build", //生产打包
    "lint": "vue-cli-service lint",
    "dev": "vue-cli-service serve", // 开发模式
    "test": "vue-cli-service build --mode test", // 测试打包
    "publish": "vue-cli-service build && vue-cli-service build --mode test" // 测试和生产一起打包
 }

请求路由动态添加

router/index.js文件

import Vue from 'vue';
import VueRouter from 'vue-router'
Vue.use(VueRouter)

import defaultRouter from './defaultRouter'
import dynamicRouter from './dynamicRouter';

import store from '@/store';

const router = new VueRouter({
    routes: defaultRouter,
    mode: 'hash',
    scrollBehavior(to, from, savedPosition) {
        // keep-alive 返回缓存页面后记录浏览位置
        if (savedPosition && to.meta.keepAlive) {
            return savedPosition;
        }
        // 异步滚动操作
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve({ x: 0, y: 0 })
            }, 200)
        })
    }
})

// 消除路由重复警告
const selfaddRoutes = function (params) {
    router.matcher = new VueRouter().matcher;
    router.addRoutes(params);
}

// 全局路由拦截
router.beforeEach((to, from, next) => {
    const { hasRoute } = store.state; // 防止路由重复添加
    if (hasRoute) {
        next()
    } else {
        dynamicRouter(to, from, next, selfaddRoutes)
    }
})

export default router;

dynamicRouter.js

import http from '@/http/request';
import defaultRouter from './defaultRouter'
import store from '@/store'

// 重新构建路由对象
const menusMap = function (menu) {
    return menu.map(v => {
        const { path, name, component } = v
        const item = {
            path,
            name,
            component: () => import(`@/${component}`)
        }
        return item;
    })
}


// 获取路由
const addPostRouter = function (to, from, next, selfaddRoutes) {
    http.windPost('/mock/menu') // 发起请求获取路由
        .then(menu => {
            defaultRouter[0].children.push(...menusMap(menu));
            selfaddRoutes(defaultRouter);
            store.commit('hasRoute', true);
            next({ ...to, replace: true })
        })
}

export default addPostRouter;

defaultRouter.js 默认路由

const main = r => require.ensure([], () => r(require('@/layout/main.vue')), 'main')
const index = r => require.ensure([], () => r(require('@/view/index/index.vue')), 'index')
const about = r => require.ensure([], () => r(require('@/view/about/about.vue')), 'about')
const detail = r => require.ensure([], () => r(require('@/view/detail/detail.vue')), 'detail')
const error = r => require.ensure([], () => r(require('@/view/404/404.vue')), 'error');
const defaultRouter = [
    {
        path: "/", 
        component: main, // 布局页
        redirect: {
            name: "index"
        },
        children:[
            {
                path: '/index',
                component: index,
                name: 'index',
                meta: {
                    title: 'index'
                }
            },
            {
                path: '/about',
                component: about,
                name: 'about',
                meta: {
                    title: 'about'
                }
            },
            {
                path: '/detail',
                component: detail,
                name: 'detail',
                meta: {
                    title: 'detail'
                }
            }
        ]
    },
    {
        path: '/404',
        component: error,
        name: '404',
        meta: {
            title: '404'
        }
    }
]
export default defaultRouter;

axios配置

import axios from "axios";
import merge from 'lodash/merge'
import qs from 'qs'

/**
 * 实例化
 * config是库的默认值,然后是实例的 defaults 属性,最后是请求设置的 config 参数。后者将优先于前者
 */
const http = axios.create({
    timeout: 1000 * 30,
    withCredentials: true, // 表示跨域请求时是否需要使用凭证
});

/**
 * 请求拦截
 */
http.interceptors.request.use(function (config) {
    return config;
}, function (error) {
    return Promise.reject(error);
});


/**
 * 响应拦截
 */
http.interceptors.response.use(response => {
    // 过期之类的操作
    if (response.data && (response.data.code === 401)) {
        // window.location.href = ''; 重定向
    }
    return response
}, error => {
    return Promise.reject(error)
})


/**
 * 请求地址处理
 */
http.adornUrl = (url) => {
    return url;
}

/**
 * get请求参数处理
 * params 参数对象
 * openDefultParams 是否开启默认参数
 */
http.adornParams = (params = {}, openDefultParams = true) => {
    var defaults = {
        t: new Date().getTime()
    }
    return openDefultParams ? merge(defaults, params) : params
}


/**
 * post请求数据处理
 * @param {*} data 数据对象
 * @param {*} openDefultdata 是否开启默认数据?
 * @param {*} contentType 数据格式
 *  json: 'application/json; charset=utf-8'
 *  form: 'application/x-www-form-urlencoded; charset=utf-8'
 */
http.adornData = (data = {}, openDefultdata = true, contentType = 'json') => {
    var defaults = {
        t: new Date().getTime()
    }
    data = openDefultdata ? merge(defaults, data) : data
    return contentType === 'json' ? JSON.stringify(data) : qs.stringify(data)
}


/**
 * windPost请求
 * @param {String} url [请求地址]
 * @param {Object} params [请求携带参数]
 */
http.windPost = function (url, params) {
    return new Promise((resolve, reject) => {
        http.post(http.adornUrl(url), qs.stringify(params))
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}


/**
 * windJsonPost请求
 * @param {String} url [请求地址]
 * @param {Object} params [请求携带参数]
 */
http.windJsonPost = function (url, params) {
    return new Promise((resolve, reject) => {
        http.post(http.adornUrl(url), http.adornParams(params))
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}


/**
 * windGet请求
 * @param {String} url [请求地址]
 * @param {Object} params [请求携带参数]
 */
http.windGet = function (url, params) {
    return new Promise((resolve, reject) => {
        http.get(http.adornUrl(url), { params: params })
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}

/**
 * 上传图片
 */
http.upLoadPhoto = function (url, params, callback) {
    let config = {}
    if (callback !== null) {
        config = {
            onUploadProgress: function (progressEvent) {
                //属性lengthComputable主要表明总共需要完成的工作量和已经完成的工作是否可以被测量
                //如果lengthComputable为false,就获取不到progressEvent.total和progressEvent.loaded
                callback(progressEvent)
            }
        }
    }
    return new Promise((resolve, reject) => {
        http.post(http.adornUrl(url), http.adornParams(params), config)
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}
export default http;

添加mock数据

const Mock = require('mockjs')

// 获取 mock.Random 对象
const Random = Mock.Random
// mock新闻数据,包括新闻标题title、内容content、创建时间createdTime
const produceNewsData = function () {
    let newsList = []
    for (let i = 0; i < 3; i++) {
        let newNewsObject = {}
        if(i === 0){
            newNewsObject.path = '/add/article';
            newNewsObject.name  = 'add-article';
            newNewsObject.component = 'modules/add/article/article';
        }
        if(i === 1){
            newNewsObject.path = '/detail/article';
            newNewsObject.name  = 'detail-article';
            newNewsObject.component = 'modules/detail/article/article'
        }
        if(i === 2){
            newNewsObject.path = '/edit/article';
            newNewsObject.name  = 'edit-article';
            newNewsObject.component = 'modules/edit/article/article'
        }
        newsList.push(newNewsObject)
    }
    return newsList;
}
Mock.mock('/mock/menu', produceNewsData)

配置全局less

pluginOptions: {
    // 配置全局less
    'style-resources-loader': {
        preProcessor: 'less',
        patterns: [resolve('./src/style/theme.less')]
    }
}

只打包改变的文件

安装cnpm i webpack -D

const { HashedModuleIdsPlugin } = require('webpack');
configureWebpack: config => {    
    const plugins = [];
    plugins.push(
        new HashedModuleIdsPlugin()
    )
}

开启分析打包日志

安装cnpm i webpack-bundle-analyzer -D

chainWebpack: config => {
    config
        .plugin('webpack-bundle-analyzer')
        .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
}

完整代码

image.png

点击获取完整代码github

获取更多相关知识

横版二维码.png

查看原文

宫商角徵羽 赞了文章 · 4月30日

一份关于vue-cli3项目常用项配置

  1. 配置全局cdn,包含js、css
  2. 开启Gzip压缩,包含文件js、css
  3. 去掉注释、去掉console.log
  4. 压缩图片
  5. 本地代理
  6. 设置别名,vscode也能识别
  7. 配置环境变量开发模式、测试模式、生产模式
  8. 请求路由动态添加
  9. axios配置
  10. 添加mock数据
  11. 配置全局less
  12. 只打包改变的文件
  13. 开启分析打包日志

vue.config.js

完整的架构配置

const path = require('path');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin') // 去掉注释
const CompressionWebpackPlugin = require('compression-webpack-plugin'); // 开启压缩
const { HashedModuleIdsPlugin } = require('webpack');

function resolve(dir) {
    return path.join(__dirname, dir)
}

const isProduction = process.env.NODE_ENV === 'production';

// cdn预加载使用
const externals = {
    'vue': 'Vue',
    'vue-router': 'VueRouter',
    'vuex': 'Vuex',
    'axios': 'axios',
    "element-ui": "ELEMENT"
}

const cdn = {
    // 开发环境
    dev: {
        css: [
            'https://unpkg.com/element-ui/lib/theme-chalk/index.css'
        ],
        js: []
    },
    // 生产环境
    build: {
        css: [
            'https://unpkg.com/element-ui/lib/theme-chalk/index.css'
        ],
        js: [
            'https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js',
            'https://cdn.jsdelivr.net/npm/vue-router@3.0.1/dist/vue-router.min.js',
            'https://cdn.jsdelivr.net/npm/vuex@3.0.1/dist/vuex.min.js',
            'https://cdn.jsdelivr.net/npm/axios@0.18.0/dist/axios.min.js',
            'https://unpkg.com/element-ui/lib/index.js'
        ]
    }
}

module.exports = {

    lintOnSave: false, // 关闭eslint
    productionSourceMap: false,
    publicPath: './', 
    outputDir: process.env.outputDir, // 生成文件的目录名称
    chainWebpack: config => {

        config.resolve.alias
            .set('@', resolve('src'))

        // 压缩图片
        config.module
            .rule('images')
            .test(/\.(png|jpe?g|gif|svg)(\?.*)?$/)
            .use('image-webpack-loader')
            .loader('image-webpack-loader')
            .options({ bypassOnDebug: true })

        // webpack 会默认给commonChunk打进chunk-vendors,所以需要对webpack的配置进行delete
        config.optimization.delete('splitChunks')

        config.plugin('html').tap(args => {
            if (process.env.NODE_ENV === 'production') {
                args[0].cdn = cdn.build
            }
            if (process.env.NODE_ENV === 'development') {
                args[0].cdn = cdn.dev
            }
            return args
        })
        
        config
            .plugin('webpack-bundle-analyzer')
            .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
    },

    configureWebpack: config => {
        const plugins = [];

        if (isProduction) {
            plugins.push(
                new UglifyJsPlugin({
                    uglifyOptions: {
                        output: {
                            comments: false, // 去掉注释
                        },
                        warnings: false,
                        compress: {
                            drop_console: true,
                            drop_debugger: false,
                            pure_funcs: ['console.log']//移除console
                        }
                    }
                })
            )
            // 服务器也要相应开启gzip
            plugins.push(
                new CompressionWebpackPlugin({
                    algorithm: 'gzip',
                    test: /\.(js|css)$/,// 匹配文件名
                    threshold: 10000, // 对超过10k的数据压缩
                    deleteOriginalAssets: false, // 不删除源文件
                    minRatio: 0.8 // 压缩比
                })
            )

            // 用于根据模块的相对路径生成 hash 作为模块 id, 一般用于生产环境
            plugins.push(
                new HashedModuleIdsPlugin()
            )

            // 开启分离js
            config.optimization = {
                runtimeChunk: 'single',
                splitChunks: {
                    chunks: 'all',
                    maxInitialRequests: Infinity,
                    minSize: 1000 * 60,
                    cacheGroups: {
                        vendor: {
                            test: /[\\/]node_modules[\\/]/,
                            name(module) {
                                // 排除node_modules 然后吧 @ 替换为空 ,考虑到服务器的兼容
                                const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]
                                return `npm.${packageName.replace('@', '')}`
                            }
                        }
                    }
                }
            };

            // 取消webpack警告的性能提示
            config.performance = {
                hints: 'warning',
                //入口起点的最大体积
                maxEntrypointSize: 1000 * 500,
                //生成文件的最大体积
                maxAssetSize: 1000 * 1000,
                //只给出 js 文件的性能提示
                assetFilter: function (assetFilename) {
                    return assetFilename.endsWith('.js');
                }
            }

            // 打包时npm包转CDN
            config.externals = externals;
        }

        return { plugins }
    },

    pluginOptions: {
        // 配置全局less
        'style-resources-loader': {
            preProcessor: 'less',
            patterns: [resolve('./src/style/theme.less')]
        }
    },
    devServer: {
        open: false, // 自动启动浏览器
        host: '0.0.0.0', // localhost
        port: 6060, // 端口号
        https: false,
        hotOnly: false, // 热更新
        proxy: {
            '^/sso': {
                target: process.env.VUE_APP_SSO, // 重写路径
                ws: true,   //开启WebSocket
                secure: false,      // 如果是https接口,需要配置这个参数
                changeOrigin: true
            }
        }
    }
}

html模板配置cdn

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <% for (var i in
        htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.css) { %>
    <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="preload" as="style" />
    <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="stylesheet" />
    <% } %>
</head>

<body>
    <noscript>
        <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
            Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
    <% for (var i in
        htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
    <script data-original="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
    <% } %>
</body>
</html>

开启Gzip压缩,包含文件js、css

new CompressionWebpackPlugin({
      algorithm: 'gzip',
      test: /\.(js|css)$/, // 匹配文件名
      threshold: 10000, // 对超过10k的数据压缩
      deleteOriginalAssets: false, // 不删除源文件
      minRatio: 0.8 // 压缩比
})

去掉注释、去掉console.log

安装cnpm i uglifyjs-webpack-plugin -D

const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
new UglifyJsPlugin({
    uglifyOptions: {
        output: {
            comments: false, // 去掉注释
        },
        warnings: false,
        compress: {
            drop_console: true,
            drop_debugger: false,
            pure_funcs: ['console.log'] //移除console
        }
    }
})

压缩图片

chainWebpack: config => {
    // 压缩图片
    config.module
        .rule('images')
        .test(/\.(png|jpe?g|gif|svg)(\?.*)?$/)
        .use('image-webpack-loader')
        .loader('image-webpack-loader')
        .options({ bypassOnDebug: true })

}

本地代理

devServer: {
    open: false, // 自动启动浏览器
    host: '0.0.0.0', // localhost
    port: 6060, // 端口号
    https: false,
    hotOnly: false, // 热更新
    proxy: {
        '^/sso': {
            target: process.env.VUE_APP_SSO, // 重写路径
            ws: true, //开启WebSocket
            secure: false, // 如果是https接口,需要配置这个参数
            changeOrigin: true
        }
    }
}

设置vscode 识别别名

在vscode中插件安装栏搜索 Path Intellisense 插件,打开settings.json文件添加 以下代码 "@": "${workspaceRoot}/src",安以下添加

{
    "workbench.iconTheme": "material-icon-theme",
    "editor.fontSize": 16,
    "editor.detectIndentation": false,
    "guides.enabled": false,
    "workbench.colorTheme": "Monokai",
    "path-intellisense.mappings": {
        "@": "${workspaceRoot}/src"
    }
}

在项目package.json所在同级目录下创建文件jsconfig.json

{
    "compilerOptions": {
        "target": "ES6",
        "module": "commonjs",
        "allowSyntheticDefaultImports": true,
        "baseUrl": "./",
        "paths": {
          "@/*": ["src/*"]
        }
    },
    "exclude": [
        "node_modules"
    ]
}

如果还没请客官移步在vscode中使用别名@按住ctrl也能跳转对应路径

配置环境变量开发模式、测试模式、生产模式

在根目录新建

.env.development

# 开发环境
NODE_ENV='development'

VUE_APP_SSO='http://http://localhost:9080'

.env.test

NODE_ENV = 'production' # 如果我们在.env.test文件中把NODE_ENV设置为test的话,那么打包出来的目录结构是有差异的
VUE_APP_MODE = 'test'
VUE_APP_SSO='http://http://localhost:9080'
outputDir = test

.env.production

NODE_ENV = 'production'

VUE_APP_SSO='http://http://localhost:9080'

package.json

"scripts": {
    "build": "vue-cli-service build", //生产打包
    "lint": "vue-cli-service lint",
    "dev": "vue-cli-service serve", // 开发模式
    "test": "vue-cli-service build --mode test", // 测试打包
    "publish": "vue-cli-service build && vue-cli-service build --mode test" // 测试和生产一起打包
 }

请求路由动态添加

router/index.js文件

import Vue from 'vue';
import VueRouter from 'vue-router'
Vue.use(VueRouter)

import defaultRouter from './defaultRouter'
import dynamicRouter from './dynamicRouter';

import store from '@/store';

const router = new VueRouter({
    routes: defaultRouter,
    mode: 'hash',
    scrollBehavior(to, from, savedPosition) {
        // keep-alive 返回缓存页面后记录浏览位置
        if (savedPosition && to.meta.keepAlive) {
            return savedPosition;
        }
        // 异步滚动操作
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve({ x: 0, y: 0 })
            }, 200)
        })
    }
})

// 消除路由重复警告
const selfaddRoutes = function (params) {
    router.matcher = new VueRouter().matcher;
    router.addRoutes(params);
}

// 全局路由拦截
router.beforeEach((to, from, next) => {
    const { hasRoute } = store.state; // 防止路由重复添加
    if (hasRoute) {
        next()
    } else {
        dynamicRouter(to, from, next, selfaddRoutes)
    }
})

export default router;

dynamicRouter.js

import http from '@/http/request';
import defaultRouter from './defaultRouter'
import store from '@/store'

// 重新构建路由对象
const menusMap = function (menu) {
    return menu.map(v => {
        const { path, name, component } = v
        const item = {
            path,
            name,
            component: () => import(`@/${component}`)
        }
        return item;
    })
}


// 获取路由
const addPostRouter = function (to, from, next, selfaddRoutes) {
    http.windPost('/mock/menu') // 发起请求获取路由
        .then(menu => {
            defaultRouter[0].children.push(...menusMap(menu));
            selfaddRoutes(defaultRouter);
            store.commit('hasRoute', true);
            next({ ...to, replace: true })
        })
}

export default addPostRouter;

defaultRouter.js 默认路由

const main = r => require.ensure([], () => r(require('@/layout/main.vue')), 'main')
const index = r => require.ensure([], () => r(require('@/view/index/index.vue')), 'index')
const about = r => require.ensure([], () => r(require('@/view/about/about.vue')), 'about')
const detail = r => require.ensure([], () => r(require('@/view/detail/detail.vue')), 'detail')
const error = r => require.ensure([], () => r(require('@/view/404/404.vue')), 'error');
const defaultRouter = [
    {
        path: "/", 
        component: main, // 布局页
        redirect: {
            name: "index"
        },
        children:[
            {
                path: '/index',
                component: index,
                name: 'index',
                meta: {
                    title: 'index'
                }
            },
            {
                path: '/about',
                component: about,
                name: 'about',
                meta: {
                    title: 'about'
                }
            },
            {
                path: '/detail',
                component: detail,
                name: 'detail',
                meta: {
                    title: 'detail'
                }
            }
        ]
    },
    {
        path: '/404',
        component: error,
        name: '404',
        meta: {
            title: '404'
        }
    }
]
export default defaultRouter;

axios配置

import axios from "axios";
import merge from 'lodash/merge'
import qs from 'qs'

/**
 * 实例化
 * config是库的默认值,然后是实例的 defaults 属性,最后是请求设置的 config 参数。后者将优先于前者
 */
const http = axios.create({
    timeout: 1000 * 30,
    withCredentials: true, // 表示跨域请求时是否需要使用凭证
});

/**
 * 请求拦截
 */
http.interceptors.request.use(function (config) {
    return config;
}, function (error) {
    return Promise.reject(error);
});


/**
 * 响应拦截
 */
http.interceptors.response.use(response => {
    // 过期之类的操作
    if (response.data && (response.data.code === 401)) {
        // window.location.href = ''; 重定向
    }
    return response
}, error => {
    return Promise.reject(error)
})


/**
 * 请求地址处理
 */
http.adornUrl = (url) => {
    return url;
}

/**
 * get请求参数处理
 * params 参数对象
 * openDefultParams 是否开启默认参数
 */
http.adornParams = (params = {}, openDefultParams = true) => {
    var defaults = {
        t: new Date().getTime()
    }
    return openDefultParams ? merge(defaults, params) : params
}


/**
 * post请求数据处理
 * @param {*} data 数据对象
 * @param {*} openDefultdata 是否开启默认数据?
 * @param {*} contentType 数据格式
 *  json: 'application/json; charset=utf-8'
 *  form: 'application/x-www-form-urlencoded; charset=utf-8'
 */
http.adornData = (data = {}, openDefultdata = true, contentType = 'json') => {
    var defaults = {
        t: new Date().getTime()
    }
    data = openDefultdata ? merge(defaults, data) : data
    return contentType === 'json' ? JSON.stringify(data) : qs.stringify(data)
}


/**
 * windPost请求
 * @param {String} url [请求地址]
 * @param {Object} params [请求携带参数]
 */
http.windPost = function (url, params) {
    return new Promise((resolve, reject) => {
        http.post(http.adornUrl(url), qs.stringify(params))
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}


/**
 * windJsonPost请求
 * @param {String} url [请求地址]
 * @param {Object} params [请求携带参数]
 */
http.windJsonPost = function (url, params) {
    return new Promise((resolve, reject) => {
        http.post(http.adornUrl(url), http.adornParams(params))
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}


/**
 * windGet请求
 * @param {String} url [请求地址]
 * @param {Object} params [请求携带参数]
 */
http.windGet = function (url, params) {
    return new Promise((resolve, reject) => {
        http.get(http.adornUrl(url), { params: params })
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}

/**
 * 上传图片
 */
http.upLoadPhoto = function (url, params, callback) {
    let config = {}
    if (callback !== null) {
        config = {
            onUploadProgress: function (progressEvent) {
                //属性lengthComputable主要表明总共需要完成的工作量和已经完成的工作是否可以被测量
                //如果lengthComputable为false,就获取不到progressEvent.total和progressEvent.loaded
                callback(progressEvent)
            }
        }
    }
    return new Promise((resolve, reject) => {
        http.post(http.adornUrl(url), http.adornParams(params), config)
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}
export default http;

添加mock数据

const Mock = require('mockjs')

// 获取 mock.Random 对象
const Random = Mock.Random
// mock新闻数据,包括新闻标题title、内容content、创建时间createdTime
const produceNewsData = function () {
    let newsList = []
    for (let i = 0; i < 3; i++) {
        let newNewsObject = {}
        if(i === 0){
            newNewsObject.path = '/add/article';
            newNewsObject.name  = 'add-article';
            newNewsObject.component = 'modules/add/article/article';
        }
        if(i === 1){
            newNewsObject.path = '/detail/article';
            newNewsObject.name  = 'detail-article';
            newNewsObject.component = 'modules/detail/article/article'
        }
        if(i === 2){
            newNewsObject.path = '/edit/article';
            newNewsObject.name  = 'edit-article';
            newNewsObject.component = 'modules/edit/article/article'
        }
        newsList.push(newNewsObject)
    }
    return newsList;
}
Mock.mock('/mock/menu', produceNewsData)

配置全局less

pluginOptions: {
    // 配置全局less
    'style-resources-loader': {
        preProcessor: 'less',
        patterns: [resolve('./src/style/theme.less')]
    }
}

只打包改变的文件

安装cnpm i webpack -D

const { HashedModuleIdsPlugin } = require('webpack');
configureWebpack: config => {    
    const plugins = [];
    plugins.push(
        new HashedModuleIdsPlugin()
    )
}

开启分析打包日志

安装cnpm i webpack-bundle-analyzer -D

chainWebpack: config => {
    config
        .plugin('webpack-bundle-analyzer')
        .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
}

完整代码

image.png

点击获取完整代码github

获取更多相关知识

横版二维码.png

查看原文

赞 56 收藏 42 评论 0

认证与成就

  • 获得 7 次点赞
  • 获得 14 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 12 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-06-26
个人主页被 340 人浏览