记 Vue Cli迁移至Rsbuid,30min到5~7min,提升打包效率

1,504 阅读10分钟

前言

在项目中添加monaco-editor以及amis后,打包时间急剧增加,最长时间甚至到了半个小时,严重影响效率。提高打包速度成了迫在眉睫的事。Vite是一个很好的选择,但由于本项目与其他项目进行对接时,用到了__webpack_public_path__Vite对此迁移可能改动就很大了。然而Rspack宣称:使用兼容 API 无缝替换 webpack,那么它是一个很好的选择。

  • 未优化打包时,最长的一次打包时间

image.png

Rspack文档

image.png

报错的一些截图&解决方案

  • 问题一
    • Parsing error: Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...</>? (7:0)

image.png

.eslintrc中的"parser": "@babel/eslint-parser"换成"parser": "vue-eslint-parser"

  • 问题二
    • ESLintError: [eslint] Parsing error: This experimental syntax requires enabling one of he following parser plugin(s): "jsx", "flow", "typescript".

image.png

.eslintrc中添加"plugins": ["react"];在babel.config.jspresets加上'@vue/babel-preset-jsx','@babel/preset-react'

  • 问题三
    • Module build faild: `Expression expected
  • Expression expected这种有很多类型,在这只截图了一种(在swc替换babel时)

image.png

pluginBabel({
      babelLoaderOptions: (config, { addPresets }) => {
        addPresets(['@babel/preset-env']);
      }
    }),
  • 问题四
    • Parsing error: Cannot find module '@vue/cli-plugin-babel/preset' Require stack:

这个没有记录到,但根据下文的配置,可以解决

image.png

babel与swc时间消耗对比

babelswc
dev45.2s20.3s
build46.3s12.6s

image.png

迁移

文档

image.png

本项目的vue.config.js

const CircularDependencyPlugin = require('circular-dependency-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
const DeadCodePlugin = require('webpack-deadcode-plugin');

const path = require('path');
function resolve(dir) {
  return path.resolve(__dirname, dir);
}

const hotServer = () => {
  const path = './server-config.js';
  // require请求文件信息时,node会解析出我们传入的字符串的文件路径的绝对路径,并且以绝对路径为键值,对该文件进行缓存
  // require.resolve可以通过相对路径获取绝对路径
  // 以绝对路径为键值删除require中的对应文件的缓存
  delete require.cache[require.resolve(path)];
  // 重新获取文件内容
  const { serverOrigin } = require(path);

  return serverOrigin || '';
};

const fePort = 8081;

module.exports = {
  publicPath: '/',
  lintOnSave: true,
  assetsDir: './',
  devServer: {
    port: fePort, // 端口号
    compress: true,
    host: 'localhost',
    server: 'https',
    open: true, //配置自动启动浏览器
    proxy: {
      '/audit-apiv2': {
        secure: false,
        // target: 'that must have a empty placeholder',
        target: 'http://127.0.0.1:10086',
        router: () => hotServer(),
        onProxyReq(proxyReq) {
          // 绕过后端的csrf验证
          proxyReq.setHeader('referer', hotServer());
        }
      },
    },
    client: {
      overlay: false
    }
  },
  css: {
    loaderOptions: {
      less: {
        prependData: '@import "@/assets/css/mixins.less";'
      }
    }
  },
  productionSourceMap: false,
  runtimeCompiler: true,
  pluginOptions: {
    'style-resources-loader': {
      preProcessor: 'less',
      patterns: [resolve('src/assets/css/global.less')]
    }
  },
  chainWebpack: (config) => {
    /** 添加对react的支持 */
    config.module
      .rule('jsx')
      .test(/components-react\/.*\.jsx$/)
      .use('babel-loader')
      .loader('babel-loader')
      .tap((options) => {
        return {
          ...options,
          presets: ['@babel/preset-env', '@babel/preset-react']
        };
      });
    config.resolve.extensions.add('.tsx').add('.ts').add('.jsx').add('.js');
    config.plugin('monaco-editor').use(new MonacoWebpackPlugin());
    /** monaco-editor的代码用了很多新特性,需要babel转译 */
    config.module
      .rule('monaco-editor')
      .test(/monaco-editor[\\/].*\.js$/)
      .include.add(resolve('node_modules/monaco-editor/esm/vs'))
      .end()
      .exclude.add(resolve('node_modules/monaco-editor/esm/vs/language'))
      .end()
      .use('babel-loader')
      .loader('babel-loader')
      .options({
        presets: [
          '@babel/preset-env' // Added preset to convert code to ES5
        ]
      })
      .end();
    // 这里跳过对loginSdk进行转意处理
    config.module
      .rule('js')
      .exclude.add(resolve('node_modules/loginSdk'))
      .end();
    config.module.rule('svg').exclude.add(resolve('src/assets/svg')).end();
    config.module
      .rule('icons')
      .test(/\.svg$/)
      .include.add(resolve('src/assets/svg'))
      .end()
      .use('svg-sprite')
      .loader('svg-sprite-loader')
      .end();

    config.plugin('html').tap((args) => {
      args[0].minify = {
        ...args[0].minify,
        removeAttributeQuotes: false
      };
      return args;
    });
  },
  configureWebpack: {
    cache: {
      type: 'filesystem',
      allowCollectingMemory: true
    },
    optimization: {
      usedExports: true
    },
    plugins: [
      new CompressionPlugin({
        test: /\.(js|css)?$/i, // 压缩文件类型
        filename: '[path][base].gz', // 压缩后的文件名
        algorithm: 'gzip', // 使用 gzip 压缩
        minRatio: 0.8, // 压缩率小于 1 才会压缩
        threshold: 10240, // 对超过 10k 的数据压缩
        deleteOriginalAssets: false // 是否删除未压缩的文件(源文件),不建议开启
      }),
      new DeadCodePlugin({
        patterns: ['src/**/*'],
        exclude: ['**/*.md']
      }),
      // 检测src目录下存在的循环依赖现象
      new CircularDependencyPlugin({
        include: /src/,
        failOnError: true,
        allowAsyncCycles: false,
        cwd: process.cwd()
      })
    ]
  }
};

本项目迁移的变化(babel)

ci环境打包时间

image.png

开发启动时间&打包时间

时间将近1min

  • rsbuid dev

image.png

  • rsbuid build

image.png

index.html文件的变化不计其中

对应配置项迁移

css => @rsbuild/plugin-less

css: {
    loaderOptions: {
      less: {
        prependData: '@import "@/assets/css/mixins.less";'
      }
    }
  },
pluginOptions: {
    'style-resources-loader': {
      preProcessor: 'less',
      patterns: [resolve('src/assets/css/global.less')]
    }
  },
  • 对于以上可以使用@rsbuild/plugin-less插件中的additionalData
  • 在编译 Less 文件时,通过 additionalData 参数自动引入两个 Less 文件:mixins.less 和 global.less
pluginLess({
      lessLoaderOptions: {
        additionalData: `@import "@/assets/css/mixins.less";@import "@/assets/css/global.less";`
      }
    }),

chainWebpack => tools.bundlerChain

具体参照上文的vue.config.js和下文rsbuild.config.mjs

configureWebpack => tools.rspack

具体参照上文的vue.config.js和下文rsbuild.config.mjs

package.json

"scripts": {
- "serve": "vue-cli-service serve",
- "build": "vue-cli-service build",
- "lint": "vue-cli-service lint",

+ "serve": "rsbuild dev",
+ "build": "rsbuild build",
+ "lint": "eslint --ext .ts,.vue --ignore-path .gitignore --fix src",
}
  • 移除与添加的依赖:
"devDependencies": {
- "@vue/cli-plugin-babel": "~5.0.4",
- "@vue/cli-plugin-eslint": "~5.0.4",
- "@vue/cli-plugin-vuex": "~5.0.4",
- "@vue/cli-service": "~5.0.4",

+ "@babel/preset-env": "^7.26.0",
+ "@rsbuild/core": "^1.1.10",
+ "@rsbuild/plugin-babel": "^1.0.3",
+ "@rsbuild/plugin-less": "^1.1.0",
+ "@rsbuild/plugin-vue2": "^1.0.2",
+ "@vue/babel-preset-jsx": "^1.4.0",
+ "babel-loader": "^9.2.1",
}
  • 移除Vue Cli的依赖
npm remove @vue/cli-service @vue/cli-plugin-babel @vue/cli-plugin-eslint @vue/cli-plugin-vuex core-js
  • 安装rsbuild的依赖
npm install @rsbuild/core @rsbuild/plugin-vue2 @rsbuild/plugin-babel @rsbuild/plugin-eslint @rsbuild/plugin-less @rsbuild/plugin-vue2-jsx -D
  • 安装babel相关依赖
npm install @vue/babel-preset-jsx babel-loader -D

babel.config.js

  • 修改babel.config.js文件中的内容
module.exports = {
  presets: ['@babel/preset-env', '@vue/babel-preset-jsx', '@babel/preset-react']
};

image.png

rsbuild.config.mjs

vue.config.js文件中的内容迁移到rsbuild.config.mjs

  • rsbuild.config.mjs
import { defineConfig } from '@rsbuild/core';
import { pluginVue2 } from '@rsbuild/plugin-vue2';
import { pluginLess } from '@rsbuild/plugin-less';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx';
import { pluginEslint } from '@rsbuild/plugin-eslint';
import { pluginBasicSsl } from '@rsbuild/plugin-basic-ssl';

import CircularDependencyPlugin from 'circular-dependency-plugin';
import CompressionPlugin from 'compression-webpack-plugin'; // 兼容
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; // 兼容
import DeadCodePlugin from 'webpack-deadcode-plugin';

import path from 'path';

function resolve(dir) {
  return path.resolve(__dirname, dir);
}

const fePort = 8081;
const serverOrigin = 'https://192.168.70.203';
// const serverOrigin = 'https://192.168.70.17';

export default defineConfig({
  // 用于注册 Rsbuild 插件。
  plugins: [
    pluginBabel({
      babelLoaderOptions: (config, { addPresets }) => {
        addPresets(['@babel/preset-env', '@babel/preset-react']);
      }
    }),
    pluginVue2(),
    pluginVue2Jsx(),
    pluginLess({
      lessLoaderOptions: {
        additionalData: `@import "@/assets/css/mixins.less";@import "@/assets/css/global.less";`
      }
    }),
    pluginEslint(),
    /**
     * 支持本地使用https开发
     * 默认设置了 server.https 选项
     */
    pluginBasicSsl()
  ],
  dev: {
    progressBar: true // 在编译过程中展示进度条
    // lazyCompilation: true // 按需编译,从而提升启动时间,不建议开启,切换页面时很慢
  },
  resolve: {
    alias: {
      '@': resolve('src')
    }
  },
  output: {
    // publicPath: '/', // rspack中的publicPath配置项
    assetPrefix: '/', // publicPath与之效果一致
    /**
     * 浏览器兼容
     * usage:注入的 polyfill 体积更小,适合对包体积有较高要求的项目使用
     * entry:注入的 polyfill 较为全面,适合对兼容性要求较高的项目使用
     */
    polyfill: 'usage'
  },
  source: {
    // 指定入口文件
    entry: {
      index: './src/main.js'
    }
  },
  html: {
    template: './public/index.html'
  },
  server: {
    port: fePort, // 端口号
    compress: true,
    host: 'localhost',
    open: true, //配置自动启动浏览器
    proxy: {
      '/audit-apiv2': {
        secure: false,
        target: serverOrigin,
        onProxyReq(proxyReq) {
          // 绕过后端的csrf验证
          proxyReq.setHeader('referer', serverOrigin);
        }
      },
    },
    client: {
      overlay: false
    }
  },
  tools: {
    /**
     * tools.rspack修改Rspack的配置项
     */
    rspack: (config, { rspack }) => {
      config.cache = true;
      config.plugins.push(
        new CompressionPlugin({
          test: /\.(js|css)?$/i, // 压缩文件类型
          filename: '[path][base].gz', // 压缩后的文件名
          algorithm: 'gzip', // 使用 gzip 压缩
          minRatio: 0.8, // 压缩率小于 1 才会压缩
          threshold: 10240, // 对超过 10k 的数据压缩
          deleteOriginalAssets: false // 是否删除未压缩的文件(源文件),不建议开启
        }),

        /**
         * 关闭警告:
         * Critical dependency: require function is used in a way in which dependencies cannot be statically extracted
         */
        new rspack.ContextReplacementPlugin(
          /require\(\[".*"\]\)/,
          resolve('public')
        )
      );
    },
    bundlerChain: (chain) => {
      /** 添加对react的支持 */
      chain.module
        .rule('jsx')
        .test(/components-react\/.*\.jsx$/)
        .use('babel-loader')
        .loader('babel-loader')
        .tap((options) => {
          return {
            ...options,
            presets: ['@babel/preset-env', '@babel/preset-react']
          };
        });
      chain.resolve.extensions.add('.tsx').add('.ts').add('.jsx').add('.js');
      chain.plugin('monaco-editor').use(new MonacoWebpackPlugin());
      /** monaco-editor的代码用了很多新特性,需要babel转译 */
      chain.module
        .rule('monaco-editor')
        .test(/monaco-editor[\\/].*\.js$/)
        .include.add(resolve('node_modules/monaco-editor/esm/vs'))
        .end()
        .exclude.add(resolve('node_modules/monaco-editor/esm/vs/language'))
        .end()
        .use('babel-loader')
        .loader('babel-loader')
        .options({
          presets: [
            '@babel/preset-env' // Added preset to convert code to ES5
          ]
        })
        .end();
      // 这里跳过对loginSdk进行转意处理
      chain.module
        .rule('js')
        .exclude.add(resolve('node_modules/loginSdk'))
        .end();
      chain.module.rule('svg').exclude.add(resolve('src/assets/svg')).end();
      chain.module
        .rule('icons')
        .test(/\.svg$/)
        .include.add(resolve('src/assets/svg'))
        .end()
        .use('svg-sprite')
        .loader('svg-sprite-loader')
        .end();
    }
  }
});

.eslintrc

image.png

"parser": "vue-eslint-parser", // 解析vue文件
  // 配置解析器的选项
  "parserOptions": {
    "parser": "@babel/eslint-parser",
    "sourceType": "module", // 使用 ES6 模块
    "ecmaVersion": 2018, // 设置 ECMAScript 版本为 2018
    "ecmaFeatures": {
      "jsx": true
    },
    "requireConfigFile": false
  },
  /**
   * 启用 React 插件
   * Parsing error: This experimental syntax requires enabling one of the following parser plugin(s): "jsx", "flow", "typescript"
   */
  "plugins": ["react"],

SWC 替换 bable

SWC(Speedy Web Compiler)是基于 Rust 语言编写的高性能 JavaScript 和 TypeScript 转译和压缩工具。SWC 提供与 Babel 和 Terser 相似的能力,在单线程上它比 Babel 快 20 倍,在四核上它比 Babel 快 70 倍。

ci环境打包时间

image.png

开发启动时间&打包时间

时间比babel缩短了一半!

  • rsbuid dev

image.png

  • rsbuid build

image.png

package.json

  • 移除的依赖
- "@babel/core": "^7.26.0",
- "@babel/eslint-parser": "^7.12.16",
- "@babel/polyfill": "^7.12.1",
- "@babel/preset-react": "^7.25.9",
- "babel-loader": "^9.2.1",

rsbuild.config.mjs

  • 使用builtin:swc-loader替换babel-loader
import { defineConfig } from '@rsbuild/core';
import { pluginVue2 } from '@rsbuild/plugin-vue2';
import { pluginLess } from '@rsbuild/plugin-less';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx';
import { pluginEslint } from '@rsbuild/plugin-eslint';
import { pluginBasicSsl } from '@rsbuild/plugin-basic-ssl';

import CompressionPlugin from 'compression-webpack-plugin'; // 兼容
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; // 兼容

import path from 'path';

function resolve(dir) {
  return path.resolve(__dirname, dir);
}

const fePort = 8081;
// const serverOrigin = 'https://192.168.70.203';
const serverOrigin = 'https://192.168.70.17';

export default defineConfig({
  // 用于注册 Rsbuild 插件。
  plugins: [
    pluginBabel({
      babelLoaderOptions: (config, { addPresets }) => {
        addPresets(['@babel/preset-env']);
      }
    }),
    pluginVue2(),
    pluginVue2Jsx(),
    pluginLess({
      lessLoaderOptions: {
        additionalData: `@import "@/assets/css/mixins.less";@import "@/assets/css/global.less";`
      }
    }),
    pluginEslint(),
    /**
     * 支持本地使用https开发
     * 默认设置了 server.https 选项
     */
    pluginBasicSsl()
  ],
  // 与本地开发有关的选项
  dev: {
    progressBar: true // 在编译过程中展示进度条
    // lazyCompilation: true // 按需编译,从而提升启动时间,不建议开启,切换页面时很慢
  },
  // 与模块解析相关的选项
  resolve: {
    alias: {
      '@': resolve('src')
    }
  },
  // 与构建产物有关的选项
  output: {
    // publicPath: '/', // rspack中的publicPath配置项
    assetPrefix: '/', // publicPath与之效果一致
    /**
     * 浏览器兼容
     * usage: 注入的 polyfill 体积更小,适合对包体积有较高要求的项目使用
     * entry: 注入的 polyfill 较为全面,适合对兼容性要求较高的项目使用
     */
    polyfill: 'entry'
  },
  // 与输入的源代码相关的选项
  source: {
    // 指定入口文件
    entry: {
      index: './src/main.js'
    }
  },
  // 与 HTML 生成有关的选项
  html: {
    template: './public/index.html'
  },
  // 与 Rsbuild 服务器有关的选项
  server: {
    port: fePort, // 端口号
    compress: true,
    host: 'localhost',
    open: true, //配置自动启动浏览器
    proxy: {
      '/audit-apiv2': {
        secure: false,
        target: serverOrigin,
        onProxyReq(proxyReq) {
          // 绕过后端的csrf验证
          proxyReq.setHeader('referer', serverOrigin);
        }
      },
    }
  },
  // 与构建性能、运行时性能有关的选项
  performance: {
    // bundleAnalyze: {} // 开启webpack-bundle-analyzer分析产物体积
  },
  // 与底层工具有关的选项
  tools: {
    /**
     * tools.rspack修改Rspack的配置项
     */
    rspack: (config, { rspack }) => {
      config.cache = true;
      config.plugins.push(
        new CompressionPlugin({
          test: /\.(js|css)?$/i, // 压缩文件类型
          filename: '[path][base].gz', // 压缩后的文件名
          algorithm: 'gzip', // 使用 gzip 压缩
          minRatio: 0.8, // 压缩率小于 1 才会压缩
          threshold: 10240, // 对超过 10k 的数据压缩
          deleteOriginalAssets: false // 是否删除未压缩的文件(源文件),不建议开启
        }),

        /**
         * 关闭警告:
         * public/tools/regulex中存在动态require
         * Critical dependency: require function is used in a way in which dependencies cannot be statically extracted
         */
        new rspack.ContextReplacementPlugin(
          /require\(\[".*"\]\)/,
          resolve('public')
        )
      );
    },
    bundlerChain: (chain) => {
      /** 添加对react的支持 */
      chain.module
        .rule('jsx')
        .test(/components-react\/.*\.jsx$/)
        .use('builtin:swc-loader')
        .loader('builtin:swc-loader')
        .options({
          jsc: {
            parser: {
              syntax: 'ecmascript',
              jsx: true
            },
            transform: {
              react: {
                pragma: 'React.createElement' // 如果你用的是 React 17 的新 JSX 转换,可以去掉这行
              }
            }
          }
        })
        .end();

      chain.resolve.extensions.add('.tsx').add('.ts').add('.jsx').add('.js');
      chain.plugin('monaco-editor').use(new MonacoWebpackPlugin());
      /** monaco-editor的代码用了很多新特性,需要babel转译 */
      chain.module
        .rule('monaco-editor')
        .test(/monaco-editor[\\/].*\.js$/)
        .include.add(resolve('node_modules/monaco-editor/esm/vs'))
        .end()
        .exclude.add(resolve('node_modules/monaco-editor/esm/vs/language'))
        .end()
        .use('builtin:swc-loader')
        .loader('builtin:swc-loader')
        .options({
          jsc: {
            parser: {
              syntax: 'ecmascript'
            },
            target: 'es5' // 将代码转译为 ES5
          }
        })
        .end();
      // 这里跳过对loginSdk进行转意处理
      chain.module
        .rule('js')
        .exclude.add(resolve('node_modules/loginSdk'))
        .end();
      chain.module.rule('svg').exclude.add(resolve('src/assets/svg')).end();
      chain.module
        .rule('icons')
        .test(/\.svg$/)
        .include.add(resolve('src/assets/svg'))
        .end()
        .use('svg-sprite')
        .loader('svg-sprite-loader')
        .end();
    }
  }
});

.eslintrc

  • 解析器用回了默认的espree
  • ECMAScript版本提高

image.png

其他

开启开发阶段的进度条

  • 默认情况下,在执行npm run serve时,在终端是没有显示进度条的,如果你需要显示这个进度条可以这样设置:
dev: {
  progressBar: true // 在编译过程中展示进度条
},

插件替代品

  • circular-dependency-pluginwebpack-deadcode-plugin这两个插件,在rsbuild是没有相应的替代品的,但是可以使用madgeunimported分别进行替代

madge

以下是如何在 rsbuild 项目中集成 madge 来检测循环依赖:

  1. 安装 madge

    npm install madge --save-dev

  2. 创建一个脚本来运行 madge

    在项目根目录下创建一个脚本文件,例如 check-circular-dependencies.js

const madge = require('madge');

madge('./src')
  .then((res) => {
    const circular = res.circular();
    if (circular.length) {
      console.error('Circular dependencies detected:');
      console.error(circular);
      process.exit(1);
    } else {
      console.log('No circular dependencies found.');
    }
  })
  .catch((err) => {
    console.error('Error detecting circular dependencies:', err);
    process.exit(1);
  });
  1. 在 package.json 中添加一个脚本来运行 madge
{
 "scripts": {
    "check-circular-dependencies": "node check-circular-dependencies.js"
  }
}
  1. 在构建过程中运行 madge

    你可以在构建之前或之后运行这个脚本,以确保没有循环依赖。例如,在构建之前运行:

{
  "scripts": {
     "build": "npm run check-circular-dependencies && rsbuild"
   }
}

通过这种方式,你可以在 rsbuild 项目中检测循环依赖,而不需要依赖 Webpack 插件。

unimported

以下是如何在 rsbuild 项目中集成 unimported 来检测未使用的代码:

  1. 安装 unimported
npm install unimported --save-dev
  1. 创建一个配置文件 unimported.config.js

    在项目根目录下创建一个配置文件,例如 unimported.config.js

module.exports = {
  entry: ['./src'],
  extensions: ['.js''.jsx''.ts''.tsx''.vue'],
  ignorePatterns: ['node_modules''dist''build'],
  ignoreUnresolved: ['@babel/preset-env''@babel/preset-   react''@vue/babel-preset-jsx'],
};
  1. 在 package.json 中添加一个脚本来运行 unimported
{
    "scripts": {
        "check-deadcode": "unimported"
    }
}
  1. 在构建过程中运行 unimported

    你可以在构建之前或之后运行这个脚本,以确保没有未使用的代码。例如,在构建之前运行:

{
    "scripts": {
        "build": "npm run check-deadcode && rsbuild"
    }
}

通过这种方式,你可以在 rsbuild 项目中检测未使用的代码,而不需要依赖 Webpack 插件。

浏览器兼容性

测试过chrome90版本的兼容性,使用swc是完全没有问题的

  • rsbuild.config.mjs
output: {
    /**
     * 浏览器兼容
     * usage: 注入的 polyfill 体积更小,适合对包体积有较高要求的项目使用
     * entry: 注入的 polyfill 较为全面,适合对兼容性要求较高的项目使用
     */
    polyfill: 'entry'
  },
  • .browserslistrc
> 0.5%
last 2 versions
chrome >= 87
edge >= 88
firefox >= 78
safari >= 14

具体对应的浏览器列表可以查看 browserslist.dev

image.png

结语

这种迁移直接问AI是比较费时间的,AI给出了也比较多错误的回答。兄弟们,还是要多看文档,一次没看懂,那就多看几遍。也可以多看看别人的迁移记录,总会有所收获。