mahy50

mahy50 查看完整档案

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

个人动态

mahy50 收藏了文章 · 2019-06-08

鼠须管输入法 傻瓜版配置 - 基于 rime_pro 增强包

简要说明

安装步骤:
第一步 下载并安装鼠须管输入法
第二步 备份现有的配置,打开终端输入 cp -a ~/Library/Rime ~/Library/Rime_ori_$(date +%Y%m%d%H%M%S) 会得到一个类似 ~/Library/Rime_ori_20170501225630 的文件夹 即为备份
第三步 下载并解压出 rime_pro 增强包 里的文件复制到 ~/Library/Rime 文件夹 覆盖默认文件
第四步 切换到鼠须管输入法,重新部署(约1分钟)

软件介绍:
以下是软件介绍。说明了鼠须管输入法的特点、rime_pro 增强包的作用和效果。rime_pro 增强包是鼠须管输入法的第三方增强包,仅由我业余爱好制作,和原版软件作者无关。如果想要卸载本增强包,那么把刚刚备份的 Rime_ori_20170501225630 文件夹 重命名为 Rime 即可。

鼠须管输入法 - 一个 OS X 平台上的开源输入法软件

鼠须管输入法是一个 OS X 平台的输入法软件,以功能强大和配置专业而著称,带来了极佳的文字输入体验,为众多用户推崇。本文是一个“傻瓜版”的配置办法 / 教程,旨在用简单的办法实现(准确地说是配置出)强大的功能,并保持自定义词库的可扩展性。

rime_pro 增强包,不折腾

鼠须管输入法是一个优秀的输入法软件,秉承开源软件精神,不仅支持一般输入法软件的除了云同步之外的所有功能,还支持强大的自定义配置。

鼠须管输入法不具备云同步词库功能,同时也不会上传你的文字输入历史,是完全断网操作的软件。

鼠须管输入法官方默认配置,实现了常用的最基础功能。通常在使用之前,需要(必须)自己动手(修改yaml格式的程序和输入法方案配置文件)自定义一些配置,把鼠须管配得更 “顺手”。这需要一点点代码知识。

rime_pro 增强包,让你在无需代码知识的情况下,也可快速上手使用鼠须管输入法。

rime_pro 增强包(非官方) 是鼠须管输入法软件的一个 预制的配置,已配好常用功能,在常用功能上完全可替代其他品牌输入法 (如搜狗输入法、百度输入法)。

安完直接用,不用再配置(所谓傻瓜版,也即不需要代码知识),即下载后可直接使用,免去逐条逐项自定义配置的繁琐和出错;也可作为后续自定义配置软件的基础 (自定义配置,请参考压缩包里的 f-customize.txt 一份简明的配置说明 )。

鼠须管输入法:

  • 开源软件

  • 一切输入法相关配置均可自定义

  • 记忆本机输入习惯作为词库积累

  • 支持词库的导入,实现词语联想

  • 支持输入法基本配置:候选词个数、候选词方向、中英切换快捷键、翻页快捷键、面板配色方案 / 皮肤、是否开启内嵌输入、拼音与五笔切换等

  • 支持双拼 (小鹤双拼)、五笔

  • 支持繁體輸入,更智能的繁體輸入

  • 支持方言语系输入(参考一参考二)

  • 支持自定义标点符号输入习惯,可启用「经典中文标点」

  • 支持更多功能:开启 emoji 和符号快捷输入、静默模式等功能,支持词语快捷输入(自动拼写更正、词语联想、中英混输)、支持添加个性词组、自定义配色方案等

  • 更多专业细节配置,是本软件强大所在,也是自定义配置的基础,见官方帮助文档

  • 配置方法:直接修改对应的 yaml 格式配置文件

  • 输入法中的输入法:配置完毕后,在常用功能上它丝毫不弱于百度输入法、搜狗输入法等;在隐私问题上它的开源保障了其安全性可靠性;在特殊输入需求方面(如经典中文标点、自定义短语、粤语输入、古音输入、输入码反查等),它会带给你惊喜

rime_pro 增强包(非官方):

  • 一份预设配置,常用功能已默认开启并配置好

  • 预置词库,强大的常用词联想

  • 自带 emoji ? + 常见符号输入 ⌘

  • 静默模式:输入法在某些应用下,默认用英文 (终端、Spotlight、Xcode等)

  • 安装简单:解压增强包,拷贝到 Rime 配置目录覆盖即可;拷贝完毕,即刻开用

  • 傻瓜版:安完直接用,不必再配置;省略配逐项置的繁琐,不需任何代码知识,need not touch any code

  • 熟悉的标点符号输入习惯 (和其他输入法软件默认的标点符号规则一致)
    (以上默认配置可自己改,改动位置和办法见 f-customize.txt )

  • 更多功能:缩写补全、自动更正拼写、个性短语 (见下文测试)

  • 即开即用:默认候选词 5 个,候选词横向,中英切换 shift,翻页 -+ ,不启用内嵌输入

后续高级自定义配置 (when really need):

  • 个性短语,自定义添加

  • 后续添加自定义短语 (个性短语):可扩展的词库,可以方便地增加自己的自定义词组;主要添加在 f_myphrases.dict.yamlf_mysecretphrases.dict.yaml 这2个文件

  • 所有软件功能和个人使用习惯,均可自定义配置

  • 后续逐项基础配置:候选词个数、翻页快捷键、面板配色方案 / 皮肤、是否开启内嵌输入、静默模式所应用于的软件、标点符号输入习惯等功能,均可自己修改,见 f-customize.txt 可作参考

  • 后续逐项高级配置:增强包压缩文件包括词库文件、已经配置好的鼠须管配置文件;如想更改,可在此基础上自己动手;主要修改 default.custom.yamlsquirrel.custom.yaml 这2个文件即可 (这2个文件会直接覆盖具体输入法方案如luna_pinyin_simp.custom.yaml内的配置)

( 配置细节:如有需求,可以参考配置文件里的注释和配置说明来自行修改并重新部署,尝试更多的自定义。参考文章附后 )

安装和卸载

第一步 安装鼠须管软件
第二步 解压出 rime_pro 增强包 里的文件扔 ~/Library/Rime 覆盖默认文件
第三步 重新部署(约1分钟) all done ?

1.下载鼠须管并安装

官方网站 http://rime.im/download/
双击pkg安装包,在安装的最后一步,需要电脑注销一次

2. 备份鼠须管初始配置

鼠须管配置目录 ~/Library/Rime
cp -a ~/Library/Rime ~/Library/Rime_ori_$(date +%Y%m%d%H%M%S)
会得到一个类似 ~/Library/Rime_ori_20170501225630 的文件夹 即为备份

3.软件增强包

rime_pro 增强包(非官方)
下载链接、重新部署

使用办法:
下载软件增强包,并把里面的东西拷贝到 ~/Library/Rime ,覆盖即可。然后重新部署

说明:
rime_pro 增强包(非官方),包括了词库文件和软件配置文件。理论上对各个平台的 Rime输入法是通用的。
对于 ~/Library/Rime 下的配置文件,重新部署后生效

词库说明:增强包里的词库,是鼠须管原生词库的 *.dict.yaml 格式的词库,收集自网络(参考词库附后 —— 如果更好的词库分享,请给本文留言)。采用鼠须管原生词库,而非基于其他输入法的细胞词库转制出的超大词库,所以基本不会卡顿或拖慢软件速度

4.重新部署

点击屏幕右上处的输入法小图标,选用鼠须管输入法✓
点击鼠须管输入法图标,选择 重新部署 (约1分钟)
选择简体中文输入:ctrl+` 并选择 《朙月拼音·简化字》方案

现在,你已经用上 OS X 平台最棒的中文输入法了,而且输入法常用功能中的 99% 已悉听尊便 ?

5.卸载办法

如何干净地卸载鼠须管输入法?
say goodbye Squirrel && killall Squirrel
系统偏好设置 - 键盘 - 输入源 - 鼠须管,移除
sudo rm -rf "/Library/Input Methods/Squirrel.app"
rm -rf ~/Library/Rime

测试

让我们来看看 (基于 rime_pro 增强包的) 鼠须管输入法能做什么?

测试1

简体中文输入:ctrl+` 并选择 《朙月拼音·简化字》方案
繁体中文输入:ctrl+` 并选择 《朙月拼音·臺灣正體》方案

测试2 emoji

huojian => ?
chuzuche => ? ?
jiong => ??
siyecao => ?
syc => ?

测试3 符号输入

duigou => ✓
sheshidu => ℃
cmd => ⌘
shift => ⇧
opt => ⌥

测试4 自定义词组 (词库、句库、词组记忆、词组联想)

yikesaiting => 亦可赛艇
modalademaliya => 抹大拉的玛丽亚

测试5 快捷输入 (自动更正拼写、短语)

rime => Rime
html => HTML
nba => NBA
wiki => Wiki
osx => OS X

测试6 快捷输入 (缩写补全)

md => Markdown
gsw => Golden State Warriors
ror => Ruby on Rails

测试7 自定义短语 (个性短语)

gmail => example@gmail.com

见 ~/Library/Rime/f_mysecretphrases.dict.yaml ,可替换为你自己的邮箱,作为个性短语实现快捷输入。注意事项见后

测试8 在特定软件里 默认使用英文输入 (静默模式)

文本文档,输入默认是中文;Spotlight 或 终端,输入默认是英文
添加静默模式应用于的软件,见 squirrel.custom.yaml

其他自定义

面板配色方案 / 皮肤 (见 squirrel.custom.yaml)
标点符号输入习惯 (见 default.custom.yaml)
静默模式的软件 (见 squirrel.custom.yaml)

简繁切换

临时繁体输入:ctrl+` 选择 漢字
一直繁体输入:ctrl+` 选择 朙月拼音·臺灣正體

模糊音

打开 luna_pinyin_simp.custom.yamlspeller/algebra 相关位置开启 即去掉相关位置的注释即可。
比如 开启 - derive/^r/l/ 之后
ruguo => 如果
luguo => 如果

*注意:如果在使用老版本时发现模糊音无法生效
请找到 enable_charset_filter 这句选项

translator:
  enable_charset_filter: true #启用罕见字過濾

这2句会和模糊音的定义发生冲突。如果发现模糊音无法生效,那么请注释掉这2句并重新部署即可
( 当前版本已修复这个问题,是默认注释掉它们的 )

「标点」标点符号配置

标点符号配置在 ~/Library/Rime/default.custom.yaml 文件里
如果需要,可依你所需,设置为经典中文或英文标点,或即选标点
依注释稍加修改即可,重新部署生效

更多词:在必要时,添加自己的自定义短语 (个性短语)

rime_pro 增强包的特点之一:保持自定义词库的可扩展性
如果觉得有必要,测试2~7 均可由你实现自定义。这需要一点点代码知识
如果觉得有必要,添加个性短语到 ~/Library/Rime 目录的以下文件

f_myphrases.dict.yaml
f_mysecretphrases.dict.yaml

如果觉得有必要,个性短语 可以显式地写在这里,方便快捷输入 (如果觉得有必要写)
如果觉得有必要,按照文件内注释修改之后重新部署即可,比如添加一行

One Piece    op    10000
波特卡斯·D·艾斯    a    10000
萨博    s    10000
蒙奇·D·路飞    l    10000

注意:分隔符 tab
注意 op 前后不是多个空格,是一个分隔符 tab (建议用 系统自带的TextEditor 或 TextWrangler编辑器 打开,因为 像SublimeText编辑器 会自动把分隔符转化为空格)。这样就得到自定义的词组啦,重新部署之后测试快捷输入:
op => One Piece
l => 蒙奇·D·路飞 (翻几页可见;多输几次会自动调整词频并优先显示)

如果觉得有必要,再比如 如何打出 diany=>电影 ?
图片描述
图片描述
diany=>电影
类似的快捷输入均可稍改配置文件来显式地实现
这正是鼠须管的方式和高明之处:通过自定义配置实现自定义功能
更详尽原理,见官方Wiki用户指南

稍微方便一点点而已。如果觉得有必要 再修改这个文件(并重新部署)。

P.S. 为什么说 “如果觉得有必要” 呢?
因为对大多数人在大多数情况下, 做个性短语是没必要的
依靠软件自带的词频记忆功能即可,比如:
词语多输入几次 (而不用自定义词组这么麻烦) 软件就会自动记住
然后打首字母就能出来 (dy=>电影);这点上和其他输入法是一样的,省时省事

重要配置文件

default.custom.yaml
squirrel.custom.yaml
luna_pinyin_simp.custom.yaml
luna_pinyin_tw.custom.yaml
double_pinyin_flypy.custom.yaml

感谢

一切配置和探索,均基于 RIME 中州韻輸入法引擎 和 鼠须管输入法 自身的强大

All credit goes to RIME developers
RIME | 中州韻輸入法引擎 官方网站 rime.imGitHub
中州韻輸入法引擎和鼠须管输入法软件作者 输入法开发专家 佛振
RIME 翰林院 GitHubBintray教学网站

官方Wiki
https://github.com/rime/home/... (官方用户指南)
https://github.com/rime/home/... (官方介绍、必知必会)
https://github.com/rime/home/...設定項速查手冊 (設定項速查手冊、Schema.yaml 詳解)
https://github.com/rime/home/...中的數據文件分佈及作用 (配置目录及文件作用)
https://gist.github.com/lotem... (更多输入方案)

感谢前人经验和配置细节
http://crossingmay.com/2016/0... (基础配置, 需要一点点代码知识)
https://github.com/rime-aca/d... (词库扩充原理)
https://github.com/rime/home/...導入其他來源的碼表 (词库扩充原理和词库编辑办法)
https://github.com/rime/home/...碼表與詞典 (词库扩充原理)
https://medium.com/@scomper/鼠須管-的调教笔记-3fdeb0e78814 (基础配置和词库编辑办法)
https://gist.github.com/lotem... (词库编辑办法)
https://www.v2ex.com/t/58428 (emoji)
http://blog.yesmryang.net/rim... (颜文字)
https://gist.github.com/zolun... (颜文字)
https://github.com/hitigon/me... (颜文字)
https://github.com/rime/home/...一例定製簡化字輸出 (正體中文輸入)
http://blog.yesmryang.net/rim... (五笔)
https://medium.com/@scomper/鼠須管-的调教笔记-3fdeb0e78814 (模糊音)
https://www.v2ex.com/t/58428 (模糊音)
https://www.zhihu.com/questio... (卸载办法)
https://github.com/iHavee/rim... (同步)
https://segmentfault.com/a/11... (软件作者访谈)

https://gist.github.com/lotem... (鼠须管支持的输入法方案,含五笔)
https://github.com/osfans/tri...小知识%281%29---Yaml文件开头注释是什么意思? (YAML文件)
https://medium.com/@scomper/鼠須管-的调教笔记-3fdeb0e78814 (YAML文件编辑器注意事项)
https://github.com/rime/home/... (定制过程参考)
https://github.com/osfans/tri...经典资料汇总-菜鸟书评

感谢前人词库
https://code.google.com/archi...
https://github.com/rime-aca/d...
https://medium.com/@scomper/鼠須管-的调教笔记-3fdeb0e78814
https://www.v2ex.com/t/58428 (emoji)

详尽配置原理和配置说明 (延伸阅读)

基于 rime_pro 你可以进行你自己的折腾,对配置文件进行改造
发挥 DIY 精神,打造符合你的要求的输入法
延伸阅读以下文章可能会有所帮助
官方Wiki
官方Wiki定制指南
官方Wiki用户指南
官方Rime方案製作詳解

-

查看原文

mahy50 赞了文章 · 2019-03-08

浅谈使用 Vue 构建前端 10w+ 代码量的单页面应用开发底层

开始之前

随着业务的不断累积,目前我们 ToC 端主要项目,除去 node_modulesbuild 配置文件dist 静态资源文件的代码量为 137521 行,后台管理系统下各个子应用代码,除去依赖等文件的总行数也达到 100万 多一点。

代码量意味不了什么,只能证明模块很多,但相同两个项目,在运行时性能相同情况下,你的 10 万行代码能容纳并维护 150 个模块,并且开发顺畅,我的项目中 10 万行代码却只能容纳 100 个模块,添加功能也好,维护起来也较为繁琐,这就很值得思考

本文会在主要描述以 Vue 技术栈技术主体ToC 端项目业务主体,在构建过程中,遇到或者总结的点(也会提及一些 ToB 项目的场景),可能并不适合你的业务场景(仅供参考),我会尽可能多的描述问题与其中的思考,最大可能的帮助到需要的同学,也辛苦开发者发现问题或者不合理/不正确的地方及时向我反馈,会尽快修改,欢迎有更好的实现方式来 pr

Git 地址
React 项目

可以参考蚂蚁金服数据体验技术团队编写的文章:

本文并不是基于上面文章写的,不过当时在看到他们文章之后觉得有相似的地方,相较于这篇文章,本文可能会枯燥些,会有大量代码,同学可以直接用上仓库看。

① 单页面,多页面

首先要思考我们的项目最终的构建主体单页面,还是多页面,还是单页 + 多页,通过他们的优缺点来分析:

  • 单页面(SPA)

    • 优点:体验好,路由之间跳转流程,可定制转场动画,使用了懒加载可有效减少首页白屏时间,相较于多页面减少了用户访问静态资源服务器的次数等。
    • 缺点:初始会加载较大的静态资源,并且随着业务增长会越来越大,懒加载也有他的弊端,不做特殊处理不利于 SEO 等。
  • 多页面(MPA)

    • 优点:对搜索引擎友好,开发难度较低。
    • 缺点:资源请求较多,整页刷新体验较差,页面间传递数据只能依赖 URLcookiestorage 等方式,较为局限。
  • SPA + MPA

    • 这种方式常见于较老 MPA 项目迁移至 SPA 的情况,缺点结合两者,两种主体通信方式也只能以兼容MPA 为准
    • 不过这种方式也有他的好处,假如你的 SPA 中,有类似文章分享这样(没有后端直出,后端返 HTML 串的情况下),想保证用户体验在 SPA 中开发一个页面,在 MPA 中也开发一个页面,去掉没用的依赖,或者直接用原生 JS 来开发,分享出去是 MPA 的文章页面,这样可以加快分享出去的打开速度,同时也能减少静态资源服务器的压力,因为如果分享出去的是 SPA 的文章页面,那 SPA 所需的静态资源至少都需要去进行协商请求,当然如果服务配置了强缓存就忽略以上所说。

我们首先根据业务所需,来最终确定构建主体,而我们选择了体验至上的 SPA,并选用 Vue 技术栈。

② 目录结构

其实我们看开源的绝大部分项目中,目录结构都会差不太多,我们可以综合一下来个通用的 src 目录:

src
├── assets          // 资源目录 图片,样式,iconfont
├── components      // 全局通用组件目录
├── config          // 项目配置,拦截器,开关
├── plugins         // 插件相关,生成路由、请求、store 等实例,并挂载 Vue 实例
├── directives      // 拓展指令集合
├── routes          // 路由配置
├── service         // 服务层
├── utils           // 工具类
└── views           // 视图层

③ 通用组件

components 中我们会存放 UI 组件库中的那些常见通用组件了,在项目中直接通过设置别名来使用,如果其他项目需要使用,就发到 npm 上。

结构

// components 简易结构
components
├── dist
├── build
├── src      
    ├── modal
    ├── toast
    └── ...
├── index.js             
└── package.json        

项目中使用

如果想最终编译成 es5,直接在 html 中使用或者部署 CDN 上,在 build 配置简单的打包逻辑,搭配着 package.json 构建 UI组件 的自动化打包发布,最终部署 dist 下的内容,并发布到 npm 上即可。

而我们也可直接使用 es6 的代码:

import 'Components/src/modal'

其他项目使用

假设我们发布的 npm 包bm-ui,并且下载到了本地 npm i bm-ui -S:

修改项目的最外层打包配置,在 rules 里 babel-loaderhappypack 中添加 includenode_modules/bm-ui

// webpack.base.conf
...
    rules: [{
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
    },
    {
        test: /\.js$/,
        loader: 'babel-loader',
        // 这里添加
        include: [resolve('src'), resolve('test'), resolve('node_modules/bm-ui')]
    },{
    ...
    }]
...

然后搭配着 babel-plugin-import 直接在项目中使用即可:

import { modal } from 'bm-ui'

多个组件库

同时有多个组件库的话,又或者有同学专门进行组件开发的话,把 `components
内部细分`一下,多一个文件分层。

components
├── bm-ui-1 
├── bm-ui-2
└── ...

你的打包配置文件可以放在 components 下,进行统一打包,当然如果要开源出去还是放在对应库下。

④ 全局配置,插件与拦截器

这个点其实会是项目中经常被忽略的,或者说很少聚合到一起,但同时我认为是整个项目中的重要之一,后续会有例子说道。

全局配置,拦截器目录结构

config
├── index.js             // 全局配置/开关
├── interceptors        // 拦截器
    ├── index.js        // 入口文件
    ├── axios.js        // 请求/响应拦截
    ├── router.js       // 路由拦截
    └── ...
└── ...

全局配置

我们在 config/index.js 可能会有如下配置:

// config/index.js

// 当前宿主平台 兼容多平台应该通过一些特定函数来取得
export const HOST_PLATFORM = 'WEB'
// 这个就不多说了
export const NODE_ENV = process.env.NODE_ENV || 'prod'

// 是否强制所有请求访问本地 MOCK,看到这里同学不难猜到,每个请求也可以单独控制是否请求 MOCK
export const AJAX_LOCALLY_ENABLE = false
// 是否开启监控
export const MONITOR_ENABLE = true
// 路由默认配置,路由表并不从此注入
export const ROUTER_DEFAULT_CONFIG = {
    waitForData: true,
    transitionOnLoad: true
}

// axios 默认配置
export const AXIOS_DEFAULT_CONFIG = {
    timeout: 20000,
    maxContentLength: 2000,
    headers: {}
}

// vuex 默认配置
export const VUEX_DEFAULT_CONFIG = {
    strict: process.env.NODE_ENV !== 'production'
}

// API 默认配置
export const API_DEFAULT_CONFIG = {
    mockBaseURL: '',
    mock: true,
    debug: false,
    sep: '/'
}

// CONST 默认配置
export const CONST_DEFAULT_CONFIG = {
    sep: '/'
}

// 还有一些业务相关的配置
// ...


// 还有一些方便开发的配置
export const CONSOLE_REQUEST_ENABLE = true      // 开启请求参数打印
export const CONSOLE_RESPONSE_ENABLE = true     // 开启响应参数打印
export const CONSOLE_MONITOR_ENABLE = true      // 监控记录打印

可以看出这里汇集了项目中所有用到的配置,下面我们在 plugins 中实例化插件,注入对应配置,目录如下:

插件目录结构

plugins
├── api.js              // 服务层 api 插件
├── axios.js            // 请求实例插件
├── const.js            // 服务层 const 插件
├── store.js            // vuex 实例插件
├── inject.js           // 注入 Vue 原型插件
└── router.js           // 路由实例插件

实例化插件并注入配置

这里先举出两个例子,看我们是如何注入配置,拦截器并实例化的

实例化 router

import Vue from 'vue'
import Router from 'vue-router'
import ROUTES from 'Routes'
import {ROUTER_DEFAULT_CONFIG} from 'Config/index'
import {routerBeforeEachFunc} from 'Config/interceptors/router'

Vue.use(Router)

// 注入默认配置和路由表
let routerInstance = new Router({
    ...ROUTER_DEFAULT_CONFIG,
    routes: ROUTES
})
// 注入拦截器
routerInstance.beforeEach(routerBeforeEachFunc)

export default routerInstance

实例化 axios

import axios from 'axios'
import {AXIOS_DEFAULT_CONFIG} from 'Config/index'
import {requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc} from 'Config/interceptors/axios'

let axiosInstance = {}

axiosInstance = axios.create(AXIOS_DEFAULT_CONFIG)

// 注入请求拦截
axiosInstance
    .interceptors.request.use(requestSuccessFunc, requestFailFunc)
// 注入响应拦截
axiosInstance
    .interceptors.response.use(responseSuccessFunc, responseFailFunc)

export default axiosInstance

我们在 main.js注入插件

// main.js
import Vue from 'vue'

GLOBAL.vbus = new Vue()

// import 'Components'// 全局组件注册
import 'Directives' // 指令

// 引入插件
import router from 'Plugins/router'
import inject from 'Plugins/inject'
import store from 'Plugins/store'
// 引入组件库及其组件库样式 
// 不需要配置的库就在这里引入 
// 如果需要配置都放入 plugin 即可
import VueOnsen from 'vue-onsenui'
import 'onsenui/css/onsenui.css'
import 'onsenui/css/onsen-css-components.css'
// 引入根组件
import App from './App'

Vue.use(inject)
Vue.use(VueOnsen)

// render
new Vue({
    el: '#app',
    router,
    store,
    template: '<App/>',
    components: { App }
})

axios 实例我们并没有直接引用,相信你也猜到他是通过 inject 插件引用的,我们看下 inject

import axios from './axios'
import api from './api'
import consts from './const'
GLOBAL.ajax = axios
 
export default {
    install: (Vue, options) => {
        Vue.prototype.$api = api
        Vue.prototype.$ajax = axios
        Vue.prototype.$const = consts
        // 需要挂载的都放在这里
    }
}

这里可以挂载你想在业务中( vue 实例中)便捷访问的 api,除了 $ajax 之外,apiconst 两个插件是我们服务层中主要的功能,后续会介绍,这样我们插件流程大致运转起来,下面写对应拦截器的方法。

请求,路由拦截器

ajax 拦截器中(config/interceptors/axios.js):

// config/interceptors/axios.js

import {CONSOLE_REQUEST_ENABLE, CONSOLE_RESPONSE_ENABLE} from '../index.js'

export function requestSuccessFunc (requestObj) {
    CONSOLE_REQUEST_ENABLE && console.info('requestInterceptorFunc', `url: ${requestObj.url}`, requestObj)
    // 自定义请求拦截逻辑,可以处理权限,请求发送监控等
    // ...
    
    return requestObj
}

export function requestFailFunc (requestError) {
    // 自定义发送请求失败逻辑,断网,请求发送监控等
    // ...
    
    return Promise.reject(requestError);
}

export function responseSuccessFunc (responseObj) {
    // 自定义响应成功逻辑,全局拦截接口,根据不同业务做不同处理,响应成功监控等
    // ...
    // 假设我们请求体为
    // {
    //     code: 1010,
    //     msg: 'this is a msg',
    //     data: null
    // }
    
    let resData =  responseObj.data
    let {code} = resData
    
    switch(code) {
        case 0: // 如果业务成功,直接进成功回调  
            return resData.data;
        case 1111: 
            // 如果业务失败,根据不同 code 做不同处理
            // 比如最常见的授权过期跳登录
            // 特定弹窗
            // 跳转特定页面等
            
            location.href = xxx // 这里的路径也可以放到全局配置里
            return;
        default:
            // 业务中还会有一些特殊 code 逻辑,我们可以在这里做统一处理,也可以下方它们到业务层
            !responseObj.config.noShowDefaultError && GLOBAL.vbus.$emit('global.$dialog.show', resData.msg);
            return Promise.reject(resData);
    }
}

export function responseFailFunc (responseError) {
    // 响应失败,可根据 responseError.message 和 responseError.response.status 来做监控处理
    // ...
    return Promise.reject(responseError);
}

定义路由拦截器(config/interceptors/router.js):

// config/interceptors/router.js

export function routerBeforeFunc (to, from, next) {
    // 这里可以做页面拦截,很多后台系统中也非常喜欢在这里面做权限处理
    
    // next(...)
}

最后在入口文件(config/interceptors/index.js)中引入并暴露出来即可:

import {requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc} from './ajax'
import {routerBeforeEachFunc} from './router'

let interceptors = {
    requestSuccessFunc,
    requestFailFunc,
    responseSuccessFunc,
    responseFailFunc,
    routerBeforeEachFunc
}

export default interceptors

请求拦截这里代码都很简单,对于 responseSuccessFunc 中 switch default 逻辑做下简单说明:

  1. responseObj.config.noShowDefaultError 这里可能不太好理解

我们在请求的时候,可以传入一个 axios 中并没有意义的 noShowDefaultError 参数为我们业务所用,当值为 false 或者不存在时,我们会触发全局事件 global.dialog.showglobal.dialog.show我们会注册在 app.vue 中:

// app.vue

export default {
    ...
    created() {
        this.bindEvents
    },
    methods: {
        bindEvents() {
            GLOBAL.vbus.$on('global.dialog.show', (msg) => {
                if(msg) return
                // 我们会在这里注册全局需要操控试图层的事件,方便在非业务代码中通过发布订阅调用
                this.$dialog.popup({
                    content: msg 
                });
            })
        }
        ...
    }
}
这里也可以把弹窗状态放入 Store 中,按团队喜好,我们习惯把公共的涉及视图逻辑的公共状态在这里注册,和业务区分开来
  1. GLOBAL 是我们挂载 window 上的全局对象,我们把需要挂载的东西都放在 window.GLOBAL 里,减少命名空间冲突的可能。
  2. vbus 其实就是我们开始 new Vue() 挂载上去的
GLOBAL.vbus = new Vue()
  1. 我们在这里 Promise.reject 出去,我们就可以在 error 回调里面只处理我们的业务逻辑,而其他如断网超时服务器出错等均通过拦截器进行统一处理。

拦截器处理前后对比

对比下处理前后在业务中的发送请求的代码

拦截器处理前

this.$axios.get('test_url').then(({code, data}) => {
    if( code === 0 ) {
        // 业务成功
    } else if () {}
        // em... 各种业务不成功处理,如果遇到通用的处理,还需要抽离出来
    
    
}, error => {
   // 需要根据 error 做各种抽离好的处理逻辑,断网,超时等等...
})

拦截器处理后

// 业务失败走默认弹窗逻辑的情况
this.$axios.get('test_url').then(({data}) => {
    // 业务成功,直接操作 data 即可
})

// 业务失败自定义
this.$axios.get('test_url', {
    noShowDefaultError: true // 可选
}).then(({data}) => {
    // 业务成功,直接操作 data 即可
    
}, (code, msg) => {
    // 当有特定 code 需要特殊处理,传入 noShowDefaultError:true,在这个回调处理就行
})

为什么要如此配置与拦截器?

在应对项目开发过程中需求的不可预见性时,让我们能处理的更快更好

到这里很多同学会觉得,就这么简单的引入判断,可有可无,
就如我们最近做的一个需求来说,我们 ToC 端项目之前一直是在微信公众号中打开的,而我们需要在小程序中通过 webview 打开大部分流程,而我们也没有时间,没有空间在小程序中重写近 100 + 的页面流程,这是我们开发之初并没有想到的。这时候必须把项目兼容到小程序端,在兼容过程中可能需要解决以下问题:

  1. 请求路径完全不同。
  2. 需要兼容两套不同的权限系统。
  3. 有些流程在小程序端需要做改动,跳转到特定页面。
  4. 有些公众号的 api ,在小程序中无用,需要调用小程序的逻辑,需要做兼容。
  5. 很多也页面上的元素,小程序端不做展示等。
可以看出,稍微不慎,会影响公众号现有逻辑。
  • 添加请求拦截 interceptors/minaAjax.jsinterceptors/minaRouter.js,原有的换更为 interceptors/officalAjax.jsinterceptors/officalRouter.js,在入口文件interceptors/index.js根据当前宿主平台,也就是全局配置 HOST_PLATFORM,通过代理模式策略模式,注入对应平台的拦截器minaAjax.js中重写请求路径和权限处理,在 minaRouter.js 中添加页面拦截配置,跳转到特定页面,这样一并解决了上面的问题 1,2,3
  • 问题 4 其实也比较好处理了,拷贝需要兼容 api 的页面,重写里面的逻辑,通过路由拦截器一并做跳转处理
  • 问题 5 也很简单,拓展两个自定义指令 v-mina-show 和 v-mina-hide ,在展示不同步的地方可以直接使用指令。

最终用最少的代码,最快的时间完美上线,丝毫没影响到现有 toC 端业务,而且这样把所有兼容逻辑绝大部分聚合到了一起,方便二次拓展和修改。

虽然这只是根据自身业务结合来说明,可能没什么说服力,不过不难看出全局配置/拦截器 虽然代码不多,但却是整个项目的核心之一,我们可以在里面做更多 awesome 的事情。

⑤ 路由配置与懒加载

directives 里面没什么可说的,不过很多难题都可以通过他来解决,要时刻记住,我们可以再指令里面操作虚拟 DOM。

路由配置

而我们根据自己的业务性质,最终根据业务流程来拆分配置:

routes
├── index.js            // 入口文件
├── common.js           // 公共路由,登录,提示页等
├── account.js          // 账户流程
├── register.js         // 挂号流程
└── ...

最终通过 index.js 暴露出去给 plugins/router 实例使用,这里的拆分配置有两个注意的地方:

  • 需要根据自己业务性质来决定,有的项目可能适合业务线划分,有的项目更适合以 功能 划分。
  • 在多人协作过程中,尽可能避免冲突,或者减少冲突。

懒加载

文章开头说到单页面静态资源过大,首次打开/每次版本升级后都会较慢,可以用懒加载来拆分静态资源,减少白屏时间,但开头也说到懒加载也有待商榷的地方:

  • 如果异步加载较多的组件,会给静态资源服务器/ CDN 带来更大的访问压力的同时,如果当多个异步组件都被修改,造成版本号的变动,发布的时候会大大增加 CDN 被击穿的风险。
  • 懒加载首次加载未被缓存的异步组件白屏的问题,造成用户体验不好。
  • 异步加载通用组件,会在页面可能会在网络延时的情况下参差不齐的展示出来等。

这就需要我们根据项目情况在空间和时间上做一些权衡。

以下几点可以作为简单的参考:

  • 对于访问量可控的项目,如公司后台管理系统中,可以以操作 view 为单位进行异步加载,通用组件全部同步加载的方式。
  • 对于一些复杂度较高,实时度较高的应用类型,可采用按功能模块拆分进行异步组件加载。
  • 如果项目想保证比较高的完整性和体验,迭代频率可控,不太关心首次加载时间的话,可按需使用异步加载或者直接不使用。
打包出来的 main.js 的大小,绝大部分都是在路由中引入的并注册的视图组件。

⑥ Service 服务层

服务层作为项目中的另一个核心之一,“自古以来”都是大家比较关心的地方。

不知道你是否看到过如下组织代码方式:

views/
    pay/
        index.vue
        service.js
        components/
            a.vue
            b.vue

service.js 中写入编写数据来源

export const CONFIAG = {
    apple: '苹果',
    banana: '香蕉'
}
// ...

// ① 处理业务逻辑,还弹窗
export function getBInfo ({name = '', id = ''}) {
    return this.$ajax.get('/api/info', {
        name, 
        id
    }).then({age} => {
        this.$modal.show({
            content: age
        })
    })
}

// ② 不处理业务,仅仅写请求方法
export function getAInfo ({name = '', id = ''}) {
    return this.$ajax.get('/api/info', {
        name, 
        id
    })
}

...

简单分析:

  • ① 就不多说了,拆分的不够单纯,当做二次开发的时候,你还得去找这弹窗到底哪里出来的。
  • ② 看起来很美好,不掺杂业务逻辑,但不知道你与没遇到过这样情况,经常会有其他业务需要用到一样的枚举,请求一样的接口,而开发其他业务的同学并不知道你在这里有一份数据源,最终造成的结果就是数据源的代码到处冗余

我相信②在绝大多数项目中都能看到。

那么我们的目的就很明显了,解决冗余,方便使用,我们把枚举和请求接口的方法,通过插件,挂载到一个大对象上,注入 Vue 原型,方面业务使用即可。

目录层级(仅供参考)

service
├── api
    ├── index.js             // 入口文件
    ├── order.js             // 订单相关接口配置
    └── ...
├── const                   
    ├── index.js             // 入口文件
    ├── order.js             // 订单常量接口配置
    └── ...
├── store                    // vuex 状态管理
├── expands                  // 拓展
    ├── monitor.js           // 监控
    ├── beacon.js            // 打点
    ├── localstorage.js      // 本地存储
    └── ...                  // 按需拓展
└── ...

抽离模型

首先抽离请求接口模型,可按照领域模型抽离 (service/api/index.js):

{
    user: [{
        name: 'info',
        method: 'GET',
        desc: '测试接口1',
        path: '/api/info',
        mockPath: '/api/info',
        params: {
            a: 1,
            b: 2
        }
    }, {
        name: 'info2',
        method: 'GET',
        desc: '测试接口2',
        path: '/api/info2',
        mockPath: '/api/info2',
        params: {
            a: 1,
            b: 2,
            b: 3
        }
    }],
    order: [{
        name: 'change',
        method: 'POST',
        desc: '订单变更',
        path: '/api/order/change',
        mockPath: '/api/order/change',
        params: {
            type: 'SUCCESS'
        }
    }]
    ...
}

定制下需要的几个功能:

  • 请求参数自动截取。
  • 请求参数不传,则发送默认配置参数。
  • 得需要命名空间。
  • 通过全局配置开启调试模式。
  • 通过全局配置来控制走本地 mock 还是线上接口等。

插件编写

定制好功能,开始编写简单的 plugins/api.js 插件:

import axios from './axios'
import _pick from 'lodash/pick'
import _assign from 'lodash/assign'
import _isEmpty from 'lodash/isEmpty'

import { assert } from 'Utils/tools'
import { API_DEFAULT_CONFIG } from 'Config'
import API_CONFIG from 'Service/api'


class MakeApi {
    constructor(options) {
        this.api = {}
        this.apiBuilder(options)
    }


    apiBuilder({
        sep = '|',
        config = {},
        mock = false, 
        debug = false,
        mockBaseURL = ''
    }) {
        Object.keys(config).map(namespace => {
            this._apiSingleBuilder({
                namespace, 
                mock, 
                mockBaseURL, 
                sep, 
                debug, 
                config: config[namespace]
            })
        })
    }
    _apiSingleBuilder({
        namespace, 
        sep = '|',
        config = {},
        mock = false, 
        debug = false,
        mockBaseURL = ''
    }) {
        config.forEach( api => {
            const {name, desc, params, method, path, mockPath } = api
            let apiname = `${namespace}${sep}${name}`,// 命名空间
                url = mock ? mockPath : path,//控制走 mock 还是线上
                baseURL = mock && mockBaseURL
            
            // 通过全局配置开启调试模式。
            debug && console.info(`调用服务层接口${apiname},接口描述为${desc}`)
            debug && assert(name, `${apiUrl} :接口name属性不能为空`)
            debug && assert(apiUrl.indexOf('/') === 0, `${apiUrl} :接口路径path,首字符应为/`)

            Object.defineProperty(this.api, `${namespace}${sep}${name}`, {
                value(outerParams, outerOptions) {
                
                    // 请求参数自动截取。
                    // 请求参数不穿则发送默认配置参数。
                    let _data = _isEmpty(outerParams) ? params : _pick(_assign({}, params, outerParams), Object.keys(params))
                    return axios(_normoalize(_assign({
                        url,
                        desc,
                        baseURL,
                        method
                    }, outerOptions), _data))
                }
            })      
        })
    }       
}

function _normoalize(options, data) {
    // 这里可以做大小写转换,也可以做其他类型 RESTFUl 的兼容
    if (options.method === 'POST') {
        options.data = data
    } else if (options.method === 'GET') {
        options.params = data
    }
    return options
} 
// 注入模型和全局配置,并暴露出去
export default new MakeApi({
    config: API_CONFIG,
    ...API_DEFAULT_CONFIG
})['api']

挂载到 Vue 原型上,上文有说到,通过 plugins/inject.js

import api from './api'
 
export default {
    install: (Vue, options) => {
        Vue.prototype.$api = api
        // 需要挂载的都放在这里
    }
}

使用

这样我们可以在业务中愉快的使用业务层代码:

// .vue 中
export default {
    methods: {
        test() {
            this.$api['order/info']({
                a: 1,
                b: 2
            })
        }
    }
}

即使在业务之外也可以使用:

import api from 'Plugins/api'

api['order/info']({
    a: 1,
    b: 2
})

当然对于运行效率要求高的项目中,避免内存使用率过大,我们需要改造 API,用解构的方式引入使用,最终利用 webpacktree-shaking 减少打包体积。几个简单的思路

一般来说,多人协作时候大家都可以先看 api 是否有对应接口,当业务量上来的时候,也肯定会有人出现找不到,或者找起来比较费劲,这时候我们完全可以在 请求拦截器中,把当前请求的 urlapi 中的请求做下判断,如果有重复接口请求路径,则提醒开发者已经配置相关请求,根据情况是否进行二次配置即可。

最终我们可以拓展 Service 层的各个功能:

基础

  • api异步与后端交互
  • const常量枚举
  • storeVuex 状态管理

拓展

  • localStorage:本地数据,稍微封装下,支持存取对象即可
  • monitor监控功能,自定义搜集策略,调用 api 中的接口发送
  • beacon打点功能,自定义搜集策略,调用 api 中的接口发送
  • ...

constlocalStoragemonitorbeacon 根据业务自行拓展暴露给业务使用即可,思想也是一样的,下面着重说下 store(Vuex)

插一句:如果看到这里没感觉不妥的话,想想上面 plugins/api.js 有没有用单例模式?该不该用?

⑦ 状态管理与视图拆分

Vuex 源码分析可以看我之前写的文章

我们是不是真的需要状态管理?

答案是否定的,就算你的项目达到 10 万行代码,那也并不意味着你必须使用 Vuex,应该由业务场景决定。

业务场景

  1. 第一类项目:业务/视图复杂度不高,不建议使用 Vuex,会带来开发与维护的成本,使用简单的 vbus 做好命名空间,来解耦即可。
let vbus = new Vue()
vbus.$on('print.hello', () => {
    console.log('hello')
})

vbus.$emit('print.hello')
  1. 第二类项目:类似多人协作项目管理有道云笔记网易云音乐微信网页版/桌面版应用,功能集中,空间利用率高,实时交互的项目,无疑 Vuex 是较好的选择。这类应用中我们可以直接抽离业务领域模型
store
├── index.js          
├── actions.js        // 根级别 action
├── mutations.js      // 根级别 mutation
└── modules
    ├── user.js       // 用户模块
    ├── products.js   // 产品模块
    ├── order.js      // 订单模块
    └── ...

当然对于这类项目,vuex 或许不是最好的选择,有兴趣的同学可以学习下 rxjs

  1. 第三类项目:后台系统或者页面之间业务耦合不高的项目,这类项目是占比应该是很大的,我们思考下这类项目:

全局共享状态不多,但是难免在某个模块中会有复杂度较高的功能(客服系统,实时聊天,多人协作功能等),这时候如果为了项目的可管理性,我们也在 store 中进行管理,随着项目的迭代我们不难遇到这样的情况:

store/
    ...
    modules/
        b.js
        ...
views/
    ...
    a/
        b.js
        ...
        
  • 试想下有几十个 module,对应这边上百个业务模块,开发者在两个平级目录之间调试与开发的成本是巨大的。
  • 这些 module 可以在项目中任一一个地方被访问,但往往他们都是冗余的,除了引用的功能模块之外,基本不会再有其他模块引用他。
  • 项目的可维护程度会随着项目增大而增大。

如何解决第三类项目的 store 使用问题?

先梳理我们的目标:

  • 项目中模块可以自定决定是否使用 Vuex。(渐进增强)
  • 从有状态管理的模块,跳转没有的模块,我们不想把之前的状态挂载到 store 上,想提高运行效率。(冗余)
  • 让这类项目的状态管理变的更加可维护。(开发成本/沟通成本)

实现

我们借助 Vuex 提供的 registerModuleunregisterModule 一并解决这些问题,我们在 service/store 中放入全局共享的状态:

service/
    store/
        index.js
        actions.js
        mutations.js
        getters.js
        state.js
一般这类项目全局状态不多,如果多了拆分 module 即可。

编写插件生成 store 实例

import Vue from 'vue'
import Vuex from 'vuex'
import {VUEX_DEFAULT_CONFIG} from 'Config'
import commonStore from 'Service/store'

Vue.use(Vuex)

export default new Vuex.Store({
    ...commonStore,
    ...VUEX_DEFAULT_CONFIG
})

对一个需要状态管理页面或者模块进行分层:

views/
    pageA/
        index.vue
        components/
            a.vue
            b.vue
            ...
        children/
            childrenA.vue
            childrenB.vue
            ...
        store/
            index.js
            actions.js
            moduleA.js  
            moduleB.js

module 中直接包含了 gettersmutationsstate,我们在 store/index.js 中做文章:

import Store from 'Plugins/store'
import actions from './actions.js'
import moduleA from './moduleA.js'
import moduleB from './moduleB.js'

export default {
    install() {
        Store.registerModule(['pageA'], {
            actions,
            modules: {
                moduleA,
                moduleB
            },
            namespaced: true
        })
    },
    uninstall() {
        Store.unregisterModule(['pageA'])
    }
}

最终在 index.vue 中引入使用, 在页面跳转之前注册这些状态和管理状态的规则,在路由离开之前,先卸载这些状态和管理状态的规则

import store from './store'
import {mapGetters} from 'vuex'
export default {
    computed: {
        ...mapGetters('pageA', ['aaa', 'bbb', 'ccc'])
    },
    beforeRouterEnter(to, from, next) {
        store.install()
        next()
    },
    beforeRouterLeave(to, from, next) {
        store.uninstall()
        next()
    }
}

当然如果你的状态要共享到全局,就不执行 uninstall

这样就解决了开头的三个问题,不同开发者在开发页面的时候,可以根据页面特性,渐进增强的选择某种开发形式。

其他

这里简单列举下其他方面,需要自行根据项目深入和使用。

打包,构建

这里网上已经有很多优化方法:dllhappypack多线程打包等,但随着项目的代码量级,每次 dev 保存的时候编译的速度也是会愈来愈慢的,而一过慢的时候我们就不得不进行拆分,这是肯定的,而在拆分之前尽可能容纳更多的可维护的代码,有几个可以尝试和规避的点:

  1. 优化项目流程:这个点看起来好像没什么用,但改变却是最直观的,页面/业务上的化简为繁会直接体现到代码上,同时也会增大项目的可维护,可拓展性等。
  2. 减少项目文件层级纵向深度。
  3. 减少无用业务代码,避免使用无用或者过大依赖(类似 moment.js 这样的库)等。

样式

  • 尽可能抽离各个模块,让整个样式底层更加灵活,同时也应该尽可能的减少冗余。
  • 如果使用的 sass 的话,善用 %placeholder 减少无用代码打包进来。
MPA 应用中样式冗余过大,%placeholder 也会给你带来帮助。

Mock

很多大公司都有自己的 mock 平台,当前后端定好接口格式,放入生成对应 mock api,如果没有 mock 平台,那就找相对好用的工具如 json-server 等。

代码规范

请强制使用 eslint,挂在 git 的钩子上。定期 diff 代码,定期培训等。

TypeScript

非常建议用 TS 编写项目,可能写 .vue 有些别扭,这样前端的大部分错误在编译时解决,同时也能提高浏览器运行时效率,可能减少 re-optimize 阶段时间等。

测试

这也是项目非常重要的一点,如果你的项目还未使用一些测试工具,请尽快接入,这里不过多赘述。

拆分系统

当项目到达到一定业务量级时,由于项目中的模块过多,新同学维护成本,开发成本都会直线上升,不得不拆分项目,后续会分享出来我们 ToB 项目在拆分系统中的简单实践。

最后

时下有各种成熟的方案,这里只是一个简单的构建分享,里面依赖的版本都是我们稳定下来的版本,需要根据自己实际情况进行升级。

项目底层构建往往会成为前端忽略的地方,我们既要从一个大局观来看待一个项目或者整条业务线,又要对每一行代码精益求精,对开发体验不断优化,慢慢累积后才能更好的应对未知的变化。

最后请允许我打一波小小的广告

EROS

如果前端同学想尝试使用 Vue 开发 App,或者熟悉 weex 开发的同学,可以来尝试使用我们的开源解决方案 eros,虽然没做过什么广告,但不完全统计,50 个在线 APP 还是有的,期待你的加入。

最后附上部分产品截图~

(逃~)

查看原文

赞 146 收藏 139 评论 9

mahy50 关注了用户 · 2018-12-19

VioletJack @violetjack

专注于Vue前端开发的学习和分享

关注 75

mahy50 收藏了文章 · 2018-12-02

React源码之Diff算法

React框架使用的目的,就是为了维护状态,更新视图。

为什么会说传统DOM操作效率低呢?当使用document.createElement()创建了一个空的Element时,会需要按照标准实现一大堆的东西,如下图所示。此外,在对DOM进行操作时,如果一不留神导致回流,性能可能就很难保证了。

相比之下,JS对象的操作却有着很高的效率,通过操作JS对象,根据这个用 JavaScript 对象表示的树结构来构建一棵真正的DOM树,正是React对上述问题的解决思路。之前的文章中可以看出,使用React进行开发时, DOM树是通过Virtual DOM构造的,并且,React在Virtual DOM上实现了DOM diff算法,当数据更新时,会通过diff算法计算出相应的更新策略,尽量只对变化的部分进行实际的浏览器的DOM更新,而不是直接重新渲染整个DOM树,从而达到提高性能的目的。在保证性能的同时,使用React的开发人员就不必再关心如何更新具体的DOM元素,而只需要数据状态和渲染结果的关系。

传统的diff算法通过循环递归来对节点进行依次比较还计算一棵树到另一棵树的最少操作,算法复杂度为O(n^3),其中n是树中节点的个数。尽管这个复杂度并不好看,但是确实一个好的算法,只是在实际前端渲染的场景中,随着DOM节点的增多,性能开销也会非常大。而React在此基础之上,针对前端渲染的具体情况进行了具体分析,做出了相应的优化,从而实现了一个稳定高效的diff算法。

diff算法有如下三个策略:

  1. DOM节点跨层级的移动操作发生频率很低,是次要矛盾;

  2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构,这里也是抓前者放后者的思想;

  3. 对于同一层级的一组子节点,通过唯一id进行区分,即没事就warn的key。
    基于各自的前提策略,React也分别进行了算法优化,来保证整体界面构建的性能。

虚拟DOM树分层比较

两棵树只会对同一层次的节点进行比较,忽略DOM节点跨层级的移动操作。React只会对相同颜色方框内的DOM节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个DOM树的比较。由此一来,最直接的提升就是复杂度变为线型增长而不是原先的指数增长。

值得一提的是,如果真的发生跨层级移动(如下图),例如某个DOM及其子节点进行移动挂到另一个DOM下时,React是不会机智的判断出子树仅仅是发生了移动,而是会直接销毁,并重新创建这个子树,然后再挂在到目标DOM上。从这里可以看出,在实现自己的组件时,保持稳定的DOM结构会有助于性能的提升。事实上,React官方也是建议不要做跨层级的操作。因此在实际使用中,比方说,我们会通过CSS隐藏或显示某些节点,而不是真的移除或添加DOM节点。其实一旦接受了React的写法,就会发现前面所说的那种移动的写法几乎不会被考虑,这里可以说是React限制了某些写法,不过遵守这些实践确实会使得React有更好的渲染性能。如果真的需要有移动某个DOM的情况,或许考虑考虑尽量用CSS3来替代会比较好吧。

关于这一部分的源码,首先需要提到的是,React是如何控制“层”的。在许多源码阅读的文章里(搜到的讲的比较细的一般都是两三年前啦),都是说用一个updateDepth或者某种控制树深的变量来记录跟踪。事实上就目前版本来看,已经不是这样了(如果我没看错…)。ReactDOMComponent .updateComponent方法用来更新已经分配并挂载到DOM上的DOM组件,并在内部调用ReactDOMComponent._updateDOMChildren。而ReactDOMComponent通过_assign将ReactMultiChild.Mixin挂到原型上,获得ReactMultiChild中定义的方法updateChildren(事实上还有updateTextContent等方法也会在不同的分支里被使用,React目前已经对这些情形做了很多细化了)。ReactMultiChild包含着diff算法的核心部分,接下来会慢慢进行梳理。到这里我们暂时不必再继续往下看,可以注意prevChildren和nextChildren这两个变量,当然removedNodes、mountImages也是意义比较明显且很重要的变量:

prevChildren和nextChildren都是ReactElement,也就是virtual DOM,从它们的$$typeof: Symbol(react.element)就可看出;removedNodes保存删除的节点,mountImages则是保存对真实DOM的映射,或者可以理解为要挂载的真实节点,这些变量会随着调用栈一层层往下作为参数传下去并被修改和包装。

而控制树的深度的方法就是靠传入nextNestedChildrenElements,把整个树的索引一层层递归的传下去,同时传入prevChildren这个虚拟DOM,进入_reconcilerUpdateChildren方法,会在里面通过flattenChildren方法(当然里面还有个traverse方法)来访问我们的子树指针nextNestedChildrenElements,得到与prevChildren同层的nextChildren。然后ReactChildReconciler.updateChildren就会将prevChildren、nextChildren封装成ReactDOMComponent类型,并进行后续比较和操作。

至此,同层比较叙述结束,后面会继续讨论针对组件的diff和对元素本身的diff。

组件间的比较

参考官方文档及其他资料,可以讲组件间的比较策略总结如下:

  1. 如果是同类型组件,则按照原策略继续比较virtual DOM树;

  2. 如果不是,则将该组件判断为dirty component,然后整个unmount这个组件下的子节点对其进行替换;

  3. 对于同类型组件,virtual DOM可能并没有发生任何变化,这时我们可以通过shouldCompoenentUpdate钩子来告诉该组件是否进行diff,从而提高大量的性能。

这里可以看出React再次抓了主要矛盾,对于不同组件但结构相似的情形不再去关注,而是对相同组件、相似结构的情形进行diff算法,并提供钩子来进一步优化。可以说,对于页面结构基本没有变化的情况,确实是有着很大的优势。

元素间的比较

这一节算是diff算法最核心的部分,我会尝试着对算法的思想进行分析,并结合自己的demo来增进理解。

例子很简单,是一个涉及到新集合中有新加入的节点且老集合存在需要删除的节点的情形。如下图所示。

也就是说,通过点击来控制文字和数字的显示与消失。这种JSX可以说是太常用了。正好借学习diff算法的机会,来看看就这种最基本的结构,React是怎么做的。

首先先在ReactMultiChild中的_updateChildren中打上第一个debugger。

断点之前的代码会得到prevChildren和nextChildren,他们经过处理会从ReactElement数组变成一个奇怪的对象,key为“.0”、“.1”这样的带点序号(这里不妨先多说一句,这是React为一个个组件们默认分配的key,如果这里我强行设置一个key给h2h3标签,那么它就会拥有如’$123’这样的key),值为ReactDOMComponent 组件,前面写初次渲染的文章中提到过ReactDOMComponent就是最终渲染到DOM之前的那一环。而在本demo中,prevChildren存放着“哈哈哈的h1标签”和“142567的h3标签”,而nextChildren存放着“哈哈哈的h1标签”和“你好啊的h2标签”。

先不看若干index变量,看到for循环的in写法,即可明白是在遍历存放了新的ReactDOMComponent的对象,并且通过hasOwnProperty来过滤掉原型上的属性和方法。接着各自拿到同层节点的第一个,并对二者进行比较。如果相同,则enqueue一个moveChild方法返回的type为MOVE_EXISTING的对象到updates里,即把更新放入一个队列,moveChild也就是移动已有节点,但是是否真的移动会根据整体diff算法的结果来决定(本例当然是没移动了),然后修改若干index量;否则,就会计算一堆index(这里其实是算法的核心,此处先不细说),然后再次enqueue一个update,事实上是一个type属性为INSERT_MARKUP的对象。对于本例而言,h1标签不变,则会先来一个MOVE_EXISTING对象,然后h3变h2,再来一个INSERT_MARKUP,然后通过ReactReconciler.getHostNode根据nextChild得到真实DOM。

这个for-in结束后,则是会把需要删除的节点用enqueue的方法继续入队unmount操作,这里this._unmountChild返回的是REMOVE_NODE对象,至此,整个更新的diff流程就走完了,而updates保存了全部的更新队列,最终由processQueue来挨个执行更新。

那么细节在哪里?慢慢来。

首先,React为同层节点比较提供了若干操作。早期版本有INSERT_MARKUP、MOVE_EXISTING、REMOVE_NODE这三个增、移、删操作,现在又加入了SET_MARKUP和TEXT_CONTENT这俩操作。

INSERT_MARKUP,新的component类型(nextChildren里的)不在老集合(prevChildren)里,即是全新的节点,需要对新节点执行插入操作;

MOVE_EXISTING,在老集合有新component类型,且element是可更新的类型,这种情况下prevChild===nextChild,就需要做移动操作,可以复用以前的DOM节点。

REMOVE_NODE,老component类型在新集合里也有,但对应的element不同则不能直接复用和更新,需要执行删除操作;或者老component不在新集合里的,也需要执行删除操作。

所有的操作都会通过enqueue来入队,把更新细节隐藏,而如何判断做出何种更新操作,则是diff算法之所在。我们回到前面的代码重新再看,并分情况讨论其中的原理。

代码分析

首先对新集合的节点(nextChildren)进行in循环遍历,通过唯一的key(这里是变量name,前面提到过nextChildren和prevChildren是以对象的形式存储ReactDOMComponent的)可以取得新老集合中相同的节点,如果不存在,prevChildren即为undefined。根据图中代码,如果存在相同节点,也即prevChild === nextChild,则进行移动操作,但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,见moveChild函数,如下图:

if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。这是一种顺序优化手段,lastIndex一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比lastIndex大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比lastIndex小时,才需要进行移动操作。

新老集合节点相同、只需要移动的情形

图是直接拷来的…画那么好我就不重复画轮子了。还是源码,就按上面的图来讲。

源码中会开始对nextChildren(即新的节点状态 对象形式)进行遍历,并且对象本身是以键值对的形式存储这些节点的状态。首先,key=’b’时,通过prevChildren[name]的方式(name即为key)取老集合节点中是否存在key为b的节点,显然,如果存在,则取得,不存在,则为undefined。然后,判断是否相等。当我们两个key值相同的B节点被判定相等时,enqueue一个’ MOVE_EXISTING’操作。这一操作内部会作如下判断:

child即为prevChild,也就是判断B._mountIndex < lastIndex,lastIndex是prevChildren最近访问的最新index,初始为0(其实因为这些个children都是对象,所以index更多的是计数而非下标)。这里,B._mountIndex=1,lastIndex为0,所以不做移动操作更新。然后更新lastIndex,如下图所示:

我们知道prevChild就是B,则prevChild._mountIndex如前所示为1,所以lastIndex更新为1,这样lastIndex就可以记录着prevChildren中最后访问的那个的序号。再然后,更新B的位置为信集合中的位置:

nextIndex随着nextChildren中遍历的子元素递增,此时为1,也就是说,把B的挂载位置设置为0,就相当于告诉B你的位置从1移动到了0。

最后更新nextIndex,准备为下一个放在位置1的元素准备序号。这里getHostNode方法会返回一个真正的DOM,它主要是给enqueue使用,可以理解为开始执行更新队列时能让React知道这些更新的节点要放到的DOM的位置。

第二轮,从新集合取到A,判断到老集合中存在相同节点,同样是对比位置来判断是否进行移动操作。只不过,这一次A._mountIndex=0,lastIndex在上一轮更新为1,满足child._mountIndex<lastIndex的条件,于是enqueue移动操作。

其中toIndex就是nextIndex,目前为1,很正确嘛。然后继续更新lastIndex为1,并更新A._mountIndex=1,然后后续基本一致。
剩下两轮判断,不出上述情形。在此不再细表。

存在需要插入、删除节点的情形

还是拿了大佬的图,哈哈。这里其实就是更完整的情形,也就会涉及到整个代码流程,当然也并不复杂。
首先,还是从新集合先取到B,判断出老集合中有B,于是本轮与上面的第一轮就一样了(同一段代码嘛)。
第二轮,从新集合取到E,但是老集合中不存在,于是走入新流程:

讲白了,就是enqueue来创建节点到指定位置,然后更新E的位置,并nextIndex++来进入下一个节点的执行。

第三轮,从新集合取到C,C在老集合中有,但是判断之后并不进行移动操作,继续各种更新然后进入下一个节点的判断。

第四轮,从新集合中取到A,A也存在,所以enqueue移动操作。

至此,diff已经完成,这之后会对removedNodes进行循环遍历,这个对象是在this._reconcilerUpdateChildren就对比新老集合得到的。

这样一来,新集合中不存在的D也就被清除了。整体上看,是先创建,后删除的方式。

Ok,差不多啦,diff算法的核心就是这么回事啦。

总结

  1. 通过diff策略,将算法从O(n^3)简化为O(n)

  2. 分层求异,对tree diff进行优化

  3. 分组件求异,相同类生成相似树形结构、不同类生成不同树形结构,对component diff进行优化

  4. 设置key,对element diff进行优化

  5. 尽量保持稳定的DOM结构、避免将最后一个节点移动到列表首部、避免节点数量过大或更新过于频繁

补充

官方文档
Keys should be stable, predictable, and unique. Unstable keys (like those produced by Math.random() will cause many component instances and DOM nodes to be unnecessarily recreated, which can cause performance degradation and lost state in child components.

查看原文

mahy50 赞了文章 · 2018-11-21

讲清楚之javascript作用域

什么是作用域(Scope)?

作用域产生于程序源代码中定义变量的区域,在程序编码阶段就确定了。javascript 中分为全局作用域(Global context: window/global )和局部作用域(Local Scope , 又称为函数作用域 Function context)。简单讲作用域就是当前函数的生成环境或者上下文,包含了当前函数内定义的变量以及对外层作用域的引用。

作用域:

作用域(Scope)-
window/global Scope全局作用域
function Scope函数作用域
Block Scope块作用域(ES6)
eval Scopeeval作用域

作用域定义了一套规则,这套规则定义了引擎如何在当前作用域或嵌套作用域根,据标识符来查询变量。反过来说N个作用域组成的作用域链决定了函数作用域内标识符查找后返回的值。

所以作用域确定了当前上下文内定义的变量的可见性,即子作用域可以访问到当前作用域内属性、函数。并且作用域链(Scope Chain)也确定了在当前上下文中查找标识符后返回的值。

图片描述

Scope分为Lexical Scope和Dynamic Scope。Lexical Scope正如字面意思,即词法阶段定义的Scope。换种说法,作用域是根据源代码中变量和块的位置,在词法分析器(lexer)处理源代码时设置。javascript 采用的就是词法作用域。

作用域规则

作用域限制了函数内变量、函数的可访问性。在函数内部申明的属性、函数属于该函数的私有属性,不对函数外部代码暴露,同时函数内部申明的嵌套函数继承了对当前函数内属性、函数的访问权。具体规则如下:

  • 如果变量 a 在函数内部定义, 则函数内部其他变量具有访问变量 a 的权限,但是函数外部代码没有访问变量 a 的权限。所以同一作用域内变量可以相互访问,即 a、b、c 在同一个作用域他们就可以相互访问。这就像鸡妈妈有宝宝,鸡宝宝可以相互打闹,其他鸡就不能跟他们打闹了,为什么? 因为鸡妈妈不容许~ o(^∀^)o 。
let a = 1
function foo () {
    let b = 1 + a
    let c = 2
    console.log(b) // 2
}
console.log(c) // error 全局作用无法访问到 c
foo()
  • 如果变量 a 在全局作用域下定义(window/global),则全局作用域下的局部作用域内的执行代码或者说是表达式都可以访问到变量 a 的值。局部变量里的同名变量(a)会截断对全局变量 a 的访问。(这里的变量 a 就相当于是饲养员,候饲养员会在合适的时候给鸡儿们投食。但是农场主为了节约成本,规定饲养员要就近给鸡投食,当饲养员1离鸡宝宝更近时其他饲养员就不能千里迢迢跨过鸭绿江去喂鸡了。)
let a = 1
let b = 2
function foo () {
    let b = 3
    function too () {
        console.log(a) // 1
        console.log(b) // 3
    }
    too()
}
foo()

再次强调 javascript 作用域会严格限制变量的可访问范围: 即根据源代码中代码和块的位置,嵌套作用域拥有对被嵌套作用域(外层作用域)的访问权限。(这一条规则说明整个农场是有规则的,不能反向的投食。)

作用域链(Scope Chain)

作用域链,是由当前环境与上层环境的一系列作用域共同组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

上面解释的稍微有些晦涩,对于我这样大脑不好使的就需要在大脑里重复的'读'几次才能明白。那么作用域链是干嘛的? 简单的说作用域链就是管理函数申明是形成的作用域嵌套(依赖)关系,并在函数运行阶段解析函数访问标识符的

再简单点解释作用域链是干嘛的:作用域链就是用来查找变量的,作用域链是由一系列作用域串联起来的。

作用域链的访问

在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程,以决定从哪里获取和存储数据。该过程从作用域链头部,也就是当前执行函数的作用域开始(下图中从左向右),查找同名的标识符,如果找到了就返回这个标识符对应的值,如果没找到继续搜索作用域链中的下一个作用域,如果搜索完所有作用域都未找到,则认为该标识符未定义。函数执行过程中,每个标识符值得解析都要经历这样的搜索过程。

图片描述
为了具象化分析问题,我们可以假设作用域链是一个数组(Scope Array),数组成员有一系列变量对象组成。我们可以在数组这个单向通道中,也就是上图模拟从左向右查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。直到最顶层(全局作用域),并且一旦找到,即停止查找。所以内层的变量可以屏蔽外层的同名变量。想象一下如果变量不是按从内向外的查找,那整个语言设计会变得N复杂了(我们需要设计一套复杂的鸡宝宝找食物的规则)

还是上面的栗子:

let a = 1
let b = 2
function foo () {
    let b = 3
    function too () {
        console.log(a) // 1
        console.log(b) // 3
    }
    too()
}
foo()

作用域嵌套结构是这样的:

图片描述

栗子中,当 javascript 引擎执行到函数 too 时, 全局、函数 foo、函数 too 的上下文分别会被创建。上下文内包含它们各自的变量对象和作用域链(注意: 作用域链包含可访问到的上层作用域的变量对象,在上下文创建阶段根据作用域规则被收集起来形成一个可访问链),我们设定他们的变量对象分别为VO(global),VO(foo), VO(too)。而 too 的作用域链,则同时包含了这三个变量对象,所以 too 的执行上下文可如下表示:

too = {
    VO: {...},  // 变量对象
    scopeChain: [VO(too), VO(foo), VO(global)], // 作用域链
}

我们直接用scopeChain来表示作用域链数组,数组的第一项scopeChain[0]为作用域链的最前端(当前函数的变量对象),而数组的最后一项,为作用域链的最末端(全局变量对象 window )。注意,所有作用域链的最末端都为全局变量对象。

再举个栗子:

let a = 1
function foo() {
    console.log(a)
}
function too() {
    let a = 2
    foo()
}
too() // 1

这个栗子如果对作用域的特点理解不透彻很容易以为输出是2。但其实最终输出的是 1。 foo() 在执行的时候先在当前作用域内查找变量 a 。然后根据函数定义时的作用域关系会在当前作用域的上层作用域里查找变量标识符 a,所以最后查到的是全局作用域的 a 而不是 foo函数里面的 a 。

变量对象、执行上下文会在后面介绍。

闭包

JavaScript中,函数和函数声明时的词法作用域形成闭包。或者更通俗的理解为闭包就是能够读取其他函数内部变量的函数,这里把闭包理解为函数内部定义的函数。

我们来看个闭包的例子

let a = 1
function foo() {
  let a = 2
  function too() {
    console.log(a)
  }
  return too
}
foo()() // 2

这是一个闭包的栗子,一个函数执行后返回另一个可执行函数,被返回的函数保留有对它定义时外层函数作用域的访问权。foo()() 调用时依次执行了 foo、too 函数。too 虽然是在全局作用域里执行的,但是too定义在 foo 作用域里面,根据作用域链规则取最近的嵌套作用域的属性 a = 2。

再拿农场的故事做比如。农场主发现还有一种方法会更节约成本,就是让每个鸡妈妈作为家庭成员的‘饲养员’, 从而改变了之前的‘饲养结构’。

从作用域链的结构可以发现,javascript引擎在查找变量标识符时是依据作用域链依次向上查找的。当标识符所在的作用域位于作用域链的更深的位置,读写的时候相对就慢一些。所以在编写代码的时候应尽量少使用全局代码,尽可能的将全局的变量缓存在局部作用域中。

不加强记忆很容记错作用域与执行上下文的区别。代码的执行过程分为编译阶段和解释执行阶段。始终应该记住javascript作用域在源代码的编码阶段就确定了,而作用域链是在编译阶段被收集到执行上下文的变量对象里的。所以作用域、作用域链都是在当前运行环境内代码执行前就确定了。这里暂且不过多的展开执行上下文的概念,可以关注后续文章。

闭包的一些优缺点

闭包的用处:

  • 用于保存私有属性:将不需要对外暴露的属性、函数保存在闭包函数父函数里,避免外部操作对值的干扰
  • 避免局部属性污染全局变量空间导致的命名空间混乱
  • 模块化封装,将对立的功能模块通过闭包进去封装,只暴露较少的 API 供外部应用使用

闭包的缺点:

  • 内存消耗:由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题。
  • 导致内存泄露:由于IE的 js 对象和 DOM 对象使用不同的垃圾收集方法,因此闭包在IE中会导致内存泄露问题,也就是无法销毁驻留在内存中的元素。解决方法是,在退出函数之前,将不使用的局部变量全部删除)。
编译阶段和解释执行阶段会在变量对象一节详细介绍。

关于闭包会的一些其他知识点在后面的章节里也会有提及,尽请关注。

思考

最后,再来看一个面试题:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

// 5 5 5 5 5

要求对上面的代码进行修改,使其输出'0 1 2 3 4'

这里也涉及到作用域链的概念,当然跟 javascript 的执行机制也有关。修改方式有很多种,下面给出一种:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }(i), 1000);
}

// 0 1 2 3 4

详细原理分析会在javascript 执行机制一节详细介绍。

查看原文

赞 6 收藏 3 评论 0

mahy50 回答了问题 · 2018-11-15

解决node.js 如何完美的从命令行接收参数所传递进来的值

evn.js

#!/usr/bin/env node

// 简单版本, 根据需要自己修改下
function Program() {
  let $argvs = []
  let _this = this
  
  this.options = function (argv) {
    $argvs.push(argv)
    return this
  }
  this.getOptions = function () {
    console.log($argvs.toString());
  }
  this.parse = function (processArgv) {
    $argvs.forEach(item => {
      let option = item.toLocaleLowerCase()
      _this[option] = (function () {
        let index = processArgv.indexOf(option)
        if (index === 2) {
          return true
        } else if (index  !== -1) {
          console.log('您需要的参数应该是执行文件后的第一个参数');
          return false
        } else {
          return false
        }
      })()
    })
  }
}

// 使用方法
let program = new Program()

// 定义参数
program.options('prod').options('dev').parse(process.argv)

if (program.prod) {
  console.log('--prod');
  // todo 执行相关任务
}
if (program.dev) {
  console.log('--dev')
  // todo 执行相关任务
}

终端下执行:
$ chmod 755 env.js
$ env.js dev prod
--prod


更多有意思的方式可参考阮一峰的博客

http://www.ruanyifeng.com/blo...

关注 7 回答 5

mahy50 发布了文章 · 2018-02-01

使用npm-scripts发布Github Pages

使用npm-scripts发布Github Pages

将项目打包后部署到GitHub Pages 上是常见需求。
这里总结下通过npm-srcrips将项目发布到gh-pages分支。需要使用到gh-pages的库。

需要用到的环境

  • node
  • npm 或者yarn
  • 本地项目,需要通过create-react-app创建的React或者vue-cli创建的Vue项目
  • gh-pages
  • Github账户

过程

1. 在 GitHub 上创建与本地项目同名的远程仓库

2. 创建本地项目

React: create-react-app

$ yarn global add create-react-app
$ create-react-app my-app

若是使用npm5.2+版本

$ npx create-react-app my-app
$ cd my-app
$ yarn start

Vue: vue-cli
@vue/cli 3.0

$ yarn global add @vue/cli
$ vue create my-app

vue-cli@2.x

$ yarn global add @vue/cli-init
$ vue init webpack my-app

然后运行项目:

$ cd my-app
$ yarn install 
$ yarn start

3.将本地项目 push 到远程:

$ git init
$ git add .
$ git commit -m 'create app'
$ git remote add origin <git url>
$ git push -u origin master

4.添加npm-scripts:

Vue:
在package.json中添加

"scripts": {
    "pregh": "npm run build",
    "gh": "gh-pages -d dist"
}

React:
在package.json中添加

"scripts": {
    "pregh": "npm run build",
    "gh": "gh-pages -d dist"
}

Vue在build时生成的目录是dist,而React在build时生成的目录时build
gh是自定义的脚本名称,你也可以叫gh-deploy等等。
想要在脚本执行之前还另外执行其他命令,就在自定义脚本前添加预处理钩子,形式是pregh脚本名称。
关于npm-scripts的知识,参考:
npm scripts 使用指南
用 npm script 打造超溜的前端工作流(需付费)

5.修改publickPath

此时,虽然可以发布,但所有相关的静态文件的目录都是指向https://<username>.github.io的,而实际的静态文件的位置是在https://<username>.github.io/<repo-name>中。
Vue:
npm build之前,修改config/index.js的配置:

module.exports = {
    ...
    build: {
        ...
        assetsPublicPath: '', // 此处原来是assetsPublicPath: '/'
        ...
    }

React:
与Vue不同,create-react-app是将所有scripts文件隐藏的。想要将讲台文件指向正确的目录,是通过在package.json中添加homepage选项。

{
    "author": ...,
    "homepage": "https://<username>.github.io/<repo-name>",
    ...
    "scripts": { ... }
}

6.生成生产版本,并部署到Github Page

$ npm run gh
# or
$ yarn run gh

查看远程的gh-pages分支,此时分支下应包含一个index.html和其他静态文件(如static目录)。
之后就可以通过https://<username>.github.io/<repo-name>访问应用程序了。

相关参考:
React的github pages 发布,Deploying a React App* to GitHub Pages
如何在 GitHub Pages 上部署 vue-cli 项目

查看原文

赞 5 收藏 1 评论 0

mahy50 发布了文章 · 2018-01-21

使用Docker+Jenkins自动构建部署

使用Docker+Jenkins自动构建部署

环境

  • 阿里云ESC,宿主机服务器安装Docker,在安全规则中确认8080端口开启。
  • 客户端mac

运行jenkins

运行jenkins容器

在主机上创建目录,并添加读写权限以便jenkins应用运行时读写文件,如:

$ mkdir -p /var/jenkins_node
$ chmod 777 /var/jenkins_node

拉取jenkins镜像docker pull jenkins,当前是2.60.3版。并运行:

docker run -d --name myjenkins -p 8080:8080 -p 50000:50000 -v <your_jenkins_path>:/var/jenkins_home jenkins

将之前的目录挂载为数据卷<your_jenkins>替换为你的目录名,路径需要是绝对路径。
等待几十秒,查看jenkins_node目录,确认是否有jenkins应用生成的文件。
通过http://you_host:8080登陆查看。是否出现Getting Started界面。

设置账户及SSH登陆

在Getting Started界面会需要初始的密码Unlock Jenkins。
密码会在输出终端,也可根据页面提示到容器的jenkins_home中查找。
所以你可以

docker logs myjenkins
# 或者进入容器
docker exec -t myjenkins /bin/bash

有了密码,输入后安装建议的插件。
完毕后,根据提示设置登陆账户。

安装Publish Over SSH插件

首页 -> 点击系统管理 -> 管理插件 ->可选插件 -> 过滤:ssh -> 选择Publish Over SSH插件,点击直接安装。

设置服务器SSH信息

首先在容器中生成rsa密钥:

# 从宿主机客户进入容器,目前容器名myjenkins,也可通过docker ps 查看
$ docker exec -it myjenkins /bin/bash
# 进入容器后建立.ssh目录,创建密钥文件私钥id_rsa,公钥id_rsa.pub
~ mkdir ~/.ssh && cd ~/.ssh
~ ssh-keygen -t rsa
# 一直回车即可

添加公钥到宿主机
将id_rsa.pub中字符串添加到authorized_keys文件末尾,重启ssh服务sudo service ssh restart
注意宿主机是否开启ssh服务。
可以在容器终端中使用下面的命令添加到宿主机中。也可手动复制id_rsa.pub到宿主机的.ssh/authorized_keys文件中。

ssh-copy-id -i ~/.ssh/id_rsa.pub <username>@<host>

需要修改目标服务器的ssh配置文件,配置文件为/etc/ssh/sshd_config。设置ssh-server允许使用私钥和公钥对的方式登录,然后使用sudo /etc/init.d/ssh restart命令重启ssh服务。

添加私钥
jenkins首页,系统管理 -> 系统设置 -> 下拉,找到Publish over SSH,填写Key 和 SSH Server -> 保存

高级选项能够配置ssh服务器端口和超时。Test可测试,显示success配置成功。

项目配置

首先,新建一个任务。填写项目名称。
选择源码管理为:Git,填写项目库的URL。私有项目需要添加Git账号。

构建环境:选择Send files or execute commands over SSH after the build runs,选择服务器,以及添加Exec command。保存。

# 根据你的项目需要编写
sudo docker stop <node> || true \
    && sudo docker rm <node> || true \
    && cd /var/jenkins_node/workspace/<node> \
    && sudo docker build --rm --no-cache=true  -t <node>  - < Dockerfile \
    && sudo docker run -d  --name <node> -p 3000:3000 -v 
    /var/jenkins_node/workspace/node:/home/project <node>

端口设置的3000,也可以另行设置-p 宿主机端口:容器端口,记得确认服务器端口权限是否开启。

Dockerfile

# 根据你的项目需要编写
FROM node
RUN mkdir -p /var/www/html/ 
RUN npm install -g yarn
WORKDIR /var/www/html
EXPOSE 3000
CMD ["npm","start"]

ps: npm install -g cnpm --registry=https://registry.npm.taobao.org

返回首页,选择项目,立即构建。成功后可以通过http://you_host:3000端口查看项目。

配置webhook

配置webhook,实现自动部署
获取API tonken:首页 -> 用户 -> 选择当前的用户 -> 设置 -> 在API Tonken 项中点击Show API Token...

添加令牌:返回首页 -> 项目 -> 配置 -> 构建触发器 -> 选择 "触发远程构建" ->粘贴"API Token"内容到"身份验证令牌"
登陆代码托管平台,找到你的项目,选择管理,选择webhook,添加URL,格式http://<you_host>:<port>/job/<object_name>/build?token=<API Token>

设置jenkins安全策略

首页 -> 系统管理 -> Configure Global Security ->
授权策略,勾选Allow anonymous read access

至此,完成自动化的构建和部署。当你推送代码后就会实现自动构建,部署。

部署错误查找

代码推送和webhook的问题不大,照着例子写不会出错。
容器出错的是镜像构建和镜像运行,以及容器间通信的问题。

  • 镜像构建:查看jenkins主页-->查看项目-->最近一次的构建历史-->查看Console Output,查看控制台输出。这里可以看到是哪一步出错。一般Sending build context to Docker daemon之前是jenkins命令问题,之后是Dockerfile问题。
  • 镜像运行:如果Console显示镜像构建成功(也可docker images查看),但运行失败,或容器运行后退出,如果代码本地运行良好,一般是CMD启动命令错误,前台运行一下容器,docker run -it --name <container_name> <image_name> /bin/bash,进入容器后手动运行CMD,看看日志输出。另,docker exec -t <container_name> /bin/bash可以进入运行中的容器,能方便的查看代码;docker logs <container_name>显示运行的日志输出。
查看原文

赞 4 收藏 7 评论 0

mahy50 赞了文章 · 2018-01-18

socket.io搭配pm2(cluster)集群解决方案

可以收藏我的博客

socket.io与cluster

在线上系统中,需要使用node的多进程模型,我们可以自己实现简易的基于cluster模式的socket分发模型,也可以使用比较稳定的pm2这样进程管理工具。在常规的http服务中,这套模式一切正常,可是一旦server中集成了socket.io服务就会导致ws通道建立失败,即使通过backup的polling方式仍会出现时断时连的现象,因此我们需要解决这种问题,让socket.io充分利用多核。

在这里之所以提到socket.io而未说websocket服务,是因为socket.io在封装websocket基础上又保证了可用性。在客户端未提供websocket功能的基础上使用xhr polling、jsonp或forever iframe的方式进行兼容,同时在建立ws连接前往往通过几次http轮训确保ws服务可用,因此socket.io并不等于websocket。再往底层深入研究,socket.io其实并没有做真正的websocket兼容,而是提供了上层的接口以及namespace服务,真正的逻辑则是在“engine.io”模块。该模块实现握手的http代理、连接升级、心跳、传输方式等,因此研究engine.io模块才能清楚的了解socket.io实现机制。

场景重现

服务端采用express+socket.io的组合方案,搭配pm2的cluster模式,实现一个简易的b/s通信demo:

app.js

var path = require('path');
var app = require('express')(),
    server = require('http').createServer(app),
    io = require('socket.io')(server);

io
  .on('connection', function(socket) {
      socket.on('disconnect', function() {
          console.log('/: disconnect-------->')
      });

      socket.on('b:message', function() {
          socket.emit('s:message', '/: '+port);
          console.log('/: '+port)
      });
  });

io.of('/ws')
  .on('connection', function(socket) {
    socket.on('disconnect', function() {
        console.log('/ws: disconnect-------->')
    });

    socket.on('b:message', function() {
        socket.emit('/ws: message', port);
    });
});

app.get('/page',function(req,res){
    res.sendFile(path.join(process.cwd(),'./index.html'));
});

server.listen(8080);

index.html

<script>
        var btn = document.getElementById('btn1');
        btn.addEventListener('click',function(){
            var socket = io.connect('http://127.0.0.1:8080/ws',{
                reconnection: false
            });
            socket.on('connect',function(){
                // 发起“脚手架安装”请求
                socket.emit('b:message',{});

                socket.on('s:message',function(d){
                    console.log(d);
                });

            });

            socket.on('error',function(err){
                console.log(err);
            })
        });
    </script>

pm2.json

{
  "apps": [
    {
      "name": "ws",
      "script": "./app.js",
      "env": {
        "NODE_ENV": "development"
      },
      "env_production": {
        "NODE_ENV": "production"
      },
      "instances": 4,
      "exec_mode": "cluster",
      "max_restarts" : 3,
      "restart_delay" : 5000,
      "log_date_format" : "YYYY-MM-DD HH:mm Z",
      "combine_logs" : true
    }
  ]
}

这样,执行命令pm2 start pm2.json即可开启服务,访问127.0.0.1:8080/page,点击按钮发起ws连接,观察控制台即可。

下图清晰显示了socket.io握手的错误:
ws握手失败

可见在websocket连接建立之前多出了3个xhr请求,而websocket连接建立失败后又多出了几个xhr请求,同时最后两个xhr请求失败了。

socket.io没有采用直接建立websocket连接的粗暴方式,而是首先通过http请求(xhr)访问服务端的相关轮训配置信息以及sid。此处sid类似sessionID,但是它唯一标识连接,可理解为socketId,以后每次http请求cookie中都必须携带sid(httponly);

初次握手信息

第二、三个请求用于确认连接,在socket.io中,post请求是客户端发送消息给服务端的唯一形式,而且post响应一定是“ok”,它的“content-length”一定为2;而get请求主要用于轮训,同时获取服务端的相关消息,这会在下文中有体现;

第四个websocket连接请求失败,这主要是由于与后端http握手失败造成的;

第五个请求为xhr方式的post请求,它是作为websocket通道建立失败后的一种兼容性处理,上文讲述了socket.io的post请求只在客户端需要发送消息给服务端时才会使用,因此,为了证实我们查看消息体:

post消息体

可见,它携带了客户端发出的消息类型b:message,同时包含消息体{}空对象。对应的,服务端返回“OK”;

第六个请求为xhr方式的get请求,用来获取服务端对第五个请求的响应。

响应

至此,大致分析了socket.io建立连接的大致过程以及连接建立失败后如何兜底的方案,下面分析为何出现握手失败的问题。

原因何在

实例中pm2主进程开启了4个工作进程,由主进程侦听8080端口并分发请求给工作进程。pm2进程在分发请求的阶段采用了某种算法的均衡,如round-robin或者其他hash方式(但不是iphash),因此在socket.io客户端连接建立阶段发送的多个xhr请求,会被pm2定位到不同的worker进程中。前文中提到每个xhr请求都会携带sid字段标识当前连接,因此当一个携带sid字段的请求被pm2定位到另一个与该连接无关的worker时,就会造成请求失败,返回{"code":1,"message":"Session ID unknown"}错误;即使前三次xhr握手成功,进入websocket连接升级阶段,负责侦听update事件的worker也往往不是之前的那个worder,因此导致websocket连接建立失败。

一言以蔽之,客户端多次请求的服务端进程不是同一个进程才导致的ws连接无法成功建立。
那么如何才能解决呢?最简单的方案就是确保客户端的每次请求都可以定位到同一个服务进程即可。当然,分布式session同样可以解决问题,依托第三方缓存类似redis并配合一致性hash算法,确保所有服务进程都可以获取到连接信息,相互配合完成连接建立。但这也仅仅是作者在理论上分析的一种实现方式,并没有测试通过,因为这种分布式架构不仅实现繁杂而且引入了相关依赖redis,不太可取。

那么下文主要针对确保客户端的每次请求都可以定位到同一个服务进程这一点实现解决方案。

多种实现

官方实现

官方提供了一种比较轻便的架构:nginx反向代理+iphash

我们的示例demo中的http服务器只侦听8080端口,因此必须由pm2分发请求,否则会出现端口占用的错误发生。但是,官方的解决方案是每个进程的socket.io服务器创建不同端口的http服务器,专注用于http握手和升级,由nginx做握手请求的代理。而且针对nginx必须设置iphash,保证同一个客户端的多次请求定位到后端同一个服务进程。

这样,示例demo中会占用5个端口,其中8080端口为公用的http服务器使用,其他四个端口则只用于ws连接握手。但是这四个端口却如何选取呢?为了保证扩展性以及顺序性,采用与pm2相兼容的方案。pm2会为每个worker进程分配一个id,并且将该id绑定到进程的环境变量中,那么我们就可以利用该worker id生成4个不同的端口号。

app.js

var path = require('path');
var app = require('express')(),
    server = require('http').createServer(app),
    port = 3131 + parseInt(process.env.NODE_APP_INSTANCE),
    io = require('socket.io')(port);

io
  .on('connection', function(socket) {
      socket.on('disconnect', function() {
          console.log('/: disconnect-------->')
      });

      socket.on('b:message', function() {
          socket.emit('s:message', '/: '+port);
          console.log('/: '+port)
      });
  });

io.of('/ws')
  .on('connection', function(socket) {
    socket.on('disconnect', function() {
        console.log('disconnect-------->')
    });

    socket.on('b:message', function() {
        socket.emit('s:message', port);
    });
});

app.get('/abc',function(req,res){
    res.sendFile(path.join(process.cwd(),'./index.html'));
});

server.listen(8080);

index.html

  <script>
        var btn = document.getElementById('btn1');
        btn.addEventListener('click',function(){
            var socket = io.connect('http://ws.vd.net/ws',{
                reconnection: false
            });
            socket.on('connect',function(){
                // 发起“脚手架安装”请求
                socket.emit('b:message',{a:1});

                socket.on('s:message',function(d){
                    console.log(d);
                });

            });

            socket.on('error',function(err){
                console.log(err);
            })
        });
    </script>

nginx.conf

    upstream io_nodes {
      ip_hash;
      server 127.0.0.1:3131;
      server 127.0.0.1:3132;
      server 127.0.0.1:3133;
      server 127.0.0.1:3134;
    }
    server {
        listen 80;
        server_name ws.vd.net;
        location / {
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection "upgrade";
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header Host $host;
          proxy_http_version 1.1;
          proxy_pass http://io_nodes;
        }
  }

在本机绑定hosts地址后开启nginx服务,同时开启服务器,点击按钮建立ws连接成功。

服务端路由

服务端路由,意义在于“服务端做worker的负载均衡,并将选择的worker ip和端口渲染在页面,之后浏览器的所有ws连接默认连接到对应 ip:port的服务器中”。这样只要是服务端渲染的页面都可以采用这种方式实现。

如果页面采用前端异步渲染,仍可以采用这种方式,不过首先通过xhr请求向服务端获取需要握手的http服务器的ip和端口,然后在进行ws连接。

服务端路由的前提仍然是需要针对每个ws服务器分配一个端口,只不过去掉nginx由服务端做ip hash。采用服务端路由架构清晰,而且实现容易,兼容性好。

上帝进程路由

此处的上帝进程即为主进程,类似pm2进程。上帝进程路由则是在上帝进程层面上做请求的定向分发,保证请求主机和进程的一致性。在上帝进程中,针对每个请求的ip做hash,并对每一个ws服务器创建单独的http服务器用于握手升级。

简易代码:

var express = require('express'),
    cluster = require('cluster'),
    net = require('net'),
    sio = require('socket.io');

var port = 3000,
    num_processes = require('os').cpus().length;

if (cluster.isMaster) {
    var workers = [];

    var spawn = function(i) {
        workers[i] = cluster.fork();
        workers[i].on('exit', function(code, signal) {
            console.log('respawning worker', i);
            spawn(i);
        });
    };

    for (var i = 0; i < num_processes; i++) {
        spawn(i);
    }

    // ip hash
    var worker_index = function(ip, len) {
        var s = '';
        for (var i = 0, _len = ip.length; i < _len; i++) {
            if (!isNaN(ip[i])) {
                s += ip[i];
            }
        }

        return Number(s) % len;
    };

    var server = net.createServer({ pauseOnConnect: true }, function(connection) {
        var worker = workers[worker_index(connection.remoteAddress, num_processes)];
        worker.send('sticky-session:connection', connection);
    }).listen(port);
} else {
    // worker
    var app = new express();

    // handshake server.
    var server = app.listen(0, 'localhost'),
        io = sio(server);

    process.on('message', function(message, connection) {
        if (message !== 'sticky-session:connection') {
            return;
        }

        server.emit('connection', connection);

        connection.resume();
    });
}

总结

本文实现了三种解决方案,归根到底就是“ip hash”,不同点在于在请求处理的不同阶段做ip hash。

可以在请求处理最前端做iphash,即nginx方式,这也就是第一种方案;
可以在请求处理的第二层分发处做iphash,即上帝进程路由的方式,即第三种;
也可以在请求处理的终端做iphash,即服务端路由的方式,也就是第二种;
同时共享session也同样可以实现,借助socket.io-redis模块也可以实现。

查看原文

赞 6 收藏 16 评论 22

认证与成就

  • 获得 9 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-07-29
个人主页被 113 人浏览