里程碑5:完成框架npm包抽象封装并发布

2 阅读4分钟

文档知识来源:抖音 “哲玄前端”,《大前端全栈实践课》

注意:原抖音视频课程工程名为elpis,这里由于自己命名成了elips;

一、发布npm包前的准备工作

1、注册一个npm账号,并记住自己的账号名字;(我的账号:choukunbc081/elips)

2、抽离原本的elips,将业务的内容进行抽离,留下公共的框架能力发布成npm包,供大家使用;

3、在本地进行测试自己抽离的npm包

(1)elips使用 npm link 创建符号链接

(2)创建 elpis-demo 工程,并在 elpis-demo 使用 npm link @choukunbc081/elips 链接到 node_modules 目录下。

二、 抽离elips工程,elips-core

修改package.json

{ 
    "name": "@choukunbc081/elips", // 包的唯一名字,此名称必须满足 NPM 的命名规范。使用npm账号
    "version": "1.0.0", // 包的版本 
    "description": "", // 简短的包描述 
    "main": "index.js", // 指定包的入口文件,引入包时会执行的文件 
    "repository": {
        "type": "git",
        "url": "https://git.code.tencent.com/wangjmqz/elips.git"
    },// 包的仓库地址 
    "author": "", // 包的作者 
    "license": "ISC", // 包的许可证 ... 
}

由于elpis是暴露给外部用的,所以很多的插件不再是开发依赖,要挪到dependencies依赖中。

除了以下是devDependencies,其余依赖全部移动到dependencies。

image.png

创建index.js,入口文件

//引入 elpis-core
const ElipsCore = require('./elpis-core');
// 引入前端工程化构建方法
const FEBuildDev = require('./app/webpack/dev.js');
const FEBuildpRrod = require('./app/webpack/prod.js');


module.exports= {

    /**
     * 服务端基础
     */
    Controller:{
        Base: require('./app/controller/base.js')
    },
    Service:{
        Base:require('./app/service/base.js')
    },

    /**
     * 编译构建前端工程
     * @param env 环境变量 local/production
     */
    frontendBuild(env){
        if(env === 'local'){
            FEBuildDev();
        }else if(env === 'production'){
            FEBuildpRrod();
        }
    },
    /**
     * 启动 elips-core
     * @param options 项目配置,透传到elips-core
     */
    serverStart(options = {}){
        const app = ElipsCore.start(options);
        return app;
    }
}

修改BFF层loader,将loader的各个都暴露出去

为了让其他使用npm包的人(自己本地测试,验证抽离内容,需创建工程elips-demo)可以进行自定义内容,elips-core/loader必须需要修改,把自身app目录下的配置和elips-demo app目录下的配置合并到一起;

elpis-core 是一个约定大于配置的服务框架,在使用时,只需要在对应的目录下写下相对应的文件,elpis-core 会帮你配置好其他的细节,并将内容挂载到 app 实例下。

重点:

  • 将原有的loader配置,支持找到elips原有路径内容,也支持找到业务层路径内容;

  • 例如: 原先的配置读取的是 elpis 路径下的文件,需要多读取一个使用 elpis 框架的项目(业务)路径

  • 将原先挂载的操作封装为函数,然后将业务内容与 elpis 内容都挂载到相应的 loadr 上。

elpis 路径:  
     [ __dirname ]/../../app/ [ loader ]
业务路径:
    process.cwd() / [ loader ]

image.png

以config.js为例

const path = require('path');
const { sep } = path;
/*
* config loader
* @param {object} app koa实例
*
* 配置区分 开发/测试/生产
* 目标:根据不同的环境(env.js),拿到不同文件配置(config.js)   env.config
* 通过 env.config 覆盖 default.config 加载到 app.config 中
*
* 在目录下对应的 config 配置
* 默认配置 config/config.default.js
* 本地配置 config/config.local.js
* 测试配置 config/config.beta.js
* 生产配置 config/config.prod.js
* */

module.exports =(app)=> {
    //1.找到 config/ 目录
    const configPath = path.resolve(app.baseDir,`.${sep}config`);//  /Users/apple/Desktop/前端系统架构/workspace/elips/config/config.default

    // elips config 目录及相关文件
    const elipsConfigPath = path.resolve(__dirname,`..${sep}..${sep}config`);
    let defaultConfig = require(path.resolve(elipsConfigPath,`.${sep}config.default.js`));
    //获取业务 config 目录及相关文件
    const businessConfigPath = path.resolve(process.cwd(),`.${sep}config`);

    //2. 获取 default.config
    //为啥写try-catch,因为这个可以存在没有创建config.default文件
    try {
        // 将导入的配置展开并覆盖前面的配置
        defaultConfig = {
            ...defaultConfig,
            ...require(path.resolve(businessConfigPath,`.${sep}config.default.js`))
        }
    }catch (e){
        console.log('[exception] default.config file exception',e);
    }
    //3.获取 env.config
    let envConfig = {};
    try {
        if(app.env.isLocal()){ //本地环境
            envConfig = require(path.resolve(businessConfigPath,`.${sep}config.local.js`));
        }else if(app.env.isBeta()){ //测试环境
            envConfig = require(path.resolve(businessConfigPath,`.${sep}config.beta.js`));
        }else if(app.env.isProduction()){//生产环境
            envConfig = require(path.resolve(businessConfigPath,`.${sep}config.prod.js`));
        }
    }catch (e) {
        console.log('[exception] env.config file exception',e);

    }
    //4. 覆盖并加载 config 配置
    // app.config.xxxxx
    app.config = Object.assign({},defaultConfig,envConfig);
};

在举例controller.js

const path = require('path');
const {sep } = path;
const glob = require('glob');


/*
* controller loader
* @param {object} app Koa实例
*
* 加载所有 controller ,可通过 ’app.controller.${目录}.${文件}' 访问
*
* 例子 :
* 文件关系
* app/controller
*    |
*    | -- custom-module
*               |
*               |-- custom-controller.js
*
* 可以这样拿到 => app.controller.customMoudel.customController
* 解答customMoudel 也就是目录custom-module,-需要用大写代替;
*
*/
module.exports = (app)=> {
    const controller = {}; //目录

    //1.读取 elips/app/controller/**/**.js 下所有文件
    // 1>先获取路径
    // path.resolve 会将路径转换为绝对路径; path.relative 来获取相对于 controllerPath 的相对路径
    const elipsControllerPath = path.resolve(__dirname,`..${sep}..${sep}app${sep}controller`);
    //2>读取所有的文件列表
    const elipsFileList = glob.sync(path.resolve(elipsControllerPath,`.${sep}**${sep}**.js`));
    elipsFileList.forEach(file =>{
        handleFile(file)
    });

    //1.读取 业务/app/controller/**/**.js 下所有文件
    const businessControllerPath = path.resolve(app.businessPath,`.${sep}controller`);
    const businessFileList = glob.sync(path.resolve(businessControllerPath,`.${sep}**${sep}**.js`));
    businessFileList.forEach(file =>{
        handleFile(file)
    });

    //2.遍历所有文件目录,把内容加载到 app.controller 下    
    function handleFile(file){
        //1> 提取文件名称
        let name = path.resolve(file);
        //2>截取路径, 举例: app/controller/custom-moudel/custom-controller.js 截取成 custom-moudel/custom-controller
        name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length,name.lastIndexOf('.'));
        //3> 把‘-’统一改为驼峰式,custom-module 目录 === customMoudel
        // name = name.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
        name = name.replace(/[_-][a-z]/ig,(s) => s.charAt(1).toUpperCase());

        //4> 挂载 controller 到内存 app 对象中
        let tempController = controller;
        const names = name.split(sep);//[customMoudel,customController]

        for (let i = 0,len = names.length;i<len; ++i) {
            if(i === len -1){//文件
                //path.resolve(file) 解析文件路径以确保它是绝对路径
                //require(path.resolve(file)) 加载该文件内容
                //require(path.resolve(file))(app) 调用这个中间件函数
                //因为后面的controler都是一些class ---app/controller/view.js---这个文件return的是class
                const ControlerMoule = require(path.resolve(file))(app);
                tempController[names[i]] = new ControlerMoule();
            }else{ //文件夹
                //如果这个目录不存在,那么就初始化为一个对象
                if(!tempController[names[i]]){
                    tempController[names[i]] = {};
                }
                tempController = tempController[names[i]];
                //1.tempController === {customMoudel : {} }
                //2.tempController === {customMoudel : { customController : { }}}
            }
        }
    }

    app.controller = controller;
};

三、抽离webpack

3.1 暴露webpack给使用方elips-demo

在 elpis 内部,我们分别提供了 dev.js 和 prod.js 来运行 webpack 的开发服务器和打包。我们需要将这些方法暴露出来,只需要将dev.js 和 prod.js 的功能封装成函数并暴露出去即可。

这部分写在了index.js中,也就是入口文件,请看上面第二点

3.2 抽离webpack内容

  1. webpack入口,支持业务方和自己

image.png

  1. 路径别名: 在将 eplis 当做包使用后之前的路径别名称会定位到我们的业务目录下,需要对此作出修改:
// 抽离前 
resolve: {
    extensions: ['.js', '.vue', '.less', '.css'], // 自动补全文件扩展名 
    alias: { $pages: path.resolve(process.cwd(), './app/pages'), // 路径别名 
    $common: path.resolve(process.cwd(), './app/pages/common'), // 路径别名 
    $widgets: path.resolve(process.cwd(), './app/pages/widgets'), // 路径别名 
    $store: path.resolve(process.cwd(), './app/pages/store'), // 路径别名 },
 }, 

// 抽离后 
resolve: { 
    $elpisPages: path.resolve(__dirname, "../../pages"), // 路径别名 
    $elpisBoot: path.resolve(__dirname, "../../pages/boot.js"), 
    $elpisCommon: path.resolve(__dirname, "../../pages/common"), // 路径别名 
    $elpisUtils: path.resolve(__dirname, "../../pages/common/utils.js"), 
    $elpisCurl: path.resolve(__dirname, "../../pages/common/curl.js"), 
    $elpisWidgets: path.resolve(__dirname, "../../pages/widgets"), // 路径别名 
}

image.png

  1. 允许用户提供自己的 webpack 配置

image.png

webpack.base.js:

const glob = require('glob');
const path = require('path');
const fs = require('fs');
const webpack = require('webpack');
const merge = require('webpack-merge');
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');

//动态构造 elipsPageEntries  elipsHtmlWebpackPluginList
const elipsPageEntries = {};
const elipsHtmlWebpackPluginList = [];
// 获取 app/pages 目录下所有入口文件(entry.xxx.js)
const elipsEntryList = path.resolve(__dirname,'../../pages/**/entry.*.js');
glob.sync(elipsEntryList).forEach(file =>{
     handleFile(file,elipsPageEntries,elipsHtmlWebpackPluginList)
});

//动态构造 businessPageEntries  businessHtmlWebpackPluginList
const businessPageEntries = {};
const businessHtmlWebpackPluginList = [];
// 获取 业务 app/pages 目录下所有入口文件(entry.xxx.js)
const businessEntryList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(businessEntryList).forEach(file =>{
     handleFile(file,businessPageEntries,businessHtmlWebpackPluginList);
    
});

// 构造相关 webpack 处理的数据结构
function handleFile(file,entries,HtmlWebpackPluginList = []){
    const entryName = path.basename(file,'.js');
    // 构造 entry 
    entries[entryName] = file;
    
    // 构造最终渲染的页面文件
    HtmlWebpackPluginList.push(
        // html-webpackplugin 辅助注入打包后的 bundle 文件到tpl文件中
        new HtmlWebpackPlugin({
            // 产物(最终模板) 输出路径
            filename: path.resolve(process.cwd(),'./app/public/dist/',`${entryName}.tpl`),
            // 指定要使用的模板文件
            template: path.resolve(__dirname,'../../view/entry.tpl'),
            // 要注入的代码快(这里需要和entry里的对应)
            chunks: [entryName],
        })
    )
}

// 加载业务webpack 配置
let businessWebpackConfig ={};
try{
    businessWebpackConfig = require(`${process.cwd()}/app/webpack.config.js`)
}catch(e){
    console.log(e,"webpack-business-alias")
}

/**
 * webpack 基础配置
 */
module.exports = merge.smart({
    // 入口配置--->单页面,一个入口--entry: 'index',
    // 多入口配置
    entry: Object.assign({},elipsPageEntries,businessPageEntries),
    // 模块解析配置(决定了要加载解释哪些模块,以及用什么方式去解释)--各种loader,例如css-loader/babel-loader
    module: {
        rules: [
            {
                test: /\.vue$/,
                use: {
                    loader: require.resolve('vue-loader')
                }
            },{
                test: /\.js$/,
                include:[
                    // 对业务代码进行 babel, 加快 webpack 打包速度
                    //处理 elips 目录 
                    path.resolve(__dirname,'../../pages'),
                    // 处理 业务 目录
                    path.resolve(process.cwd(),'./app/pages')
                ],
                use: {
                    loader: require.resolve('babel-loader')
                },
            },{
                test: /\.(png|jpe?g|gif)(\?.+)?$/,
                use: {
                    loader: require.resolve('url-loader'),
                    options: {
                        limit: 300,
                        esModule: false
                    }
                }
            },{
                test: /\.css$/,
                use: [
                    require.resolve('style-loader'),
                    require.resolve('css-loader')],
            },{
                test: /\.less$/,
                use: [
                    require.resolve('style-loader'),
                    require.resolve('css-loader'),
                    require.resolve('less-loader')],
            },{
                test: /\.(eoy|svg|ttf|woff|woff2)(\?\S*)?$/,
                use: require.resolve('file-loader')
            }
        ]
    },
    // 产出输出路径,因为开发和生产环境不一致,所以需要再各自的文件里面进行配置
    output: {},
    // 配置模块解析的具体行为(定义webpack 在打包时,如何找到并解析具体模块的路径)
    //例如 imprort {xxxx} from './app/pages/xxx/xxx.js'或者//imprort {xxxx} from '../xxx.vue' ,找这种文件的后缀的一种配置
    // 配置了alias之后,import {xxx} from '$elipsPages/xxx/xxx.js'
    resolve: {
        modules: [
            'node_modules',
            path.resolve(__dirname, '../../../node_modules'),  // elips 的 node_modules
            path.resolve(process.cwd(), 'node_modules'),      // 业务项目的 node_modules
        ],
        extensions: ['.js','.vue','.less','.css'],
        extensions: ['.js','.vue','.less','.css'],
        //别名
        alias:(()=>{
            const aliasMap ={};
            const blankModulePath = path.resolve(__dirname,'../libs/blank.js');

            //dashboard 路由扩展配置  
            const businessDashboardRouterConfig = path.resolve(process.cwd(),'./app/pages/dashboard/router.js');
            aliasMap['$businessDashboardRouterConfig'] = fs.existsSync(businessDashboardRouterConfig) ? businessDashboardRouterConfig :blankModulePath;

            // scheam-view component 扩展配置
            const businessComponentConfig = path.resolve(process.cwd(),'./app/pages/dashboard/complex-view/schema-view/components/component-config.js');
            aliasMap['$businessComponentConfig'] = fs.existsSync(businessComponentConfig) ? businessComponentConfig :blankModulePath;

            // scheam-from component 扩展配置
            const businessFormItemConfig = path.resolve(process.cwd(),'./app/pages/widgets/schema-form/form-item-config.js');
            aliasMap['$businessFormItemConfig'] = fs.existsSync(businessFormItemConfig) ? businessFormItemConfig :blankModulePath;

            // scheam-search component 扩展配置
            const businessSearchItemConfig = path.resolve(process.cwd(),'./app/pages/widgets/schema-search-bar/search-item-config.js');
            aliasMap['$businessSearchItemConfig'] = fs.existsSync(businessSearchItemConfig) ? businessSearchItemConfig :blankModulePath;

            // header-container 扩展配置
            const businessHeaderConfig = path.resolve(process.cwd(),'./app/pages/widgets/header-container/header-config.js');
            aliasMap['$businessHeaderConfig'] = fs.existsSync(businessHeaderConfig) ? businessHeaderConfig :blankModulePath;
                      
            return {
                'vue':require.resolve('vue'),
                $elipsPages: path.resolve(__dirname,'../../pages'),
                $elipsCommon: path.resolve(__dirname,'../../pages/common'),
                $elipsCurl: path.resolve(__dirname,'../../pages/common/curl.js'),
                $elipsUtils: path.resolve(__dirname,'../../pages/common/utils.js'),
                $elipsWidgets: path.resolve(__dirname,'../../pages/widgets'),
                $elipsHeaderContainer: path.resolve(__dirname,'../../pages/widgets/header-container/header-container.vue'),
                $elipsSchemaForm: path.resolve(__dirname,'../../pages/widgets/schema-form/schema-form.vue'),
                $elipsSchemaSearchBar: path.resolve(__dirname,'../../pages/widgets/schema-search-bar/schema-search-bar.vue'),
                $elipsSchemaTable: path.resolve(__dirname,'../../pages/widgets/schema-table/schema-table.vue'),
                $elipsSiderContainer: path.resolve(__dirname,'../../pages/widgets/sider-container/sider-container.vue'),
                $elipsStore: path.resolve(__dirname,'../../pages/store'),
                $elipsBoot:path.resolve(__dirname,'../../pages/boot.js'),
                ...aliasMap
            }
        })()
    },
    // 配置 webpack 插件
    plugins: [
        // 处理 .vue 文件,这个插件是必须的
        // 它的只能是将你定义过的其他规则复制并应用到 .vue 文件里
        // 例如,如果有一条匹配规则 /\.js/ 的规则,那么他会应用到 .vue 文件中的 <script> 模板中
        new VueLoaderPlugin(),
        // 第三方组件 把第三方库暴露到 window context 下
        new webpack.ProvidePlugin({
            Vue: 'vue',
            axios: 'axios',
            _: 'lodash'
        }),
        // 定义全局常量
        new webpack.DefinePlugin({
            __VUE_OPTIONS_API__: true,// 支持 vue 解析 optionsApi
            __VUE_PROD_DEVTOOLS__: false,// 在生产环境中禁用 Devtools 支持
            __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,// 关闭 vue.js 生产环境的客户端渲染警告 禁用生产环境显示 “水合”信息
        }),
        // 构造最终渲染的页面模板
        /**
         * 工程化打包的过程中,
         * 1. path.resolve(process.cwd(),'./app/view/entry.tpl') 找到这个tpl
         * 2. 找到 chunks: [entry.page2]的代码块,将 entry.page2 的代码块注入到 root 的 vue 的模板里面
         * 3. 将注入好的产物,吐出到 filename: path.resolve(process.cwd(),'./app/public/dist/','entry.page2.tpl'), 这个路径下 ,文件名为 entry.page2.tpl
         */
        ...elipsHtmlWebpackPluginList,
        ...businessHtmlWebpackPluginList
    ],
    // 配置打包输出优化(代码分割,模块合并,缓存,TreeShaing,压缩等优化策略)
    // 为何要分包,是因为 如果多个文件(A、B)引用了同一个文件(C)内容是,这时打包只会是在A的里面有C,和B的里面有C,这样导致C重复
    // 所以有了分包的概念,也就是把相同的内容就行提炼
    optimization: {
        // 在做分包,对产出的文件进行分包-splitChunks 这个的配置,是有你自己可以配置的
        /**
         *   哲哥分包策略
         *   把 js 文件打包成3种类型
         *  1. vendor : 第三方 lib ,基本不会动,除非依赖版本升级
         *  2. common : 业务组件代码的公共部分抽取出来,改动较少
         *  3. entry.{page}: 不同页面 entry 里的业务组件代码的差异部分,会经常改动
         *  目的:把改动和引用评率不一样的 js 区分出来,已达到更好利用浏览器缓存的效果
         */
        splitChunks: {
            chunks: 'all',// 对同步和异步模块都进行分割
            maxAsyncRequests: 10, // 每次异步加载的最大进行请求书
            maxInitialRequests: 10,// 入口点的最大并行请求数
            cacheGroups: {
                vendor: { // 第三方依赖库
                    test:/[\\/]node_modules[\\/]/, // 打包node_module 中的文件
                    name: 'vendor', // 模块名称
                    priority: 20, // 优先级,数字越大,优先级越高
                    enforce: true, // 强制执行
                    reuseExistingChunk: true,//复用已有的公共模块chunk
                },
                common: { //公共模块
                    test: /[\\/]common|widgets[\\/]/,
                    name: 'common', // 模块名称
                    minChunks: 2 ,// 被两处引用 即被归为公共模块
                    minSize: 1,// 最小分割文件大小(1 byte)
                    priority: 10,// 优先级
                    reuseExistingChunk: true,// 复用已有的公共 chunk
                }
            }
        },
        // 将webpack 运行时生成的代码打包到 runtime.js
        runtimeChunk: true,
    },
},businessWebpackConfig)

四、提供公共组件能力

以 schema-view 为例,在 schema-view 中,我们提供了动态组件的扩展能力,需要为用户提供在外部扩展的能力。

image.png

import createForm from "./create-form/create-form.vue";
import editForm from "./edit-Form/edit-Form.vue";
import detailPanel from "./detail-panel/detail-panel.vue";

// 业务扩展 component 配置
import BusinessComponentConfig from '$businessComponentConfig';

const ComponentConfig =  {
    createForm:{
        component: createForm
    },
    editForm:{
        component:editForm
    },
    detailPanel:{
        component:detailPanel
    }
}

export default {
    ...ComponentConfig,
    ...BusinessComponentConfig
}; 

五、elips-demo 结构

详细抽离代码:git.code.tencent.com/wangjmqz/el…

image.png ``

六、npm包发布命令

npm config get

npm config set registry

npm login ---这里可以按回车键,会直接到浏览器进行登录,登陆成功后,返回控制台就会显示登录成功了

npm whoami

npm publish --会失败,因为这是一个私有的包

npm publish --access public --第一次提交需要告诉这是一个公有包

如果这一步失败,证明你是第一次注册npm

那么你需要先登录Npm之后,在进行创建token,然后再次执行npm publish --access public ,就会成功了;