教你搭建一个自己的脚手架(二)

961 阅读10分钟

【写在前面】

在上一篇《教你搭建一个自己的脚手架(一)》中,我们介绍了一些脚手架的公共配置。完成了dev-server和对多文件格式的加载处理功能,这一节中,我们将继续完善配置,以期完成基础版的所有功能。
完整的code在这里Github 点进去。与此同时,我们再回顾一下我们的目标。

基础版标准版
dev-server
处理html/JS/VUE/CSS/LESS/SASS/IMG/JPG等各种文件
路由配置
mock数据
代理接口
打包分析
单元测试
编译构建
dev-server
处理html/JS/VUE/CSS/LESS/SASS/IMG/JPG等各种文件
mock数据
代理接口
打包分析
单元测试
编译构建
基本布局
导航配置
支持TS
支持Markdown
风格检查

【看这里】

在上一节里,webpack.config.base.js基本已经完成。我们需要针对开发和生产环境做一些差异化的配置。正如最开始设计的那样,我们将开发环境需要的配置放置到webpack.config.dev.js中,生产环境需要的配置放置到webpack.config.prod.js中。
对比我们的设计目标,不难发现mock数据、接口代理和打包分析是开发场景中特有的需求,在生产环境中不需要。因此,我们先搞定研发场景。

mock数据
数据mock其实原理比较简单,即拦截请求,加载用户事先配置好的mock数据,作为response返回即可。
社区中常见的方法可以分为两类,一类是直接在client的层面直接集成拦截钩子,比如axios有对应的axios-mock-adapter作为插件可以mock数据。另一类,是在server中间件的层面拦截请求。两种方式的区别在于,第一种方式并未真正的发出ajax请求,即在网络控制台中看不到network的记录;第二种方式发出了ajax请求,但请求在本地server被拦截了,请求并未真正从本机发出,但network面板中有记录存在。这里,我选择了第二种方式,原因显而易见,因为我们希望从chrome/firefox等用户代理的network面板中看到请求记录,方便我们调试和排查问题。
通过查询webpack-dev-servergithub,发现其实现是基于express的。express的中间件机制,允许我们注册自己的逻辑到调用链中。又因为并未看到有特别好的社区插件,我决定自己实现一个。
首先,它最后的使用方式应该是很简单的, 比如类似这样子:

/**
 * @file mock/api/user.js
 * @description mock api for user model
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 */
module.exports = {
    queryUser: {
        url: /\/user\/\d$/,
        method: 'get',
        status: 200,
        response: {
            success: true,
            message: 'get user info success',
            data: {
                id: 1,
                name: 'John'
            }
        }
    },
    listUsers: {
        url: /\/user$/,
        method: 'get',
        status: 200,
        response: {
            success: true,
            message: 'list user info success',
            data: {
                users: [
                    { id: 1, name: 'John' },
                    { id: 2, name: 'Sharon' }
                ]
            }
        }
    }
};

样例中给了两条mock配置,一个是获取某个特定用户的信息,另一个是获取所有的用户列表。它的字段语义还是很明确的:

// 需要拦截的路由path,支持字符串和正则匹配
url: string/reg
// 需要拦截的方法类型
method: get/post/put/delete/option
// 响应的状态码
status: HTTP_STATUS_CODE
// 响应的结构体,支持对象结构体和方法动态生成数据
response: Object/Function

下面,我们实现对路由的拦截和响应数据的加载功能。它应该提供一个单例模式的实例,应该有对外暴露的方法,可以设置状态、注册配置、加载钩子等。我们先来看下最后的实现,然后一一解读一下:

/**
 * @file mock/lib.js
 * @description export mock
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 */
const _ = require('lodash');

const Mock = {
    on: true,
    rules: [],
};

Mock.Use = () => {
    Mock.on = true;
};

Mock.Restore = () => {
    Mock.on = false;
};

Mock.Reset = () => {
    Mock.on = false;
    Mock.rules = [];
};

Mock.Register = (c) => {
    c['on'] = c.hasOwnProperty('on') ? c['on'] : true;
    Mock.rules.push(c);
};

Mock.findRule = (path, method) => {
    const result = {willMock: false, rule: null};
    if (Mock.on) {
        for (let r of Mock.rules) {
            if (r.on
                && r.method.toUpperCase() === method
                && ((_.isString(r.url) && r.url === path) || (_.isRegExp(r.url) && r.url.test(path)))
            ) {
                result.willMock = true;
                result.rule = r;
                break;
            }
        }
    }
    return result;
};

Mock.LoadMock = (req, res, next, app, server, compiler) => {
    if (req && req.path && req.method) {
        const {willMock, rule} = Mock.findRule(req.path, req.method);
        if (willMock) {
            console.log(`[${req.method.toUpperCase()}]${req.path} mocked by mocker...`);
            if (_.isFunction(rule.response)) {
                res.status(rule.status).send(rule.response(req, res));
            }
            else {
                res.status(rule.status).send(rule.response);
            }
            return;
        }
    }
    next();
};

module.exports = Mock;

我们定义了一个Mock对象,它的属性包括了字段Mock.onMock.rules,分别用来管控mock的生效状态和路由拦截配置。同时暴露了多个方法支持对内部状态进行修改,其中Mock.useMock.RestoreMock.Reset是对生效状态的修改,Mock.RegisterMock.LoadMock是注册路由拦截规则和加载mock主逻辑的接口。导出的方法使用大驼峰命名方式,内部方法采用小驼峰命名方式。
Mock.LoadMock中传入请求和响应对象,对请求的方法、路由进行判断,一旦命中用户配置的规则,则加载配置的响应数据返回给用户代理,并直接return,阻断后续的调用链。否则执行next(), 继续下一个中间件逻辑。这里的response支持配置成一个方法,动态的生成数据或者远程加载数据等。
到这里,mock数据的主逻辑就有了。那这个中间件到底怎样注册呢?我们通过查询文档,发现devServer.before中暴露了接口,可以让我们自己定义的逻辑先于所有其它中间件执行,所以我们把拦截逻辑放到这里就可以了。

// webpack.config.dev.js
const MockUp = require('../mock/index');
devServer: {
        before: function(app, server, compiler) {
            MockUp.registerAll();
            server.use(function(req, res, next) {
                MockUp.loadMock(req, res, next);
            });
        },
    }

devServer.before中首先执行了MockUp.registerAll方法,这个方法并未出现在我们对Mock的定义中。其实这里是为了更好的解耦应用和库逻辑,也为了使用插件的时候更方便,又封装了一下Mock导出的方法。它的具体实现如下:

/**
 * @file mock/index.js
 * @description mock api entry
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 */
const Mock = require('./lib');
const User = require('./api/user');

const apiModels = [User];
module.exports = {
    registerAll: () => {
        apiModels.forEach(model => {
            Object.keys(model).forEach(key => {
                Mock.Register(model[key]);
            });
        });
    },
    loadMock: (req, res, next, app, server, compiler) => {
        Mock.LoadMock(req, res, next, app, server, compiler);
    }
};

到这里,mock的整体脉络就比较清晰了。我们定义按实体model对象作为一个模块来划分mock的规则配置是一种好的实践,因此mock文件夹下有了这样的组织方式: 当然,你还可以根据自己的业务需求,在model下面再进行拆分。比api/user/common.jsapi/user/vip.js,一切都是可以调整的。

接口代理
接口的代理作为一个常规的研发需求,已经有很多优秀的社区开源插件可以支持。比较知名的有http-proxy-middleware,可以直接拿来使用。同样因为webpack-dev-server是基于express实现的原因,它可以用与mock同样的方式注册到devServer的中间件调用链中。
除此之外,devServer.proxy默认集成了对外暴露的代理接口。我们可以直接使用,不再引入第三方插件,相关文档可以参考这里。它的使用方式,类似这样:

/**
 * @file proxy.js
 *
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 * @see https://webpack.js.org/configuration/dev-server/#devserverproxy
 * @see https://github.com/chimurai/http-proxy-middleware 
 */
module.exports = {
    '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: { '^/api': '/path' }
    }
};

打包分析
在很多工作场景中,产品发布到了线上,会出现产出包size过大,导致首次加载较慢的问题出现。这种状态的改善,一般依赖于多个方面:启用Gzip压缩、使用CDN、拆分产出包。我们这里只关注第三个部分。
拆分代码是一个相对完整的体系范畴,它包含了多种拆分的原则和操作方法。这一点,我们在生产环境的配置中再详细解读。线上的包体积是要严格控制的,但优化的路径确是提前设计好的,因此我们需要一个工具来分析一下打包出来的产出体积和结构。
webpack-bundle-analyzer是一个比较知名的开源实现,它以可视化的方式,展示了各种产出包的Size和依赖关系。

// webpack.config.dev.js
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const baseWebpackConfig = require('./webpack.config.base.js');

module.exports = merge(baseWebpackConfig, {
    plugins: [
        new BundleAnalyzerPlugin({
            // 'disabled' will close analyser
            analyzerMode: 'server',
        }),
    ],
});

这个插件会随着启动dev脚本的时候,同步启动一个单独的server,并展示各种构建包和依赖关系。鼠标悬浮到某个方块,可以看到它的size信息。

其它配置
除了上面的配置,开发环境还需要有热加载、sourceMap等功能。完整的开发环境配置如下:

/**
 * @file webpack.config.dev.js
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 */
const webpack = require('webpack');
const MockUp = require('../mock/index');
const proxy = require('../src/common/proxy');
const { merge } = require('webpack-merge');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const baseWebpackConfig = require('./webpack.config.base.js');

module.exports = merge(baseWebpackConfig, {
    mode: 'development',
    devtool: 'eval-cheap-source-map',
    stats: 'minimal',
    plugins: [
        new BundleAnalyzerPlugin({
            // 'disabled' will close analyser
            analyzerMode: 'server',
        }),
        new webpack.HotModuleReplacementPlugin(),
    ],
    devServer: {
        hot: true,
        open: true,
        before: function(app, server, compiler) {
            MockUp.registerAll();
            server.use(function(req, res, next) {
                MockUp.loadMock(req, res, next);
            });
        },
        // proxy: proxy
    }
});

webpack.HotModuleReplacementPlugin是启用热加载功能,是webpack^5最新的写法。devtool是配置了sourceMap的生成方式,根据不同的取值,其打包构建的速度也不同,最后生成的map信息粒度也不同。webpack^5中的该字段取值,也发生了一些变化,具体可以参考devtool官方文档
sourceMap的作用原理在另一篇文章《UNDERSCORE.js 源码解析(一)》中,做过详细的说明,这里不再赘述了。

生产环境的配置较为简单,出了产出构建包,再加一个代码拆分的配置就可以了。但为了配合在开发环节就设计好拆分原则,我们将拆分代码的配置抽取到了webapck.config.base.js中。

/**
 * @file webpack.config.base.js
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 */
const path = require('path');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const config = require('./config');

module.exports = {
    ...
    plugins: [
        new CleanWebpackPlugin(),
        new MiniCssExtractPlugin({
            filename: utils.genFilePathWithName('[name].css')
        }),
    ],
    optimization: {
        splitChunks: {
            cacheGroups: {
                vendor: {
                    test: /[\\/]node_modules[\\/]/,
                    name: 'vendor',
                    chunks: 'initial',
                    priority: -10
                },
                default: {
                    minChunks: 2,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }
};

optimization.splitChunks.cacheGroups中定义了两个分组, priority字段定义了优先级,表示当资源同时满足两个分组的匹配时,优先使用哪个规则对资源进行处理。这里是将node_modules中的三方依赖都打包到了vendor.js中,initial表示只抽取与入口文件依赖共享的三方文件。default是另外一个默认分组,表示未命中vendor分组的都使用这条规则。minChunks表示当依赖同时被2个以上的资源文件引用时,就将其抽取成一个单独的包。yarn dev启动脚手架,并访问http://localhost:8080/webpack-dev-server就可以看到拆分后的包结构了:

当然,这里面包含了一些devServer生成的支持运行时调试的文件,但不影响我们关注和评估自己的配置所产生的拆分效果。关于代码拆分的其它配置项使用,可以参看codeSplitting专题split-chunks-plugin插件。所以,生产环境的配置就只剩下一个mode字段的配置,用来标识其环境场景:

/**
 * @file webpack.config.prod.js
 * @author nimingdexiaohai(nimingdexiaohai@163.com)
 */
const config = require('./config');
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const baseWebpackConfig = require('./webpack.config.base.js');

module.exports = merge(baseWebpackConfig, {
    mode: 'production',
    stats: 'minimal',
});

测试
完成了开发环境和生产环境的基础配置。还需要集成一个测试框架来支持我们对一些核心逻辑配套单元测试。

"scripts": {
    "dev": "cross-env NODE_ENV=development webpack serve --config ./build/webpack.config.dev.js",
    "build": "cross-env NODE_ENV=production webpack --config ./build/webpack.config.prod.js --progress --color",
    "test": "cross-env NODE_ENV=test karma start test/karma.conf.js"
  },

我们选用了karma + mocha + karma-spec-reporter构建测试框架。其中,karma是一个用于跑测试case的工具。mocha是一个可以同时支持运行在Node环境和Browser环境的测试框架,它。karma-spec-reporter是用来生成测试结果报告的。
在集成过程中,发现karma-webpack还未支持webpack^5。因此测试集成并未完全完成,又因为不愿意妥协降级到webpack4,所以决定等待webpack^5的支持后,再做进一步的动作。支持计划见github.com/ryanclark/k…

【总结】

到这里,脚手架的基础版就基本完成了。欢迎交流沟通~

微信同步更新: