Elpis-npm包制作

0 阅读6分钟

prev: Elpis-动态组件设计

接上一篇文章,Elpis项目已经基本完成了。但是为了更方便使用,可以把项目进行改造,发布到 npm 上面。包名叫 @qsb-elpis/elpis

优点:

  1. 别人可以通过安装 npm 包方式使用,方便创建项目,而不是去仓库下载。
  2. 内部的一些源码别人改不了,避免一些新人随意篡改核心的代码。
  3. 后续可以通过 npm 升级,使用方可以更容易同步升级。

缺点就不说了。


既然要变成通用的 npm 包 @qsb-elpis/elpis,那么之前写的业务代码就必须清理掉,把项目挖空。业务代码由使用方通过在自己项目安装 @qsb-elpis/elpis 后,按照约定的方式开发。

大致目录结构如下:

image.png

为方便称呼,我们把要改造成 @qsb-elpis/elpis 的本地项目叫 elpis-base。 elpis-base 的根目录的index.js,不能直接执行启动函数,而是把启动函数暴露给外部,由外部执行。

// 引入 elpis 核心
const Elpis = require("./elpis-core");
// 引入 前端工程构建方法
const FEBuildDev = require("./app/webpack/dev.js");
const FEBuildProd = require("./app/webpack/prod.js");

// TODO: 临时做个标记,证明是本地的,调试完后删掉
console.log("\x1b[31m", "当前正在使用本地 elpis-base 代码", "\x1b[0m");

module.exports = {
  // 控制器与服务
  Controller: {
    Base: require("./app/controller/base.js"),
  },
  Service: {
    Base: require("./app/service/base.js"),
  },
  // 打包构建
  frontendBuild(env) {
    if (env === "local") {
      FEBuildDev();
    } else if (env === "production") {
      FEBuildProd();
    }
  },

  serverStart(options = {}) {
    const app = Elpis.start(options);
    return app;
  },
};

同理 app/webpack/dev.jsapp/webpack/prod.js也要封装成函数,导出给外部使用。以prod.js为例子:

const webpack = require("webpack");
const webpackProdConfig = require("./config/webpack.prod.js");

module.exports = () => {
  console.log("\n building... \n");

  webpack(webpackProdConfig, (err, stats) => {
    if (err) {
      console.log("err ", err);
      return;
    }
    process.stdout.write(
      stats.toString({
        colors: true, // 使用颜色标识
        modules: false, // 不显示每个模块的打包信息
        children: false, // 不显示子编译任务信息
        chunks: false, // 不显示每个代码块的信息
        chunkModules: true, // 显示代码块中模块的信息
      }) + "\n\n"
    );
  });
};

elpis-basepackage.json 的 name 要改成 @qsb-elpis/elpis


同时为了方便边看效果边开发调试,新建一个示例项目 elpis-demo 进行观察和实践,elpis-demo目录 和 elpis-base同一层级。

# 初始化 elpis-demo
npm init -y

package.json 依赖本地的 "@qsb-elpis/elpis": "file:../elpis-base"

{
  "name": "elpis-demo",
  "version": "1.0.0",
  "description": "elpis-demo",
  "main": "server.js",
  "devDependencies": {
    "nodemon": "^3.1.9"
  },
  "scripts": {
    "dev": "_ENV='local' nodemon ./server.js",
    "beta": "_ENV='beta' PORT=8081 node ./server.js",
    "prod": "_ENV='production' node ./server.js",
    "build:dev": "_ENV='local' node --max-old-space-size=4096 ./build.js",
    "build:prod": "_ENV='production' node ./build.js"
  },
  "keywords": [],
  "author": "qsb3008",
  "license": "ISC",
  "dependencies": {
    "@qsb-elpis/elpis": "file:../elpis-base"
  }
}

新建 server.js 作为启动文件

const { serverStart } = require("@qsb-elpis/elpis");

// 启动 elpis 服务
const app = serverStart({
  name: "ElpisDemo",
  icon: "/static/logo.png",
  homePage: "/view/project-list",
});

新建 build.js 作为构建脚本文件。

const { frontendBuild } = require("@qsb-elpis/elpis");

// 编译构建前端工程
frontendBuild(process.env._ENV);

npm install 安装依赖。

npm run build:dev 编译构建前端工程

npm run dev 运行项目。

image.png

所以上面的一系列操作,其实是把原本在 elpis-base 做的打包和运行服务的操作,挪到了 elpis-demo

elpis-demo 项目能够使用本地的 elpis-base,并且正常运行,再往后进行操作。


接下来的工作大都是细节方面的处理,比较繁琐。

由于elpis-base是暴露给外部用的,所以很多的插件不再是开发依赖,要挪到dependencies依赖中。除了以下是devDependencies,其余依赖全部移动到dependencies

"devDependencies": {
    "assert": "^2.0.0",
    "babel-eslint": "^10.0.2",
    "eslint": "^7.32.0",
    "eslint-plugin-import": "^2.28.1",
    "eslint-plugin-vue": "^9.17.0",
    "ghooks": "~1.0.3",
    "mocha": "^6.1.4",
    "supertest": "^4.0.2",
    "validate-commit-msg": "~2.14.0"
}

为了让使用方 elpis-demo,能扩展自定义内容,elpis-base 的 elpis-core/loader 必须进行修改,把自身的app目录下的controller、config、router等配置和elpis-demo app目录下的配置合并到一起。

elpis-core/loader/router.js 为例,修改内容如下。

  // 找到 elpis 路由文件
  const elpisRouterPath = path.resolve(
    __dirname,
    `..${sep}..${sep}app${sep}router`
  );

  // 注册所有 elpis 路由
  const elpisFileList = glob.sync(
    path.resolve(elpisRouterPath, `.${sep}**${sep}**.js`)
  );
  elpisFileList.forEach((file) => {
    // router file 导出一个函数,参数为 app 和 router
    // 执行函数时,会将路由注册到koa-router上
    require(path.resolve(file))(app, router);
  });

  // 找到 业务 路由文件
  const businessRouterPath = path.resolve(app.bussinessPath, `.${sep}router`);

  // 注册所有 业务 路由
  const businessFileList = glob.sync(
    path.resolve(businessRouterPath, `.${sep}**${sep}**.js`)
  );
  businessFileList.forEach((file) => {
    // router file 导出一个函数,参数为 app 和 router
    // 执行函数时,会将路由注册到koa-router上
    require(path.resolve(file))(app, router);
  });

处理的逻辑是把 elpis-baseelpis-demo下的 app/router 的文件都进行加载。以类似的手段处理以下文件:

  • elpis-core/loader/config.js
  • elpis-core/loader/controller.js
  • elpis-core/loader/extend.js
  • elpis-core/loader/middleware.js
  • elpis-core/loader/router-schema.js
  • elpis-core/loader/router.js

require.resolve
  1. require.resolve 是 Node.js 中用于解析模块路径的内置函数,与 require 不同,它仅返回模块的完整路径而不加载模块。
  2. 相对路径解析时,require.resolve 会从当前目录或默认 node_modules 路径查找。

image.png

如图所示,大部分依赖都是写在elpis-base, 而 elpis-demo 依赖了 elpis-base(也就是发布后的@qsb3008-elpis/elpis)。因此,webpack配置文件中,各种用到的loader,都要改为用require.resolve包裹,才能真正找到对应的loader插件。

webpack.base.js 改造如下:

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

// 动态构造 elpis 页面入口和输出配置
const elpisPageEntries = {};
const elpisHtmlWebpackPluginList = [];
// 获取 elpis/app/pages 目录下所有入口文件 entry.[pageName].js
const elpisEntryList = path.resolve(__dirname, "../../pages/**/entry.**.js");
glob.sync(elpisEntryList).forEach((file) => handleFile(file, elpisPageEntries, elpisHtmlWebpackPluginList));

// 动态构造 业务 页面入口和输出配置
const businessPageEntries = {};
const businessHtmlWebpackPluginList = [];

// 获取 business/app/pages 目录下所有入口文件 entry.[pageName].js
const businessEntryList = path.resolve(process.cwd(), "./app/pages/**/entry.**.js");
glob.sync(businessEntryList).forEach((file) => handleFile(file, businessPageEntries, businessHtmlWebpackPluginList));

// 构造相关 webpack 处理数据结构
function handleFile(file, entrys = {}, pluginList = []) {
  const entryName = path.basename(file, ".js");
  // 生成webpack的入口entry配置
  entrys[entryName] = file;
  // 构造最终的渲染页面文件
  pluginList.push(
    new HtmlWebpackPlugin({
      // 产物(最终模板)输出路径
      filename: path.resolve(process.cwd(), "./app/public/dist/", `${entryName}.tpl`),
      // 指定要使用的模板文件
      template: path.resolve(__dirname, "../../view/entry.tpl"),
      // 要注入的代码块
      chunks: [entryName],
    })
  );
}

// 加载用户 自定义 业务webpack配置
let businessWebpackConfig = {};
try {
  businessWebpackConfig = require(`${process.cwd()}/app/webpack.config.js`);
} catch (error) {}

module.exports = merge.smart(
  {
    entry: Object.assign({}, elpisPageEntries, businessPageEntries),
    output: {},
    module: {
      rules: [
        {
          test: /\.vue$/,
          // 不能排除node_modules,eplis发布到npm,最终安装在node_modules,exclude导致编译失败
          // exclude: /node_modules/,
          use: { loader: require.resolve("vue-loader") },
        },
        {
          test: /\.js$/,
          include: [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: 1024,
              esModule: false,
            },
          },
        },
        {
          // 字体文件处理
          test: /\.(woff|woff2|eot|ttf|otf)(\?\S*)?$/,
          use: require.resolve("file-loader"),
        },
      ],
    },
    // 配置模块解析的具体行为(定义webpack在打包时,如何找到并解析具体模块的路径)
    resolve: {
      extensions: [".js", ".vue", ".less", ".css"],
      alias: (() => {
        const aliasMap = {};
        const blankModulePath = path.resolve(__dirname, "../libs/blank.js");

        const businessDashboardRouterConfig = path.resolve(process.cwd(), "./app/pages/dashboard/router.js");
        const isExist = fs.existsSync(businessDashboardRouterConfig);
        aliasMap["$businessDashboardRouterConfig"] = isExist ? businessDashboardRouterConfig : blankModulePath;

        // shcema-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;

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

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

        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"),
          "@babel/runtime/helpers/asyncToGenerator": require.resolve("@babel/runtime/helpers/asyncToGenerator"),
          "@babel/runtime/regenerator": require.resolve("@babel/runtime/regenerator"),
          $elpisPages: path.resolve(__dirname, "../../pages"),
          // 工具库
          $elpisCommon: path.resolve(__dirname, "../../pages/common"),
          $elpisCurl: path.resolve(__dirname, "../../pages/common/curl.js"),
          $elpisUtils: path.resolve(__dirname, "../../pages/common/utils.js"),
          // 元件
          $elpisWidgets: path.resolve(__dirname, "../../pages/widgets"),
          $elpisHeaderContainer: path.resolve(__dirname, "../../pages/widgets/header-container/header-container.vue"),
          $elpisSiderContainer: path.resolve(__dirname, "../../pages/widgets/sider-container/sider-container.vue"),
          $elpisSchemaTable: path.resolve(__dirname, "../../pages/widgets/schema-table/schema-table.vue"),
          $elpisSchemaForm: path.resolve(__dirname, "../../pages/widgets/schema-form/schema-form.vue"),
          $elpisSchemaSearchBar: path.resolve(__dirname, "../../pages/widgets/schema-search-bar/schema-search-bar.vue"),
          // 缓存别名
          $elpisStore: path.resolve(__dirname, "../../pages/store"),
          // 启动文件 别名
          $elpisBoot: path.resolve(__dirname, "../../pages/boot.js"),
          // 扩展路由
          ...aliasMap,
        };
      })(),
    },
    // 配置 webpack 插件
    plugins: [
      new VueLoaderPlugin(),
      // 把第三方库暴露到 window context 下
      new webpack.ProvidePlugin({
        Vue: "vue",
        axios: "axios",
        _: "lodash",
      }),
      // 定义全局变量
      new webpack.DefinePlugin({
        // 在 vue3 中,支持选项式api
        __VUE_OPTIONS_API__: true,
        //禁用生成环境 Vue 调试工具
        __VUE_PROD_DEVTOOLS__: false,
        // 禁用生产环境显示“水合”信息
        __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
      }),
      // 构造最终渲染的页面模板
      ...elpisHtmlWebpackPluginList,
      ...businessHtmlWebpackPluginList,
    ],
  },
  businessWebpackConfig
);

要加载和解析 elpis-demo 的内容,使用 path.resolve(process.cwd(), 路径) 查找。

要加载和解析 elpis-base 的内容,使用 path.resolve(__dirname, 路径) 查找。

同理,反正webpack各个环境的配置,按照以上的方式,根据实际需求进行处理,如 webpack.dev.jswebpack.prod.js文件等。


elpis-demo 添加一些数据进行测试,如果没问题,就可以把 elpis-base发布到npm平台。

npm publish --access=public

目前npm包 @qsb3008-elpis/elpis 里面包含了一套内置的 dashboard 模版,提供了一些预置的组件,schema-formschema-table 等,以及相关的 inputselectinputNumber等配套的元件。

我们可以使用内置的模板,配置出自己的项目,并且可以通过在app目录下继续添加各种组件、controller、service 等进行完善。也可以增加新的模板,当模板和组件使用的频率比较高的时候,我们可以继续沉淀到我们的npm包里面。

引用: 抖音“哲玄前端”《大前端全栈实践》