Elpis — 抽离并发布 npm 包

372 阅读2分钟

Elpis — 抽离并发布 npm 包

一、 准备工作

在发布 npm 包之前,我们需要先在本地做好测试工作,如何在本地将我们的工程作为包来使用呢?

  1. 在 elpis 目录下使用 npm link 创建符号链接
  2. 创建 elpis-demo 工程,并在 elpis-demo 使用 npm link elpis 将 elpis 链接到 node_modules 目录下。

只需要简单的两个步骤就让在本地开始测试啦。

二、抽离 elpis-core

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

module.exports = (app) => {
  // 读取 app/controller/**/**.js 下所有的文件
  const controllerPath = path.resolve(app.businessPath, `.${sep}controller`);
  const fileList = glob.sync(
    path.resolve(controllerPath, `.${sep}**${sep}**.js`)
  );
  // 遍历所有文件目录,把内容加载到 app.controllers 下
  const controllers = {};
  fileList.forEach((file) => {
    // 提取文件名称
    let name = path.resolve(file);
    // 截取路径 app/controller/custom-module/custom-controller.js ==> custom-module/custom-controller
    name = name.substring(
      name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length,
      name.lastIndexOf(".")
    );
    // 把 '-' 更改为驼峰 'custom-module' -> 'customModule'
    name = name.replace(/[_-]([a-z])/gi, (s) => s.substring(1).toUpperCase());

    let temController = controllers;
    const names = name.split(sep); // [customModule(目录), customController(文件)]
    for (let i = 0, len = names.length; i < len; i++) {
      if (i === len - 1) {
        const ControllerModel = require(path.resolve(file))(app);
        temController[names[i]] = new ControllerModel();
      } else {
        if (!temController[names[i]]) {
          temController[names[i]] = {};
        }
        temController = temController[names[i]];
      }
    }
  });
  // 挂载 controller 到内存 app 对象中
  app.controller = controllers;
};

当我们将 elpis 当做包来使用时,这份代码只会为我们自动挂载业务目录下的 contrloler 。我们不仅要将业务上的 controller 挂载到

app 下,还要提供自己的 controller 。所以将上述代码做一点修改:

module.exports = (app) => {
  const controllers = {};
  // 读取 elpis/app/controller/**/**.js 下所有的文件
  const elpisControllerPath = path.resolve(
    __dirname,
    `..${sep}..${sep}app${sep}controller`
  );
  const elpisFileList = glob.sync(
    path.resolve(elpisControllerPath, `.${sep}**${sep}**.js`)
  );
  elpisFileList.forEach((file) => {
    handleFile(file);
  });

  // 读取 业务/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);
  });

  // 把内容加载到 app.controllers 下
  function handleFile(file) {
    // 提取文件名称
    let name = path.resolve(file);
    // 截取路径 app/controller/custom-module/custom-controller.js ==> custom-module/custom-controller
    name = name.substring(
      name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length,
      name.lastIndexOf(".")
    );
    // 把 '-' 更改为驼峰 'custom-module' -> 'customModule'
    name = name.replace(/[_-]([a-z])/gi, (s) => s.substring(1).toUpperCase());

    let temController = controllers;
    const names = name.split(sep); // [customModule(目录), customController(文件)]
    for (let i = 0, len = names.length; i < len; i++) {
      if (i === len - 1) {
        const ControllerModel = require(path.resolve(file))(app);
        temController[names[i]] = new ControllerModel();
      } else {
        if (!temController[names[i]]) {
          temController[names[i]] = {};
        }
        temController = temController[names[i]];
      }
    }
  }
  // 挂载 controller 到内存 app 对象中
  app.controller = controllers;
};

三、抽离 webpack

3.1 暴露 webpack

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

// elpis/app/webpack/dev.js
module.exports = () => {
  // 启动开发服务器
}

// elpis/app/webpack/prod.js
module.exports = () => {
 // 打包...
}

// elpis/index.js
const FEBuildDev = require("./app/webpack/dev.js");
const FEBuildProd = require("./app/webpack/prod.js");
module.exports = () => {
   /**
   * 编译构建前端工程
   * @param {String} env 环境变量 local/production
   */
  frontedBuild(env) {
    if (env === "local") {
      FEBuildDev();
    } else if (env === "production") {
      FEBuildProd();
    }
  },
      
  // 其他方法...
}

3.2 修改 webpack 配置

  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"), // 路径别名
      }
    
  2. Loader: 在外部使用 webpack 打包 loader 会从业务目录下的 node_modules 下定位,而我们的包中提供了自己的 loader ,不需要用户额外的下载其他版本的 loader 。只需要使用 require.resolve('swc-loader') 包裹起来即可。同理 elpis 也提供了自己使用的 vue 版本,使用路径别名的方式给 vue 定位即可。

    resolve: {
      vue: require.resolve('vue')
      // ...
    }
    
  3. **入口:**在 elpis 中,提供了 dashbord 和 project-list 入口文件,除了用户的 entry,内部的入口文件也需要被解析。

    // 动态构造 elpisPageEntries elpisHtmlWebpackPluginList
    const elpisPageEntries = {};
    const elpisHtmlWebpackPluginList = [];
    // 获取 elpis/app/pages 目录下所有入口文件 (entry.xx.js)
    const elpisEntryList = path.resolve(__dirname, "../../pages/**/entry.*.js");
    glob.sync(elpisEntryList).forEach((file) => {
      handleFile(file, elpisPageEntries, elpisHtmlWebpackPluginList);
    });
    
    // 动态构造 businessPageEntries businessHtmlWebpackPluginList
    const businessPageEntries = {};
    const businessHtmlWebpackPluginList = [];
    // 获取 业务/app/pages 目录下所有入口文件 (entry.xx.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-webpack-plugin 辅助注入 bundle 到 tpl 文件中
        new HtmlWebpackPlugin({
          // 产物(最终模板)输出路径
          filename: path.resolve(
            process.cwd(),
            "./app/public/dist/",
            `${entryName}.tpl`
          ),
          // 指定要使用的模板
          template: path.resolve(__dirname, "../../view/entry.tpl"),
          chunks: [entryName], // 引入的 chunk
        })
      );
    }
    
    // webpackConfig
    {
      entry: Object.assign({}, elpisPageEntries, businessPageEntries),
      // ...
    }
    
  4. 允许用户提供自己的 webpack 配置

    // 加载 业务 webpack 配置
    let businessWebpackConfig = {};
    try {
      businessWebpackConfig = require(`${process.cwd()}/app/webpack.config.js`);
    } catch (e) {}
    
    webpackconfig = marge.smart(
      // ...,
      businessWebpackConfig
    )
    

四、提供公共组件扩展

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

// component-config.js
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,
};

若用户未配置自己的component-config.js文件,webpack 在打包时会发生 can not resolve module 的错误,我们需要对这种情况做进一步的处理,将 webpack 配置的中的路径别名的设置使用立即执行函数来避免错误的发生。

// webpackConfig.resolve
resolve: {
      extensions: [".js", ".vue", ".less", ".css"], // 自动补全文件扩展名
      alias: (() => {
        const aliasMap = {};
        const blankModulePath = path.resolve(__dirname, "../../pages/blank");

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

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

        // schema-from 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 component 扩展配置
        const BusinessSearchItemConfig = path.resolve(
          process.cwd(),
          "./app/pages/widgets/schema-search-bar/search-item-config.js"
        );
        aliasMap["$businessSearchItemConfig"] = fs.existsSync(
          BusinessSearchItemConfig
        )
          ? BusinessSearchItemConfig
          : blankModulePath;

        return {
          vue: require.resolve("vue"),
          $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"), // 路径别名
          $elpisHeaderContainer: path.resolve(
            __dirname,
            "../../pages/widgets/header-container/header-container.vue"
          ),
          $elpisSiderContainer: path.resolve(
            __dirname,
            "../../pages/widgets/sider-container/sider-container.vue"
          ),
          $elpisSchemaForm: path.resolve(
            __dirname,
            "../../pages/widgets/schema-form/schema-form.vue"
          ),
          $elpisSchemaTable: path.resolve(
            __dirname,
            "../../pages/widgets/schema-table/schema-table.vue"
          ),
          $elpisSchemaSearchBar: path.resolve(
            __dirname,
            "../../pages/widgets/schema-search-bar/schema-search-bar.vue"
          ),
          $elpisStore: path.resolve(__dirname, "../../pages/store"), // 路径别名
          ...aliasMap,
        };
      })(),
    },

五、总结

在抽离 npm 包的整个过程需要对各个环节细致把控,充分考虑不同目录下资源的处理以及用户的定制需求,如此才能成功抽离并发布功能完备、适应性强的 npm 包。

出处: 《哲玄课堂-大前端全栈实践》