整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

多页面项目webpack打包实践

多页面项目webpack打包实践

在最近的项目开发中,涉及到了多页面的 webpack 打包,以下是我项目过程中的一些踩坑总结。

前言

项目使用了 vue 作为框架来开发前端页面,其中需要开发多个前端页面,包括有登录、进游戏、充值等等。作为vue最佳的打包工具—— webpack,需要将各个页面分别打包到不同模板目录里。

但默认的 vue 项目框架是单页面应用的,并不能达到项目开发的目的。这就需要调整 webpack 的配置来实现多页面的发布处理。

以下是目录结构:

project
├───bin
│   └───vb.js
├───build
│   │   dev.js
│   │   release.js
│   │   webpack.config.base.js
│   │   webpack.config.build.js
│   └───webpack.config.dev.js
│   README.md
│   package.json
└───src
    ├───components
    │   │   count.vue
    │   │   dialog.vue
    │   │   errortips.vue
    │   └───...
    ├───game
    │   │   game.htm
    │   │   game.js
    │   └───game.vue
    ├───login
    │   │   login.htm
    │   │   login.js
    │   └───login.vue
    ├───pay
    │   │   pay_result.htm
    │   │   pay_result.js
    │   │   pay_result.vue
    │   │   pay.htm
    │   │   pay.js
    │   └───pay.vue
    └───...


修改配置前的一些知识

我们知道webpack的核心是一切皆模块,所以它本质上是一个静态模块打包器。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图,其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

官网显示的这幅图很形象地描述了这个过程。


从 webpack v4.0.0 开始,webpack 提供了一系列的配置默认项,让开发者可以零配置打包,不再强制要求必须进行繁琐的 webpack 配置,让开发者可以从繁琐的配置文件里抽出,专注应用的开发。但是若你需要有特殊的处理,webpack 仍然可以进行高度可配置来满足你的需求。

在开始前需要了解四个核心概念:

  • 入口(entry):指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。


  • 输出(output):指示 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件


  • loader:让 webpack 能够去处理那些非 JavaScript 文件


  • 插件(plugins):可以让 webpack 执行范围更广的任务。


本篇将会针对这4个核心配置的修改和优化来实现多页面打包。在 webpack4 的版本,还新增了一个 mode 配置项。mode 有两个值:development 或者是 production,用户可以启用相应模式下的 webpack 内置的优化。不同 mode 的区别与默认配置可以参考:https://segmentfault.com/a/1190000013712229


一、入口配置

在单页面应用里,一般在根目录下面会有一个 index.html 文件。它是页面的 html 模板文件。但是在多页面应用里,则会有多个应用模板文件,为了方便管理,可以将不同类的入口文件、逻辑处理和模板文件分别存放在相应的独立目录。若用到了组件,则单独将组件存放在一个目录。

project
└───src
    ├───components
    │   │   count.vue
    │   │   dialog.vue
    │   │   errortips.vue
    │   └───...
    ├───game
    │   │   game.htm
    │   │   game.js
    │   └───game.vue
    ├───login
    │   │   login.htm
    │   │   login.js
    │   └───login.vue
    ├───pay
    │   │   pay_result.htm
    │   │   pay_result.js
    │   │   pay_result.vue
    │   │   pay.htm
    │   │   pay.js
    │   └───pay.vue
    └───...


webpack 的入口配置中是支持多入口的,给 entry 传入对象即可,如下所示:

const config={
  entry: {
    game: './src/game/game.js',
    login: './src/login/login.js',
    pay: './src/pay/pay.js',
    pay_result: './src/pay/pay_result.js'
  }
};

但这样的配置对于未知页面数量的项目并不友好,若每新增页面都要重新配置和重启程序,显然是不合理的。而我们可以创建一个getEntry()的方法来遍历文件夹来获取入口。

const fs=require('fs');
const glob=require("glob"); 
function getEntry() {
    const entry={};
    //读取src目录所有page入口
    glob.sync('./src/*/*.js') //获取符合正则的文件数组
        .forEach(function (filePath) {
            var name=filePath.match(/\/src\/(.+)\/*.js/);
            name=name[1];
            //须有配套的模板文件才认为是入口
            if (!fs.existsSync('./src/' + name + '.htm')) {
                return;
            }
            entry[name]=filePath;
        });
    return entry;
};
module.exports={
  // 多入口
  entry: getEntry(),
}


二、输出配置

输出配置仅需指定一个

const config={
    output: {
        path: path.join(__projectDir, __setting.distJs),
        publicPath: __setting.domainJs, //自定义变量,用来定义公共静态资源路径
        filename: '[name][hash].js'
    },
};
  • path:目标输出目录的绝对路径
  • publicPath:文件中静态资源的引用路径

https://www.webpackjs.com/configuration/output/#output-publicpath

  • filename:用于输出文件的文件名

https://www.webpackjs.com/configuration/output/#output-filename

在配置中有以下几点需要注意:

  • publicPath

publicPath 是指定在浏览器中所引用的「此输出目录对应的公开 URL」。

简单的例子:

publicPath: "https://cdn.example.com/assets/"

输出到html则变成

<script src="https://cdn.example.com/assets/bundle.js"></script>

这个属性是整个项目共用的静态资源路径,若某个模块需要使用其他的静态资源路径。webpack 提供了__webpack_public_path__来动态设置 publicPath,只需在入口文件的最顶部定义即可。

__webpack_public_path__=myRuntimePublicPath; // 一定要写在最顶部
  • hash

filename的[hash]是以项目为维度的 hash 值,若输出了多个文件,则文件名都会共用一个 hash 值。

filename的[chunkhash]是以chunk为维度生成的 hash 值,不同入口生成不同的 chunkhash 值。

filename的[contenthash]根据资源内容生成的 hash 值。

通常使用 hash 或 chunkhash,contenthash 通常用于某些特殊场景(官方文档在使用 ExtractTextWebpackPlugin 插件时有使用)。

https://www.webpackjs.com/plugins/extract-text-webpack-plugin/


三、loader配置

由于 webpack 只能理解 JavaScript 和 JSON 文件。而配置 loader 就是让 webpack 能够去处理其他类型的文件,并将它们转换为有效模块。

loader 可以使开发者在 import 或"加载"模块时预处理文件。例如,将内联图像转换为 data URL,或者允许开发者直接在 JavaScript 模块中 import CSS文件

1、js 模块

加载js模块,我们通常是为了引入babel,让其能将ES6的语法转成ES5,让项目能在低版本的浏览器上运行。

js文件需要使用babel的话,引入babel-loader

const config={
    module: {
        rules: [{
            test: /\.js$/,
            include: [path.resolve(__projectDir, 'src')], //通过include精确指定只处理哪些目录下的文件
            exclude: /node_modules/, //设置哪些目录里的文件不进行处理
            loader: "babel-loader"
        }]
    }
}

但仅仅配置了babel-loader还不够,还需要配置 babel 的环境,需要引入 polyfill。

引入 polyfill 的方式有很多种,根据 vue 官方文档在浏览器兼容性的处理,默认使用的是@vue/babel-preset-app ,它通过@babel/preset-env和browserslist配置来决定项目需要的 polyfill。

https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/babel-preset-app


browserslist

项目根目录创建.browserslist文件

> 1%
last 2 versions

当然,你也可以在package.json文件里添加的browserslist字段来配置。

这个配置的目的是为了指定了项目的目标浏览器的范围,配置的值会被 @babel/preset-env 用来确定需要转译的 JavaScript 特性。

详细的说明可以查阅 https://github.com/browserslist/browserslist,了解如何指定浏览器范围。

Polyfill

项目根目录创建.babelrc文件

{
  "presets": [
    ["@babel/preset-env",
      {
        "modules": false, // 对ES6的模块文件不做转化,以便使用tree shaking、sideEffects等
        "useBuiltIns": "entry", // browserslist环境不支持的所有垫片都导入
        "corejs": {
          "version": 3, // 使用core-js@3
          "proposals": true
        }
      }
    ]
  ]
}

这里特别说下的是配置里的useBuiltIns,可设置的值分别是"usage" | "entry" | false,3个值分别代表:

  • usage 会根据配置的浏览器兼容,以及你代码中用到的 API 来进行 polyfill,实现了按需添加。
  • entry 根据配置的浏览器兼容,引入浏览器不兼容的 polyfill。这时会自动根据 browserslist 替换成浏览器不兼容的所有 polyfill。
  • false 此时不对 polyfill 做操作。如果引入@babel/polyfill,则无视配置的浏览器兼容,引入所有的 polyfill。

项目使用的是"useBuiltIns": "entry",所以需要指定corejs的版本,这里使用的版本是core-js@3,所以我们在 webpack 的入口配置里加上"core-js/stable"和 "regenerator-runtime/runtime"。

function getEntry() {
    const entry={};
    //读取src目录所有page入口
    glob.sync('./src/*/*.js') //获取符合正则的文件数组
        .forEach(function (filePath) {
            var name=filePath.match(/\/src\/(.+)\/*.js/);
            name=name[1];
            //须有配套的模板文件才认为是入口
            if (!fs.existsSync('./src/' + name + '.htm')) {
                return;
            }
            entry[name]=["core-js/stable", "regenerator-runtime/runtime", path.join(__projectDir, filePath)];
        });
    return entry;
};


2、css 模块

我们通常使用style-loader和css-loader。css-loader用来处理 js 文件中引入的 css 模块(处理@import和url()),style-loader是将css-loader打包好的css代码以<style>标签的形式插入到 html 文件中。而 webpack 对于 loader 的调用是从右往左的,所以通常是这样配置:

{
    test: /\.css$/,
    use: [ 'style-loader', 'css-loader' ]
}

我们在项目中还经常会使用 sass 或者 scss。sass 是一种 CSS 的预编译语言。因此 webpack 要将其处理会使用更多 loader。

{
    test: /\.(sc|sa)ss$/,
    use: [{
        loader: 'vue-style-loader'
    }, {
        loader: 'css-loader',
        options: {
            sourceMap: true,
        }
    }, {
        loader: 'postcss-loader',
        options: {
            sourceMap: true
        }
    }, {
        loader: 'sass-loader',
        options: {
            sourceMap: true
        }
    }, {
        loader: 'sass-resources-loader', //组件里面使用全局scss
        options: {
            sourceMap: true,
            resources: [
                path.resolve('./src/public/css/common.scss')
            ]
        }
    }]
}

在使用sass-loader的时候若某个 scss 文件(比如a.scss)@import 了其他 scss 文件(比如b.scss),如果b.scss里的url()的路径是相对路径,在sass-loader处理过后给css-loader处理时就会报错,找不到url()里指定的资源。

这是因为sass-loader处理时,会将 scss 文件里 @import 路径的文件一并合并进来,结合上面的例子就是b.scss会被sass-loader合并进a.scss。

如何解决呢?可以有两个解决方法:

  • 将资源路径改为变量来统一管理
  • 通过 alias 设置路径别名,从而便捷使用绝对路径。注意在scss文件中使用 alias 里定义的路径别名时,需要带上~前缀,否则打包时仍会被识别为普通路径。

在项目中由于还用到了postcss-loader,我们还须要在根目录创建postcss-loader的配置文件postcss.config.js

//自动添加css浏览器前缀
module.exports={
    plugins: [
        require('autoprefixer')
    ]
}


3、图片等静态资源

对于图片资源的打包,经常会使用file-loader来完成,配置也很简单:

{
  test: /\.(gif|png|jpe?g)$/,
  loader: 'file-loader',
}

打包后,会将图片移动到了 dist 目录下,并将该图片改名为[hash].[ext]格式的图片。开发者也可以根据需要,修改输出的文件名。

但在项目开发过程中,我们会创建很多张图片,这就使得页面在加载是时候会发送很多http请求,当页面图片过多,会影响的页面的性能。所以,这里推荐使用url-loader。

{
    test: /\.(png|jpg|jepg|svg|gif)$/,
    use: [{
        loader: 'url-loader',
        options: {
            limit: 10240, //这里的单位是b
            name: 'image/[name][hash].[ext]' //打包后输出路径
        }
    }]
}

使用url-loader我们可以通过设置limit的值,将文件大小小于某个值的图片打包成base64的形式存放在打包后的 js 中,若超过了这个设定值,默认会使用file-loader(所以虽然代码没有配置 file-loader,但还是需要使用安装file-loader),并且会将配置的选项传递给file-loader。


4、import AMD 模块

有时我们需要在项目里使用一些 AMD 模块或者完全不支持模块化的库。例如移动端经常使用的 zepto。如果我们直接使用 import zepto 的方式引入是会报错的:Uncaught TypeError: Cannot read property 'createElement' of undefined

要使用也很简单,使用script-loader和exports-loader即可:

{
    test: require.resolve('zepto'),
    use: ['exports-loader?window.Zepto','script-loader']
}
  • script-loader 用 eval 的方法将 zepto 在引入的时候执行了一遍,此时 zepto 库已存在于 window.Zepto
  • exports-loader 将传入的 window.Zepto 以 module.exports=window.Zepto 的形式向外暴露接口,使这个模块符合 CommonJS 规范,支持 import 这样我们就可以直接import $ from 'zepto'了,其他 AMD 模块或者其他不支持模块化的库也类似。



四、plugins

webpack 可以使用插件(plugins)来让开发者能够在打包的过程中实现更多功能,插件会在整个构建过程中生效,并执行相关的任务。这里会介绍几个比较实用的插件:

1、mini-css-extract-plugin

在使用style-loader处理后,css 文件会作为模块打包进 js 文件里。若我们想将 js 文件和 css 文件分离。就可以使用mini-css-extract-plugin:

module: {
    rules: [{
        test: /\.css$/,
        use: [{
            loader: MiniCssExtractPlugin.loader
        },
            'css-loader'
        ]
    }]
},
plugins: [
    new MiniCssExtractPlugin({
        filename: 'css/[hash].css'
    })
]


2、copy-webpack-plugin

有时候我们会有一些没经过打包的文件需要复制到我们的生产目录里,copy-webpack-plugin就可以实现这个功能。

plugins: [
    new CopyWebpackPlugin([
        {
            from: { glob: './src/public/*.htm', dot: true },
            to: path.join(__setting.distTpl, 'public','[name].htm')
        }
    ], { copyUnmodified: true })
]


3、html-webpack-plugin

我们前面介绍入口配置的时候会看到只配置了 js 文件,只是因为 webpack 现在入口只支持 js 文件,所以打包输出的也是 js 文件,那如果我们需要将 js 文件引入到 html 里,就需要使用到html-webpack-plugin插件。

html-webpack-plugin在使用的时候,是必须一个入口对应一个配置的,所以我们前面使用了多页面的配置,也需要进行相应的修改,修改后的getEntry方法:

const htmlPluginArray=[];
function getEntry() {
    const entry={};
    //读取src目录所有page入口
    glob.sync('./src/' + __setting.moduleId + '/*.js')
        .forEach(function (filePath) {
            var name=filePath.match(/\/src\/(.+)\/*.js/);
            name=name[1];
            if (!fs.existsSync(path.join(__projectDir, './src/' + name + '.htm'))) {
                return;
            }
            entry[name]=["core-js/stable", "regenerator-runtime/runtime", path.join(__projectDir, filePath)];
+           htmlPluginArray.push(new HtmlWebpackPlugin({
+               filename: `${__setting.distTpl}/${name}.htm`,
+               template: './src/' + name + '.htm',
+               inject: 'body',
+               minify: {
+                   removeComments: true,
+                   collapseWhitespace: true
+               },
+               chunks: [name],
+               inlineSource: '.(js|css)'
+           }))
        });
    return entry;
};


// 配置plugin,由于plugins通常使用数组类型来配置,
// 所以可以使用concat方法将配置好的html的数组添加进去。
plugins: [
    new MiniCssExtractPlugin({
        filename: 'css/[hash].css'
    })
].concat(htmlPluginArray),


里面的一些配置是要注意一下的:

  • filename

filename 是配置需要将 html 改成什么名字并输出到哪里的配置。这里配置的的路径是以 output 里配置的path为相对路径的,我们上面 output 配置的是

path: path.join(__projectDir, __setting.distJs)

那最终的html输出路径就是

path.join(__projectDir, __setting.distJs, 
`${__setting.distTpl}/${name}.htm`)
  • minify

是将html里的代码进行压缩。如果 minify 选项设置为 true 或者配置对象 ( true 是 webpack 模式为 production 时的默认值),生成的 HTML 将使用 HTML-minifier压缩代码,更多具体的配置可以看这里minification。

  • 其他

template 生成 filename 文件的模版。重点:与 filename 的路径不同, 当匹配模版路径的时候将会从项目的根路径开始。

inject 制定 webpack 打包的 js css 静态资源插入到 html 的位置。

chunks 指定模板允许添加哪个入口文件。若不配置这个会将所有的入口都添加进来。


4、html-webpack-inline-source-plugin

若我们想将打包好的 js 代码 inline 进 html 的话,就要使用到html-webpack-inline-source-plugin

可以看到上面html-webpack-plugin的配置里有inlineSource: '.(js|css)'

这就是告诉html-webpack-inline-source-plugin需要将打包好的代码 inline 进 html 里,插件需要添加到html-webpack-plugin的配置后

plugins: [
    new MiniCssExtractPlugin({
        filename: 'css/[hash].css'
    })
].concat(htmlPluginArray).concat([
    new HtmlWebpackInlineSourcePlugin()
])

但是html-webpack-inline-source-plugin也仅能将打包后输出的 js 文件引入 html,若你想将 html 码其他使用 script 标签加载的 js 文件或者 style 标签加载的 css 文件也 inline 进 html 里的话,html-webpack-inline-source-plugin并不能实现。从html-webpack-plugin里的 Issues 来看,html-webpack-plugin的作者也无意做这样的事情,但也给出了建议,可以借助html-webpack-plugin插件的 hooks html-webpack-plugin-before-html-processing达到我们需要的效果。


5、自定义插件

上面说到要将外部的静态文件也 inline 进 html,我们可以编写自定义插件,借助html-webpack-plugin插件的 hooks html-webpack-plugin-before-html-processing,再结合inline-source组件来实现我们的功能。

const {
    inlineSource
}=require('inline-source');//加载inline-source组件
//定义方法
function scriptInlineHtml(options) {
    // Configure your plugin with options...
    this.options=options || {};
}


scriptInlineHtml.prototype.apply=function (compiler) {
    let that=this;
    (compiler.hooks ? //判断webpack版本,4.0以上和4.0以下的处理不一样
        compiler.hooks.compilation.tap.bind(compiler.hooks.compilation, 'script-inline-html') :
        compiler.plugin.bind(compiler, 'compilation'))(function (compilation) {
            (compilation.hooks ?
                compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing.tapAsync.bind(compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing, 'script-inline-html') :
                compilation.plugin.bind(compilation, 'html-webpack-plugin-before-html-processing'))(async function (htmlPluginData, callback) {


                    //获取的html内容处理后重新赋值;
                    try {
                        htmlPluginData.html=await inlineSource(htmlPluginData.html, that.options);
                        // Do something with html
                    } catch (err) {
                        // Handle error
                    }
                    //继续执行下个插件
                    callback(null, htmlPluginData);
                });
        });
};
//webpack插件添加
plugins: [
    new MiniCssExtractPlugin({
        filename: 'css/[hash].css'
    })
].concat(htmlPluginArray).concat([
    new scriptInlineHtml(),
    new HtmlWebpackInlineSourcePlugin()
])

使用

<script src="/src/public/js/px2rem.js" inline></script>

这里结合 inline 静态资源,简单介绍了自定义插件的使用,在html-webpack-plugin构建 html 过程中,还提供其他一系列的事件。

Async:

  • html-webpack-plugin-before-html-generation
  • html-webpack-plugin-before-html-processing
  • html-webpack-plugin-alter-asset-tags
  • html-webpack-plugin-after-html-processing
  • html-webpack-plugin-after-emit

Sync:

  • html-webpack-plugin-alter-chunks

这些事件可以让我们在构建 html 的不同阶段里,通过一些处理来达到我们的目的。例如:可以结合smarty.js将使用了 smarty 的模板,引入一些模拟数据后解析成正常的html代码;读取 HTML 文件进行翻译文本的替换,实现页面的多语言化。打包不同皮肤的html文件等等。



五、其他配置

1、resolve

resolve 配置规定了 webpack 如何寻找各个依赖模块。

前面有讲到使用 alias 设置路径别名。在资源引用时,如果资源引用路径太深,又比较常用,我们可以定义路径别名,例如:

resolve: {
    alias: {
        '@': path.resolve(__projectDir, 'src')
    }
}

我们就可以直接在代码中这样引用了:

let backimg=require("@/public/image/common/ico-back.png").default;


2、webpack dev server

webpack-dev-server是开发时的必备利器,它可以在本地起一个简单的 web 服务器,当文件发生变化时,能够实时重新加载。webpack-dev-server的配置也很简单:

devServer: {
    contentBase: __projectDir, //页面的基础目录
    publicPath:'/',
    port: 8080,
    host: '127.0.0.1',
    open: true, //是否运行后自动打开浏览器
    hot: true
}

启动 webpack-dev-server 后,在目标文件夹中是看不到编译后的文件的,实时编译后的文件都保存到了内存当中。

1) HMR

hot设置为 true 是启用 webpack 的 模块热替换( HMR )功能,但这里注意必须要添加插件webpack.HotModuleReplacementPlugin 才能完全启用 HMR

2) publicPath

publicPath 路径下的打包文件可以在浏览器中访问,webpack-dev-server 打包的内容是放在内存中的,并没有实际创建文件,这些打包后的资源对外的的根目录就是 publicPath。

默认 devServer.publicPath 是 '/',所以你的包( bundle )可以通过 http://127.0.0.1:8080/bundle.js 访问。注意:当这里的 publicPath 和 output 的 publicPath 同时设置时,这里的优先级更高。


总结

webpack 的配置能介绍的点其实还有很多,例如开发环境和生产环境进行配置分离;利用浏览器的缓存将公共的模块抽离分开打包;还有很多常用 plugins 插件等等。

这篇文章是以我在开发某个多页面应用项目为例,总结了一些我在webpack配置上的理解。希望能对浏览这篇文章的小伙伴有帮助。



作者:HZH

来源-微信公众号:三七互娱技术团队

出处:https://mp.weixin.qq.com/s/JzZDqe-f_NRMmdxDLXC7tQ

问大家一个问题,曾经的你是否也遇到过,一个项目中有好几个页面长得基本相同,但又差那么一点,想用 vue extends 继承它又不能按需继承html模板部分,恰好 B 页面需要用的 A 页面 80% 的模板,剩下的 20% 由 B 页面自定义,举个栗子:

我们假设这是两个页面,B页面比A页面多了个p标签,剩余的东西都一样,难道仅仅是因为这一个 p标签就要重新写一份模板吗?相信大部分伙伴解决方式是把公共部分抽成一个组件来用,这是一个好的做法。没错,但是来了,老板让你在 标题1、标题2下面分别插入一段内容,这会儿你是不是头大了?难道只能重写一份了吗?当然不是,来开始我们的填坑之路~(当你的业务能用插槽或者组件抽离的方式固然更好,以下内容仅针对当你项目达到一定体量,vue老三套难以处理的情况下采用)

准备工作

准备以下工具包:

  • node-html-parser: 将html生成dom树 官网
npm install --save node-html-parser


思路

  1. 子页面提供继承的父页面的路径,如下:
<template extend="./xxx.vue">
</template>


  1. 子页面需要通过一个自定义标签(假设是 extend)的方式,来决定如何拓展父页面,如下就应该是一个替换的操作,它最少应该具备拓展类型 type 与目标节点 target 属性。
<template extend="./xxx.vue">
  <div>
    <extend type="replace" target="#div_1">
      <a>通过replace替换掉父页面下id为div_1的元素 </a>
    </extend>
  </div>
</template>


最终它生成的应该是除了 id 为 div_1元素被<a>通过replace替换掉父页面下id为div_1的元素 </a>替换掉之外,剩下的全部和xxx.vue一样的页面。

梳理需求点

子页面继承父页面既可以完全继承,也可以通过某种方式以父页面为基板,对其进行增、删、改。方便理解,我们先定义一个自定义标签 extend,子页面通过该标签对其继承的页面操刀动手术,为了实现一个比较完善的继承拓展,extend 标签需要具备以下属性:

Extend Attributes

参数

说明

类型

可选值

type

指定扩展类型

string

insert(插入)、replace(替换)、remove(移除)、append(向子集追加)

position

指定插入的位置(仅在 type 取值 insert 时生效)

string

before(目标前)、after(目标后)

指定插入的位置(仅在 type 取值 append 时生效,用于指定插入成为第几个子节点)

number

-

target

指定扩展的目标

string


实现需求

新建一个vue2的项目,项目结构如下:

我们的继承拓展通过自定义loader在编译的时候实现,进入到src/loader/index.js

const extend=require('./extend');
module.exports=function (source) {
     // 当前模块目录
     const resourcePath=this.resourcePath;
     // 合并
     const result=new extend(source, resourcePath).mergePage();
     // console.log('result :>> ', result);
     // 返回合并后的内容
     this.callback(null, result);
};


实现继承拓展主要逻辑代码:src/loader/extend.js

const parser=require('node-html-parser');
const fs=require('fs');
const pathFile=require('path');
/**
 * 通过node-html-parser解析页面文件重组模板
 * @param {String} source 页面内容
 * @param {String} resourcePath 页面目录
 * @returns {String} 重组后的文件内容
 */
class Extend {
    constructor(source, resourcePath) {
        this.source=source;
        this.resourcePath=resourcePath;
    }
    // 合并页面
    mergePage() {
        // 通过node-html-parser解析模板文件
        const pageAst=parser.parse(this.source).removeWhitespace();
        // 获取template标签extend属性值
        const extendPath=pageAst.querySelector('template').getAttribute('extend');
        if (!extendPath) {
            return pageAst.toString();
        }
        // extendPath文件内容
        const extendContent=fs.readFileSync(pathFile.resolve(pathFile.dirname(this.resourcePath), extendPath), 'utf-8');
        // extendContent文件解析
        const extendAst=parser.parse(extendContent).removeWhitespace();
        // 获取页面文件标签为extend的元素
        const extendElements=pageAst.querySelectorAll('extend');

        extendElements.forEach((el)=> {
            // 获取对应属性值
            const type=el.getAttribute('type');
            const target=el.getAttribute('target');
            const position=parseInt(el.getAttribute('position'));

            // 匹配模板符合target的元素
            let templateElements=extendAst.querySelectorAll(target);

            // type属性为insert
            if (type==='insert') {
                templateElements.forEach((tel)=> {
                    // 通过position属性判断插入位置 默认为after
                    if (position==='before') {
                        el.childNodes.forEach((child)=> {
                            tel.insertAdjacentHTML('beforebegin', child.toString());
                        });
                    } else {
                        el.childNodes.forEach((child)=> {
                            tel.insertAdjacentHTML('afterend', child.toString());
                        });
                    }
                });
            }
            // type属性为append
            if (type==='append') {
                templateElements.forEach((tel)=> {
                   const elNodes=el.childNodes;
                   let tlNodes=tel.childNodes;
                   const len=tlNodes.filter((node)=> node.nodeType===1 || node.nodeType===3).length;
                    // 未传position属性或不为数字、大于len、小于0时默认插入到最后
                    if(isNaN(position) || position > len || position <=0){
                        elNodes.forEach((child)=> {
                            tel.insertAdjacentHTML('beforeend', child.toString());
                        });
                    }else {
                        tlNodes=[...tlNodes.slice(0, position-1), ...elNodes, ...tlNodes.slice(position-1)]
                        tel.set_content(tlNodes);
                    }
                });
            }
            // type属性为replace
            if (type==='replace') {
                templateElements.forEach((tel)=> {
                    tel.replaceWith(...el.childNodes);
                });
            }
            // type属性为remove
            if (type==='remove') {
                templateElements.forEach((tel)=> {
                    tel.remove();
                });
            }
        });
        // 重组文件内容
        const template=extendAst.querySelector('template').toString();
        const script=pageAst.querySelector('script').toString();
        const style=extendAst.querySelector('style').toString() + pageAst.querySelector('style').toString() 
        return`${template}${script}${style}`
    }

}
module.exports=Extend;


好的,自定义loader已经编写完成,在vue.config.js里面配置好我们的loader

const { defineConfig }=require('@vue/cli-service')
module.exports=defineConfig({
  configureWebpack: {
    module: {
      rules: [
        {
          test: /\.vue$/,
          use: [
            {
              loader: require.resolve('./src/loader'),
            },
          ],
        },
      ],
    },
  },
})


接下来我们尝试编写A页面和B页面:

A.vue:

<template>
  <div class="template">
      <div id="div_1" class="div">父页面的div_1</div>
      <div id="div_2" class="div">父页面的div_2</div>
      <div id="div_3" class="div">父页面的div_3</div>
      <div id="div_4" class="div">父页面的div_4</div>
      <div id="div_5" class="div">父页面的div_5</div>
      <div id="div_6" class="div">父页面的div_6</div>
      <div id="div_7" class="div">父页面的div_7</div>
      <div id="div_8" class="div">父页面的div_8</div>
  </div>
</template>
<script>
export default {
  name: 'COM_A',
  props: {
    msg: String
  }
}
</script>
<style scoped>
.div {
  color: #42b983;
  font-size: 1.5em;
  margin: 0.5em;
  padding: 0.5em;
  border: 2px solid #42b983; 
  border-radius:  0.2em;
}
</style>


B.vue:

<template extend="./A.vue">
  <div>
    <extend type="insert" target="#div_1" position="after">
      <div id="div_child" class="div">子页面的div_5</div>
    </extend>
    <extend type="append" target="#div_3" position="2">
      <a> 子页面通过append插入的超链接 </a>
    </extend>
  </div>
</template>
<script>
import A from './A.vue'
export default {
  name: 'COM_B',
  extends: A,//继承业务逻辑代码
  props: {
    msg: String
  }
}
</script>
<style scoped>
#div_child {
  color: #d68924;
  font-size: 1.5em;
  margin: 0.5em;
  padding: 0.5em;
  border: 2px solid #d68924;
}
a {
  color: blue;
  font-size: 0.7em;
}
</style>


我们在App.vue下引入B.vue

<template>
  <div id="app">
    <B/>
  </div>
</template>
<script>
import B from './components/B.vue'
export default {
  name: 'App',
  components: {
    B
  }
}
</script>
<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>


当我们执行编译的时候,实际上B.vue的编译结果如下:

<template>
  <div class="template">
    <div id="div_1" class="div">父页面的div_1</div>
    <div id="div_child" class="div">子页面的div_5</div>
    <div id="div_2" class="div">父页面的div_2</div>
    <div id="div_3" class="div">
      父页面的div_3
      <a> 子页面通过append插入的超链接 </a>
    </div>
    <div id="div_4" class="div">父页面的div_4</div>
    <div id="div_5" class="div">父页面的div_5</div>
    <div id="div_6" class="div">父页面的div_6</div>
    <div id="div_7" class="div">父页面的div_7</div>
    <div id="div_8" class="div">父页面的div_8</div>
  </div>
</template>
<script>
import A from './A.vue'
export default {
  name: 'COM_B',
  extends: A,//继承业务逻辑代码
  props: {
    msg: String
  }
}
</script>
<style scoped>
.div {
  color: #42b983;
  font-size: 1.5em;
  margin: 0.5em;
  padding: 0.5em;
  border: 2px solid #42b983;
  border-radius: 0.2em;
}
</style>
<style scoped>
#div_child {
  color: #d68924;
  font-size: 1.5em;
  margin: 0.5em;
  padding: 0.5em;
  border: 2px solid #d68924;
}

a {
  color: blue;
  font-size: 0.7em;
}
</style>


注意我们在B.vue使用了extends继承了组件A,这里是为了能复用业务逻辑代码,最后我们运行代码,页面输出为:

结语

在真实的项目当中,我们遇到大量重复的页面但是又有小区别的页面,是可以通过这种方式减少我们的代码量,当然也许有更好的办法,也希望大伙能提出宝贵的建议。

最后引用一下 @XivLaw 老哥的评论:有很多人说通过cv就能解决,但是当你的业务有成千上万个页面是趋同,并且具有相同的基本功能,当界面需要统一调整或者需要进行ui统一管控的时候,cv就成了你的累赘了。 也有朋友说通过组件化和插槽解决,组件化是一个不错的方案,但是当成千上万个趋同的界面存在时,插槽并一定能覆盖所有的业务定制化。 使不使用这种方式,主要看你的业务。

直白一点说就是:我现在有一千个页面几乎一样,有的页面是头部多一点东西,有的是底部,有的是某个按钮旁边多一个按钮,有的是输入框之间多个输入框,ui或者界面或者同时需要添加固定功能,需要调整的时候,这一千个页面要怎么调?


作者:小小小小_柏
链接:https://juejin.cn/post/7347973138787467274


端工程化是什么?一提到工程化我们的第一反应往往就是 webpack。webpack 确实是前端工程化中重要的工具,但二者并不能划等号。

其实顾名思义,前端,工程化,就是把前端做成一项工程。这其中的区别类似于,写一个简单的展示页面=小孩子搭积木,一个大型的项目=开发商盖房子。搭积木只需要简单的手工操作,而盖房子则需要的工程规划、设计、资源管理等一系列流程。

前端工程化贯穿从编码、发布到运维的整个前端研发生命周期,一切以提高效率、降低成本、质量保证为目的的手段都属于工程化。它借鉴了软件工程相关的方法和思想,通过使用工具、流程、最佳实践和规范来提高前端开发效率、质量和可维护性;旨在解决前端开发中出现的各种挑战,包括项目复杂性、跨浏览器兼容性、性能优化、代码可维护性以及团队协作等问题。

前端开发模式进化史

首先让我们回顾一下前端开发模式的演化历史,前端工程化正是为了应对这些演化中出现的挑战和需求而发展起来的:

  1. 前后端混合:服务端渲染,javascript仅实现交互
  2. 前后端分离:借助 ajax 实现前后端分离、单页应用(SPA)等新模式
  3. 模块化开发:npm 管理模块、Webpack 编译打包资源
  4. 模块化 + MVVM:基于 React 或 Vue 等框架进行组件化开发,不再手动操作 html 元素

前端工程化解决了什么问题

那么前端工程化究竟解决了什么问题呢:

  1. 全局作用域问题:前端工程化可以帮助解决全局作用域污染问题。模块化开发工具如Webpack和ES6模块化帮助开发者将代码分解为模块,避免全局变量冲突,并提高代码的可维护性。
  2. 编码规范:通过代码规范工具(如ESLint、TSLint)和自动化代码格式化工具(如Prettier),前端工程化可以确保代码风格一致,减少错误和提高可读性。
  3. 资源合并和压缩:前端工程化工具可以自动合并和压缩CSS、JavaScript和图片等前端资源,以减小文件大小,提高页面加载速度,并减少带宽占用。
  4. 高版本JS预发降级:前端工程化工具可以使用特性检测和polyfill库,以确保新版本JavaScript特性在旧版本浏览器中仍然可用。这有助于实现跨浏览器兼容性。
  5. 模块管理:前端工程化工具和模块化开发使前端项目更易于管理,避免依赖混乱,促进代码的重用和维护。
  6. 自动化测试:前端工程化通过测试工具和自动化测试流程,帮助检测和预防潜在的问题,确保代码的可靠性。
  7. 持续集成和持续交付(CI/CD) :前端工程化支持CI/CD流程,以确保代码在每次更改后都经过构建、测试和部署。这有助于快速交付功能,减少错误。
  8. 性能优化:前端工程化工具支持性能优化策略,如延迟加载、资源缓存和减少HTTP请求次数,以提供更好的用户体验。
  9. 团队协作:前端工程化规范化项目结构、版本控制、文档和工作流程,促进多人协作,减少沟通和协调成本。

举几个企业中的例子,比如前端团队从几个人增加到了几百人,如果不统一代码规范,阅读和接手他人代码的心智负担较大;项目代码从几百几千行增加到几万几十万行之后,如果不做模块化处理,单个文件过大,阅读和维护困难,可复用性差;项目数量从几十个发展到成千上万个,如果没有前端研发的脚手架,每个项目都要重复搭建,不同开发人员构建的项目难以统一管理。

前端模块化

前端模块化是前端工程化的一个重要组成部分,前者关注代码的组织和结构,而后者关注整个前端开发过程的自动化和最佳实践。前端工程化借助前端模块化来提高代码的组织和可维护性,从而解决前端开发中的一系列问题。

前端模块化是什么?

前端模块化指的是将前端代码分解成独立的可复用的模块,以便更好地组织、维护和扩展代码。模块可以包括JavaScript、CSS、HTML等各种前端资源。前端模块化的目标是将复杂的前端应用程序分解为小块,每个块都有特定的功能,可以独立开发和测试。

前端模块是一种规范,而不是具体的实现。比方说 Node.js 实现了 CommonJS 规范,ES6 模块提供了 ESM 规范。这些规范有两个共性:

  • 将复杂程序根据规范拆分成若干模块,一个模块包括输入和输出
  • 模块的内部实现是私有的,对外暴露接口与其他模块通信

前端模块化发展史

① 全局函数模式

将不同的功能封装到单独的全局函数中,通过函数引用功能。比方说一个加法模块:

function sum(a, b) {
    return a + b;
}


这就是一个全局函数,可以在任何地方调用。

缺点: 如果出现相同的函数名,容易引起冲突。

② 命名空间模式

在 window 下新建一个对象属性来存放模块(命名只要不冲突即可,这样只需要确保这个属性名唯一,就能解决全局函数模式的缺点,如 __Module),再将模块中的变量和功能作为 __Module 的属性。

var __Module={
    sum: function(a, b) {
        return a + b;
    }
}


缺点: 外部能够修改模块内部的数据,丧失了封装性。(window.__Module.属性名 可以直接修改模块)

③ 立即执行函数模式

通过IIFE(立即调用函数表达式),利用闭包来创建私有变量。

为了解决 namespace 模式中的缺点,我们可以将创建 __Module 的过程放在一个IIFE中:

(function () {
    var x=1;

    function getX() {
        return x;
    }

    function setX(val) {
        x=val;
    }

    function sum(a, b) {
        return a + b;
    }

    window.__Module={
        x,
        setX,
        getX,
        sum,
    };
})();


这样 window.__Module 下的 x 只是一个拷贝,真正的 x 存放在IIFE的私有作用域中,而不是全局作用域。这样,外部代码就无法直接访问或修改真正的 x 变量。

我们还可以稍加改动,实现一个增强的IIFE模式,使它能够支持自定义传入依赖:

// 模块A
(function (dependencyA, dependencyB) {
    // 在这里可以使用 dependencyA 和 dependencyB
    function doSomething() {
        dependencyA.doThis();
        dependencyB.doThat();
    }

    // 向全局暴露公共接口
    window.ModuleA={
        doSomething: doSomething
    };
})(window.DependencyA, window.DependencyB);

// 模块B
(function () {
    function doThis() {
        // 实现某些功能
    }

    // 向全局暴露公共接口
    window.DependencyA={
        doThis: doThis
    };
})();

// 模块C
(function () {
    function doThat() {
        // 实现某些功能
    }

    // 向全局暴露公共接口
    window.DependencyB={
        doThat: doThat 
    };
})();


至此,已经是我们自己通过纯 JavaScript 来实现模块化方案的最终方案了。但是,它没有特定的语法支持,代码阅读困难,也没有完善的依赖管理、模块加载机制。

所以,为了应对大型项目中复杂的模块化需求,我们需要更加现代的模块系统。

? CommonJS

CommonJS 是 Node.js 中默认的模块化规范:

  • 文件级别的模块作用域(每个文件就是一个作用域):每个 CommonJS 模块都有自己的作用域。
  • 使用 require 函数来导入,通过 module.exports 导出。
  • CommonJS 模块是同步加载的,这意味着模块在导入时会阻塞执行,直到模块完全加载并可用,并且模块加载的顺序会按照其在代码中出现的顺序。
  • 模块可以多次加载,首次加载的时候会运行模块并对输出结果进行缓存,再次加载时会直接使用缓存中的结果。

Node 中 CommonJS 的原理可以分成三部分来看:

主模块加载

应用程序的入口点,包含 require 调用加载其他模块。

模块加载

  1. 解析模块标识符: 它会解析模块的标识符,通常是一个文件路径,以确定要加载的模块。
  2. 检查模块缓存: CommonJS实现会检查模块缓存来查看是否已经加载了该模块。如果已经加载,它会直接返回缓存中的模块对象。
  3. 创建模块对象: 如果模块尚未加载,CommonJS实现会创建一个新的模块对象,通常是一个包含了 exports 和 module 属性的对象。
  4. 执行模块代码: 接下来,CommonJS实现会将模块的代码包装在一个IIFE中,并向该IIFE传递 exports、module 和 require,以确保模块的作用域是隔离的。模块的代码会在这个作用域内执行,可以在模块内定义变量和函数,并通过 exports 和 module.exports 暴露模块的接口。
(function (exports, module, require) {
  // 模块内部的代码
  // 可以在这里定义模块内的变量和函数
  // 通过exports和module.exports来暴露模块的接口
})(exports, module, require);


  1. 缓存模块: 模块加载完成后,CommonJS实现会将这个模块对象缓存起来,使用模块标识符作为键,以便后续 require 调用可以直接返回缓存的模块对象。

模块缓存

已加载的模块以模块标识符为键,模块对象为值存储在模块缓存中。 这个缓存允许模块在后续的 require 调用中被快速访问,而不需要重新加载。

AMD

Node 模块通常都位于本地,加载速度快,不用担心同步加载带来的阻塞问题。但是在浏览器运行过程中,同步加载会阻塞页面的渲染。

require.js 中实现了AMD(Asynchronous Module Definition) ,旨在解决浏览器环境中的异步模块加载和依赖管理问题。它的主要特点是允许在浏览器中异步加载模块,以提高性能和模块化的管理。

CMD

sea.js中实现了CMD(Common Module Definition),它整合了 CommonJS 和 AMD 的优点。

实际上,AMD和CMD等模块规范有一个最大的问题:它们没有得到官方的JavaScript语言规范的支持,这也是他们最终过时了的原因。在现代前端开发中,ES6模块已经成为了主要的模块化解决方案。

? ESM

前面所说的几种模块化规范都必须在运行时才能确定依赖和输入输出,而 ESModule 的理念是在编译时就确定模块依赖的输入输出。

? CommonJS 和 ESModule 规范对比:

  • CommonJS 模块输出的是值的拷贝,ESM 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ESM 模块是编译时输出接口。
  • CommonJS 是单个对象导出,多次导出会覆盖之前的结果;ESM 可以导出多个。
  • CommonJS 模块是同步加载,ESM 支持异步加载
  • CommonJS 的 this 是当前模块,ESM 的 this 是 undefined。

现在大多数浏览器中默认的模块化规范都是 ESM 了,作为一种规范它已经比较成熟了,但是我们在浏览器模块化问题上仍有一些问题未能解决:

  • 浏览器没有模块管理能力,模块分散在各个项目中无法复用。
  • 性能加载慢,大型项目中无法直接使用。

为了解决这两个问题,前端工程化又引入了两个新的工具:

引入 npm 负责管理模块,引入打包工具比如 webpack 进行打包聚合提高性能。

npm 简介

npm 的全称是 Node Package Manager,它是一个用于 Node.js 包的默认包管理器。npm 的主要目标是提供一个集中的、共享的解决方案,用于开发者之间共享和复用代码。在 npm 出现之前,开发者想要在另一个项目中复用某个模块,只能通过复制和粘贴文件的方式。有了 npm 之后,开发者可以把所有模块都上传到仓库(registry):在模块内创建 package.json 文件来标注模块的基本信息,然后通过 npm publish 命令发布模块;使用时通过 npm install 命令安装指定模块到 node_modules 目录。

webpack 简介

虽然 npm 能解决模块的管理问题,但它无法解决加载性能问题。为了解决这个问题,webpack 诞生了。webpack 的整个工作流程可以简单地理解为先合并、再分割:

  • 合并:为了解决项目中依赖文件过多,而导致 HTTP 请求过多的问题,webpack 会把所有资源文件视为模块,分析模块之间的依赖关系,并把所有的依赖打包成一个或多个 bundle.js 文件。
  • 分割:合并打包会带来一个问题——单文件过大,导致加载时间过长。为了解决这个问题,Webpack 引入了代码分割的概念。代码分割允许你将一个大的 bundle 文件分割成多个小文件,这些小文件在需要时才会被加载。这提高了应用程序的加载性能,因为浏览器只需要下载当前页面所需的代码块,而不是整个应用的所有代码。Webpack 提供了不同的代码分割策略,如按路由、按组件或按异步加载。

在 webpack 打包过程中,首先会通过 entry(入口)找到需要打包的文件,然后通过 module(模块)来处理各种类型的文件。在处理文件时,会用到各种 loader(加载器),比如 babel-loader 用来处理 JS 文件,css-loader 和 style-loader 用来处理 CSS 文件。最后通过 output(输出)把处理过的文件输出到指定的目录。


作者:FrontEnd_Reese
链接:https://juejin.cn/post/7291186181157535800