使用 FIS3 构建 Vue 前端工程. webpack 用户也请看过来

本文的目标是通过一个例子向大家展示如何使用 FIS3 作为构建工具, 来对使用 Vue 的前端工程进行构建. 当然, Vue 只是一个例子, 完全可以推广到其它的项目中.

前端构建工具, 我从 Grunt, Gulp, webpack 一路用过来. 虽然 webpack 非常火, 但是最终我在项目中使用的是 FIS3. 为什么用 webpack 的那么多, 我却没有使用呢.

我在使用 wepback 的过程中, 遇到了比较大的问题, 所以最终没用 webpack. 这也是文章标题中请 webpack 用户也看过来的原因.

1. webpack 基于数字的模块ID方案, 会很大程序上导致浏览器的缓存大面积失效

在 webpack 的配置中, 大家一般都会使用文件 hash 作为文件名, 然后在静态服务器中设置强制缓存, 可以保证用户在使用缓存的同时, 方便我们进行文件的更新. 只要 hash 一变, 用户自然就会去下载新的文件了.

但是, 文件内容没有变化, 仅仅因为我们组织、引用文件的顺序变化, 或者新添加了一个模块, 就有可能导致缓存大面积失效.

假设我们有这样一个场景, 有一个路由文件 route.js, 会在此文件中使用 webpack 的 code spliting 写法, 按需加载文件.

在 route.js 中分别对应三个页面(foo, bar, baz), 按需加载对应的入口文件. 在入口文件中, 会再分别加载各个子页面依赖的文件.

当我们使用 webpack 进行 build 后, webpack 会给每个文件分配一个 moduleId, 这个 moduleId 是随着 webpack 扫描到的文件的数目而进行递增的. 一种结果是类似于下面的示例, 文件后面的括号中是生成的 moduleId 号

如果我们调整了文件的依赖顺序, 把 dep2 放在 dep1 之前, 那么相应的moduleId 会变成类似下面这样

而如果我们需要在foo.js中增加一个依赖 dep4.js, 那么相应的 moduleId 会变成类似于下面这样

我们只增加(或删除)了一个文件, 或者只调整了文件的引用顺序, 却导致了多个文件的 moduleId 变化, 这样就导致多个文件的内容发生了变化. 那么当重新发布后, 其实有的页面的内容根本没有变化, 但是仅仅因为 moduleId 变化, 而导致需要重新下载这些文件, 使得没法使用浏览器已经缓存的文件.

听说现在已经有了 namedModulePlugin 这个插件, 可以保证文件的模块ID是稳定的, 但是请看下面的一个问题.

2. 多页面中 webpack 的 commonChunksPlugin 插件, 导致的冗余加载问题

在项目中, 肯定会有很多公共模块, 所以大家都会使用 commonChunksPlugin 来提取公共模块.

像一些第三方库的话, 我们可以通过 commonChunksPlugin, 将第三方类库放到 vendor 中. 我们自己写的公共模块, 一般也是通过 commonChunksPlugin, 并传入 minChunks 选项来进行抽取. 如果一个模块被依赖次数达到了 minChunks 的大小, 就会被抽取到一个类似 common.js 中.

只是模块抽取的逻辑, 以及导致的冗余加载的问题, 大家有没有关注过呢.

比如在多页面项目中, 设置了多个 entry, 并通过 commonChunksPlugin 抽取公共模块, 假设我们配置的公共模块抽取的目标文件为 common.js.

当 minChunks 为最小值 2 时, 即只要一个模块被依赖次数大于等于 2, 就会被抽取到 common.js 中. 所以构建完成后, common.js 会包含所有的公共模块. 那么当我们在加载的时候, 可能只需要其中的一个模块, 却要把整个文件下载下来. 这样造成的冗余性非常大.

那么如果我们把 minChunks 设置的大一些会怎么样呢? 如果一个公共模块被依赖的次数没有达到 minChunks, 那么此模块就会在所有依赖它的文件中都会被打包一份. 同样也造成了资源的冗余加载.

所以, 不管 minChunks 被设置为多少, 总是会有冗余加载的问题. 使用 webpack 的用户, 请看一下你们的 commonChunksPlugin 配置, 然后再看一下你们 build 之后的文件, 是不是会出现我上面所说的问题.

3. SPA 中, code split 导致的冗余加载问题

多页面的项目, 冗余加载的问题其实并没有那么严重, 即使有冗余加载, 但是因为用户刷新了页面, 也不会占用过多资源. 但是在 SPA 项目中, 影响就会变得大了, 同时还会有一些潜在的问题.

在 SPA 中, 一般我们都是通过在 route 中使用 require.ensure 实现动态加载. 但是 webpack 在切分文件时, 也会造成冗余加载的问题.

比如

a.js

require('./c');
console.log('a.js')

b.js

require('./b');
console.log('a.js')

c.js

console.log('c.js');

main.js

module.exports = {
  a: function(){
    require.ensure([], function(){
      require('./a');
    })
  },
  b: function(){
    require.ensure([], function(){
      require('./b');
    })
  }
};

webpack.config.js

var path = require('path');
var webpack = require('webpack');
module.exports = {
  entry: path.resolve(__dirname, 'main.js'),
  output: {
    filename: '[name].js',
    path: path.resolve('.', 'dist')
  }
};

最终通过 webpack, 生成了三个文件. 0.js, 1.js, main.js, 内容分别如下

0.js 对应 main.js 中的 a

webpackJsonp([0],[
/* 0 */,
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__(3);
console.log('b.js')
/***/ }),
/* 2 */,
/* 3 */
/***/ (function(module, exports) {
console.log('c.js');
/***/ })
]);

1.js 对应 main.js 中的 b

webpackJsonp([1],[
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__(3);
console.log('a.js')
/***/ }),
/* 1 */,
/* 2 */,
/* 3 */
/***/ (function(module, exports) {
console.log('c.js');
/***/ })
]);

可以看到, 相同的 c.js 被分别打包进了 a.js 和 b.js

所以当我们加载的时候, A 模块依赖 C 模块, 下载下来的文件中, 既有 A, 也会有 C. 但是 B 模块也依赖 C 模块, 下载下来的文件中, 既有 B, 也会有 C. 相同的 C 模块被下载了2次.

设想一下, 如果这个 C 模块是一个 EventBus 模块, A 使用它来发布消息, B 使用它来订阅消息. 那么 A B 之间的通讯就会出现问题. 因为 A 使用的 C 和 B 使用的 C 是两个完全不同的模块, 它们的数据并不会共享, 也并不知道彼此的存在

这几个问题, 是我在使用 webpack 的时候, 一直在思考的问题, 也在网上搜索过相关的知识. 但是好像使用 webpack 的用户, 都不认为这是问题?


在构建项目的时候, 我想让模块ID能够稳定, 那么使用文件的路径作为模块ID就是一个不错的方案. 我需要按需加载, 但是却不希望有冗余加载. 一个办法是, 在构建的时候, 其它流程都正常进行, 但是却不合并, 或者是通过配置有限合并. 加载的时候采取其它的办法. 这个办法后面会讲到.

但是对 webpack 来说, 它作为一个打包器, 却要让它不进行打包合并, 真的是困难.

使用 FIS3

因为上面的问题, 在了解了 FIS3 之后, 我就选择了 FIS3. 我选择 FIS3, 首先是因为它能很好的解决上面的问题, 其次在它的基础上, 我们还能有更加优化的办法.

FIS3 默认使用文件路径作为模块ID, 所以也就不存在模块ID不稳定的问题. 其次, 如果在没有进一步配置的情况下, FIS3 产出的文件是没有任何合并的, 只是把 commonjs 模块包装成 amd 模块, 添加了模块ID, 并且会产出一份静态文件资源表, 标识出文件之间的互相依赖关系.

虽然没有自动合并, 但是 FIS3 提供了打包合并的配置, 我们可以通过配置选择将哪些文件合并打包在一起. 由于是我们自己控制的, 所以肯定会比 webpack 的自动化打包方案更灵活.

可能使用 webpack 的人会有疑问, 通过手动配置的方式? 这不是更麻烦吗. 其实后面通过例子会看到, 真的很简单.

下面是一个使用 FIS3 的实现的一个 demo. 此 demo 是 vue 官方出的 vue-hackernews-2.0, 我把它改成使用 FIS3 进行构建.

这是整个项目的结构及依赖关系.

  1. 最下层的是作为全局的第三方类库, 整个项目初始化时就需要加载. 所以我通过 FIS3 的 packTo 配置, 将他们打包为 runtimes/packages.js 文件. fis 的配置只需要下面这样
     // node_modules 库, 只 packTo 部分文件, 有的文件不是全局依赖还是按需加载
     fis.match('/(node_modules/{' + require('./src/runtimes/packages.json').join(',') + '}/**.{js,ts})', {
       packTo: '/src/runtimes/packages.js'
     })
    

    runtimes/package.json 中的内容如下

     [
       "es6-promise",
       "vue",
       "vuex",
       "vue-router",
       "vuex-router-sync",
       "process",
       "tslib",
       "firebase"
     ]
    

    我在 runtimes/packages.json 中定义哪些 node_modules 模块作为初始化加载的依赖. 如果没有在这里列出, 就会走异步加载的流程.

  2. 往上一层是项目的全局依赖模块, 包括 route, filters, app.vue 等等, 我把他们全都放到 runtimes 文件夹下, 表示它们都是作为运行时依赖. 我把它们合并为 runtimes/runtimes.js
     // 全局 runtimes 文件
     fis.match('/src/runtimes/**.{js,ts,vue}', {
       packTo: '/src/runtimes/runtimes.js'
     })
    
  3. 再往上一层就是我们的 view, 业务逻辑. 每个 view 占据 views 下面的一个文件夹, 做到高度自制. 即, 此 view 需要的所有私有依赖, 都放到此文件夹下, 包括但不限于 自用组件, 图片, 字体, 工具函数等等. 上面图中 vuex 中的 store 我也把它放在这里, 因为 vuex 中有 registerModule 方法, 可以在 view 被加载时, 动态注册 vuex module. 最后通过 FIS3, 把每个view的文件夹合并为一个文件. fis 的配置中, 通过 FIS3 提供的类似于 glob 的语法, 可以很方便的进行配置
     // 各个页面自用的文件, 打包成一个文件, 减少http请求
     fis.match('/src/views/(*)/**.{js,ts,vue}', {
       packTo: '/src/views/$1-pack.js'
     })
    

    但是这里, 有个需要注意的地方. 如果是其中的某个模块, 也被其它模块依赖时, 可以把此依赖从此 view 文件夹中提出来, 上移到公共模块文件夹中. 但是如果是一些 icon 图片, 我更倾向于复制一份到view中, 而不是把它上移到公共图片目录中. 这样当某个view不需要了, 可以安全的将此目录直接删除

  4. 右边就是一些公共模块, 公共组件了, 它们依赖 node_modules, 同时被各个 view 所依赖. 但是它们不会被合并到任何文件中, 每个模块在构建完毕后, 都是一个单独的文件, 所以也就不会出现冗余加载的情况发生.

其实通过上面的一些结构或者做法, 我们做了下面这些事情

  1. 我们把初始化需要的 node_modules 模块放到一个文件中, 这和 webpack 中通过定义 vendor, 然后使用 commonChunksPlugin 将 vendor 抽取到 vendor.js 中效果一样
  2. 我们把项目中的运行时依赖, 合并为 runtimes/runtimes.js 中, 这和 webpack 中, 通过 commonChunksPlugin 抽取项目公共模块的做法类似, 但是更精准. 只合并了项目初始化需要的依赖, 并不包括各个 view 展示时需要的依赖.
  3. 各个 view 中的模块都被合并成一个文件, 加载一个 view, 最小的 http 请求变为一个

那么, 如果一个 view 依赖了很多公共模块, 不就会出现很多的请求吗?

请求数优化方法

因为 FIS3 在构建时, 会产出一份静态资源表, 所以我们根据此资源表, 可以有两种解决办法.

  1. 本地 localStorage 缓存, 这是只需要前端, 不需要后端配合就可以完成的一种方案, 但并不是最好的办法.

    1. 初次加载时, 将模块缓存到 localStorage 中, 并以文件 url 或者文件 hash 作为标识
    2. 再次加载时, 首先检查 localStorage 中是否有缓存的模块, 以及模块 hash 是否匹配. 如果匹配的话, 直接拿出使用, 否则去远程加载

    所以当第一次到达某个页面时, http 请求可能会很多. 但是当用户以后再次到达此页面时, 因为localStorage 中有缓存, 所以完全可以做到 0 个请求.

  2. 上面的办法不是最好的办法, 是因为它没法解决第一次加载时, http 请求数目过多的问题. 那么解决此问题最好的办法就是, 后端 combo 服务.

    由于我们有静态资源表, 所以当加载某个模块时, 在发出请求之前, 我们就可以通过静态资源表递归的找到所有依赖, 然后拼装成 combo 服务接收的参数, 一次请求, 把所有的依赖全部下载下来.

如果把上面的 1, 2 结合起来, 我认为才是真正最好的解决方案.

  1. 首次加载时, 通过 combo 服务, 一个请求, 即可把模块的递归依赖全部下载下来, 然后缓存到 localStorage 中, http 请求数最小
  2. 再次加载时, 由于 localStorage 中有缓存, 所以不需要发出 http 请求即可完成.
  3. 如果是在其它view中加载某个模块, 首先通过静态资源表, 递归的把所有的依赖找到, 然后先去 localStorage 缓存中过滤一遍, 将 localStorage 中已经存在并可以使用的模块缓存直接拿出来使用, 然后再将 localStorage 中没有或者已经失效的模块, 使用 combo 服务进行加载. 这样就可以实现以最小的请求量以及最少的请求数, 即可完成模块加载.

    比如首次加载 a.js, 依赖模块有 b, c, d. 当再次加载时, 因为已经有缓存, 所以不需要发出 http 请求, 直接使用缓存即可. 当加载另一个模块 e.js 时, 分析出的依赖模块有 c, d, f, g. 此时, 先去 localStorage 中, 发现缓存中有 c, d 模块, 直接拿出使用. 然后再用一个请求, 将 f, g 模块使用 combo 服务下载下来

最终的 demo 的链接 fis3-typescript-vue-hackernews-2.0.

dev 模式下的请求

在 dev 模式下, 我没有配置任何的合并, 所以进入首页后, 请求数很多. 除去 livereload, 以及 vconsole 这两个 dev 模式下才有的模块外, 还有 21 个文件请求.

production 模式下的请求

在 production 模式下, 我通过上面说到的使用 FIS3 的 packTo 配置, 将请求数降到 7 个. 其实还可以更进下一步, 将 init.jsruntimes/runtimes.js 再合并一下, 将运行时依赖以及项目启动文件, 同时合并到 runtimes/init.js

// init 初始化文件
fis.match('/src/{boot,app}.{js,ts,vue}', {
  packTo: '/src/runtimes/init.js'
})

// 全局 runtimes 文件
fis.match('/src/runtimes/**.{js,ts,vue}', {
  packTo: '/src/runtimes/init.js'
})

那么这时候只剩下了 6 个请求, 还剩下的没有被合并的就是首页 view 依赖的一些公共组件模块了. 这里可以看一下, FIS3 提供的模块加载框架 mod.js 根据静态资源表分析到的文件依赖关系.

最后一个分组中的文件, 就是前面一个图中最后加载的 4 个文件. 我们已经可以在请求发出前, 就知道这4个文件有关联, 所以使用 combo 服务, 就可以一次请求这4个文件.

那么最后, 通过使用 FIS3 的 packTo 配置, 再配合使用 combo 服务的话, 我就可以将 21 个文件请求, 优化到 3 个文件请求. 如果进行页面切换的话, 因为有 combo, 发出的请求数也会很少.

再进下一步, 假如我们不使用 FIS3 的 packTo 配置, 而只使用 combo 服务的话, 通过上面的图, 可以看出, 也是可以将首次请求数量优化到3个.

第一个请求

"boot"
"node_modules/es6-promise/dist/es6-promise"
"node_modules/process/browser"

第二个请求

"app"
"node_modules/tslib/tslib"
"node_modules/vue/dist/vue"
"node_modules/vuex-router-sync/index"
"runtimes/filters/filters"
"runtimes/router/router"
"node_modules/vue-router/dist/vue-router.common"
"runtimes/store/store"
"node_modules/vuex/dist/vuex"
"runtimes/store/api"
"runtimes/store/create-api"
"node_modules/firebase/app"
"node_modules/firebase/database"
"runtimes/views/app"

第三个请求

"views/createListView"
"components/ItemList"
"components/Spinner"
"components/Item"

只是, combo 服务需要后端或者 CDN 去配合, 只是前端的话, 就可以只使用 packTo 的配置, 效果也是很明显的.

Edit

经掘金上的任侠提醒, 在使用文件名 hash 和 CDN 强缓存的情况下, 浏览器根本不会去远端请求,直接从本地读取, 所以就不需要使用 localStorage 了.

将多说评论系统迁移到Disqus

最近几天看到多说官方发布消息, 多说评论系统将于2017年6月1日正式停止服务, 是时间考虑将评论系统换一下了.

当初使用多说, 一是因为是国内的服务, 速度会比较快, 二是国内用的人确实不少, 沟通起来可以比较方便.

现在多说不能使用了, 其实大可以直接将博客中的多说下掉, 但是以前的评论就没有了. 这些评论是从使用 WordPress 时就积累下来的, 如果丢失了, 真的比较可惜.

所以将评论迁移, 第一个考虑切换的目标就是使用人最多的 Disqus 了.

网上找了一番, 发现迁移脚本都是时间比较长的了, 害怕会有些变化, 所以打算自己写一个.

duoshuo2disqus

参考了 Disqus 导入时的格式 WXR, 然后使用 Node 处理一下多说导出的数据.

处理数据的时候还遇到了一个问题. 多说导出的文章和评论中, 都有一个 thread_idpost_id 的字段. 这两个字段导出的类型为数字类型, 而且非常大. 导致 Node 在 require 这些内容的时候, 数字不准确, 造成非常多的重复和错乱.

解决办法就是, 先把导出的内容中, 这两个字段由数字类型, 转为字符串类型, 即可解决.

在使用 JS 的项目中使用 ESLint 强制进行 code style check

code style 是一个经常被大家提起的东西. 当项目中人少的时候, 大家一般都能有一个约定, 并去遵守.

但是当项目变大, 或者有新的开发人员加入进来的时候, code style 不一致的问题就会变大. 甚至有些写法不规范的地方, 导致看代码的时候感觉不舒服. 假如每个人喜欢的风格不一致, 当编辑代码的时候, 又使用了代码自动格式化工具按照自己的喜好进行格式化, 那么当提交到 git 的时候, 会出现全红全绿的情况, 特别不利于 review.

因此应该采取一种手段进行约束.

所以我采用了 git hook 的方式在本地强制进行 code style check. 当 check fail 时, 直接拒绝 commit. 防止出现为了修复 code style 而添加的一些意义不大的 commit.

  1. 项目安装 ESLint, 并配置 eslintrceslintignore
  2. 在项目的 .git-hooks 目录下添加如下脚本, 来源 https://gist.github.com/linhmtran168/2286aeafe747e78f53bf

    #!/bin/sh
    
    STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep ".jsx\{0,1\}$")
    
    if [[ "$STAGED_FILES" = "" ]]; then
      exit 0
    fi
    
    # Check for eslint
    ESLINT="./node_modules/.bin/eslint"
    if [[ ! -e $ESLINT ]]; then
      echo ""
      echo "  \033[41mPlease use <npm install> to install ESlint\033[0m"
      exit 1
    fi;
    
    PASS=true
    echo ""
    
    for FILE in $STAGED_FILES
    do
      $ESLINT "$FILE"
    
      if [[ "$?" == 0 ]]; then
        echo "  \033[32mESLint Passed: $FILE\033[0m"
      else
        echo "  \033[41mESLint Failed: $FILE\033[0m"
        PASS=false
      fi
    done
    
    if ! $PASS; then
      echo "\n\033[41mCOMMIT FAILED:\033[0m Your commit contains files that should pass ESLint but do not. Please fix the ESLint errors and try again.\n"
      exit 1
    else
      echo "\n\033[42mCOMMIT SUCCEEDED\033[0m\n"
    fi
    
    exit $?
    
  3. package.json 中进行配置 postinstall 为如下内容

    cp .git-hooks/* .git/hooks/
    

这样, 每次 npm install 之后, 都会将检查脚本放置到正确的位置. 每次 git commit 的时候, 都会进行 code style check.

我们目前的项目中, 使用了 FIS3 进行开发, 所以我们开发时, 会有对应的 npm run dev 这样的 script, 为了确保检查脚本能被正确放置, 我在 npm run dev 中也会去跑一遍 npm run postinstall. 这样, 就不会出现, 由于原来已经 npm install 过了, 而没有把检查脚本正确放置的情况.

使用 System.js 作为 ES6 的加载器

随着 ES6 的慢慢普及以及各浏览器对于 ES6 的支持, 在新开的项目或者对于老项目的更新上, 大家纷纷采取拥抱 ES6 的态度. 因为即使有的浏览器目前对于 ES6 的支持还不太高, 但是我们可以使用 webpack, 加上 webpack 中的各种 loader, 我们可以很方便的将 ES6 编译为普通的 ES5 代码, 然后跑在所有的浏览器上.

webpack 为大家提供了 webpack-dev-server 用于开发环境, webpack-dev-server 可以方便的进行代码调试, reload 等功能.

但是我在使用 webpack 搭配 webpack-dev-server 的时候, 由于项目很大, 文件特别多, 所以遇到了一个很大的问题, rebuild 等待的时间太久了, 完全不如我原来没有使用 webpack 时保存代码后手动刷新浏览器来的快速. 但是因为我使用了 webpack 进行合并压缩, 我不得不等待整个项目编译完成才能进行调试.

为了解决这个问题, 我写了 webpack-source-code-loader, 并且写了一篇文章 在Angular.js项目中使用异步加载(五).

后来接触到了 System.js, 发现其实我的需求完全可以使用 System.js 进行解决. 因为 System.js 可以通过 Babel.js 或者 TypeScript 在浏览器中完成 ES6 => ES5 的编译工作. 具体例子请看我写的 demo

在使用的时候我发现, 使用 TypeScript 时会比使用 Babel.js 有更快的编译速度, 同时还可以使用 TypeScript 提供的类型系统, 真的很好. 并且, System.js 和 Babel.js@6.x 有兼容性问题, 目前还只能使用 Babel.js@5.x 才能进行正常工作.

综上, 以后开始一个新的项目的时候, 完全可以先不管 webpack 的配置, 可以直接使用 System.js 来加载 ES6 源码, 等到整个项目完成的差不多了, 再考虑使用 webpack 或者其它构建工具进行工程化也不迟.

近期文章

相关页面