作者:饥人谷CTO --方应杭原文:https://juejin.im/post/5d8adb8ff265da5ba12cd173#heading-0
阿里云最近在做活动,低至2折,有兴趣可以看看:
https://promotion.aliyun.com/ntms/yunparter/invite.html?userCode=pxuujn3r
最近打算改进一下现有网站的架构,微前端这个词多次进入了我的视野。
但是网上关于微前端文章总是说得似是而非,于是我找到这篇文章进行翻译。并大概理解微前端的理念。目前还没有确定是否使用微前端架构,因为看起来业界对最佳实践并没有达成一致。
阿里云最近在做活动,低至2折,有兴趣可以看看(或者点击阅读原文):
引言
把前端做好很难,让多个团队同时开发大型前端应用,就更难了。目前有一种趋势是将前端应用拆分成更小、更易于管理的小应用。这种体系结构是如何提高前端团队的效率的呢?
本文将对这些问题进行阐述。除了讨论利弊,我们还将介绍一些可用的例子,并深入研究一个完整的示例应用。
近年来,微服务已迅速普及,许多组织都使用这种体系结构样式来避免大型单体应用的局限性。尽管有很多介绍微服务的文章,但还是有许多公司局限于单体式前端应用。
假设你想构建一个渐进式的Web应用程序,但是你很难将新的功能实现于现有的整体应用中。比如你想开始使用新的 JS 语法(或TypeScript),但是你无法在现有的构建过程中使用对应的构建工具。又或者,你只想扩展你的开发团队,以便多个团队可以同时处理一个产品,但是现有应用中的耦合和复杂度让每个开发者互相掣肘。这些都是真实存在的问题,这些问题极大地降低了大型团队的开发效率。
最近,我们看到越来越多前端开始把注意力集中在复杂前端应用的架构上面。尤其是如何将前端整体分解,每一块可以独立开发、测试和部署,同时对用户而言仍是一个整体。这种技术就是微前端,我们将其定义为:
一种将独立的前端应用组成一个更大的整体的架构风格
当然,在软件体系结构方面没有免费的午餐。一些微型前端实现可能导致依赖关系非常重复,从而增加用户的下载量。而且,团队自治可能会导致团队分散。尽管如此,我们认为风险是可控的,收益是高于成本的。
微前端的优点
增量升级
对于许多团队而言,这是开始微前端之旅的首要原因。技术债阻碍了项目的发展,只能重写。为了避免完全重写的风险,我们更希望 逐个替换旧的模块。
简单、解耦的代码库
每个单独的微型前端应用的源代码都将比单个整体前端应用的源代码少得多。这些较小的代码库对于开发人员来说更容易维护。尤其是我们避免了组件间耦合所导致的复杂性。
独立部署
就像微服务一样,微前端的独立部署能力是关键。部署范围的减小,带来了风险的降低。每个微前端应用都应具有自己的持续交付途径,不停地构建、测试、部署。
团队自治
每个团队需要围绕业务功能垂直组建,而不是根据技术能力来组建。这为团队带来了更高的凝聚力。
总结
简而言之,微前端就是将大而恐怖的东西切成更小、更易于管理的部分,然后明确地表明它们之间的依赖性。我们的技术选择,我们的代码库,我们的团队以及我们的发布流程都应该能够彼此独立地操作和发展,无需过多的协调。
举例
想象一个订餐网站。从表面上看,这是一个非常简单的概念,但是如果你想做得好,会有很多令人惊讶的细节:
-
应该有一个落地页,顾客可以浏览和搜索餐馆。这些餐厅应该可以通过许多属性进行搜索和过滤,包括价格、菜名或客户先前订购的内容
-
每个餐厅都需要有自己的页面,显示其菜单,并允许客户下单、选折扣和填写附加要求
-
客户应该有个人资料页面,可以查看订单记录、自定义付款方式
每个页面都足够复杂,从微前端的角度我们可以把每个页面交给一个专门的团队(译注:这些团队的人员可以重叠),并且每个团队都应该能够独立于其他团队工作。他们应该能够开发、测试、部署和维护其代码,而不必担心与其他团队冲突。
集成
鉴于上面的定义相当宽松,所有有许多方法实现微前端。在本节中,我们将显示一些示例并讨论它们的取舍之道。每个页面都有一个容器应用,该容器可以:
-
呈现常见的页面元素,例如页眉和页脚
-
解决认证和导航等跨领域问题
-
将各种微型前端应用集中到页面上,并告诉每个微型前端何时以及在何处进行渲染
后端模板的集成
我们用一个非常传统的方式开始,将多个模板渲染到服务器上的HTML里。我们有一个index.html
,其中包含所有常见的页面元素,然后使用 include
来引入其他模板:
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Feed me</title>
</head>
<body>
<h1>🍽 Feed me</h1>
<!--# include file="$PAGE.html" -->
</body>
</html>
然后配置 nginx
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
ssi on;
# 将 / 重定向到 /browse
rewrite ^/$ http://localhost:8080/browse redirect;
# 根据路径访问 html
location /browse {
set $PAGE 'browse';
}
location /order {
set $PAGE 'order';
}
location /profile {
set $PAGE 'profile'
}
# 所有其他路径都渲染 /index.html
error_page 404 /index.html;
}
这是相当标准的服务器端应用。我们之所以可以称其为微前端,是因为我们让每个页面独立,可由一个独立的团队交付。
为了获得更大的独立性,可以有一个单独的服务器负责渲染和服务每个微型前端,其中一个服务器位于前端,向其他服务器发出请求。通过缓存,可以把延迟降到最低。
package 集成
有人会用到的一种方法是将每个微前端发布为一个 node
包,并让容器应用程序将所有微前端应用作为依赖项。比如这个 package.json
:
{
"name": "@feed-me/container",
"version": "1.0.0",
"description": "A food delivery web app",
"dependencies": {
"@feed-me/browse-restaurants": "^1.2.3",
"@feed-me/order-food": "^4.5.6",
"@feed-me/user-profile": "^7.8.9"
}
}
乍看似乎没什么问题,这种做法会产生一个可部署的包,我们可以轻松管理依赖项。
但是,这种方法意味着我们必须重新编译并发布每个微前端应用,才能发布我们对某个应用作出的更改。我们强烈不建议使用这种微前端方案。
通过 iframe 集成
iframe
是集成的最简单方式之一。本质上来说,iframe
里的页面是完全独立的,可以轻松构建。而且 iframe
还提供了很多的隔离机制。
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<iframe id="micro-frontend-container"></iframe>
<script type="text/javascript">
const microFrontendsByRoute = {
'/': 'https://browse.example.com/index.html',
'/order-food': 'https://order.example.com/index.html',
'/user-profile': 'https://profile.example.com/index.html',
};
const iframe = document.getElementById('micro-frontend-container');
iframe.src = microFrontendsByRoute[window.location.pathname];
</script>
</body>
</html>
iframe
并不是一项新技术,所以上面代码也许看起来并不那么令人兴奋。
但是,如果我们重新审视先前列出的微前端的主要优势,只要我们谨慎地划分微应用和组建团队的方式,iframe便很适合。
我们经常看到很多人不愿意选择iframe。因为 iframe有点令人讨厌,但 iframe 实际上还是有它的优点的。上面提到的容易隔离确实会使iframe不够灵活。它会使路由、历史记录和深层链接变得更加复杂,并且很难做成响应式页面。
使用 JS 集成
这种方式可能是最灵活的一种,也是被采用频率最高的一种方法。每个微前端都对应一个 <script>
标签,并且在加载时导出一个全局变量。然后,容器应用程序确定应该安装哪些微应用,并调用相关函数以告知微应用何时以及在何处进行渲染。
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<!-- 这些脚本不会马上渲染应用 -->
<!-- 而是分别暴露全局变量 -->
<script src="https://browse.example.com/bundle.js"></script>
<script src="https://order.example.com/bundle.js"></script>
<script src="https://profile.example.com/bundle.js"></script>
<div id="micro-frontend-root"></div>
<script type="text/javascript">
// 这些全局函数是上面脚本暴露的
const microFrontendsByRoute = {
'/': window.renderBrowseRestaurants,
'/order-food': window.renderOrderFood,
'/user-profile': window.renderUserProfile,
};
const renderFunction = microFrontendsByRoute[window.location.pathname];
// 渲染第一个微应用
renderFunction('micro-frontend-root');
</script>
</body>
</html>
上面是一个很基本的例子,演示了 JS 集成的大体思路。
与 package
集成不同,我们可以用不同的bundle.js独立部署每个应用。
与 iframe
集成不同的是,我们具有完全的灵活性,你可以用 JS
控制什么时候下载每个应用,以及渲染应用时额外传参数。
这种方法的灵活性和独立性使其成为最常用的方案。当我们展示完整的示例时,会有更详细的探讨。
通过 Web Component 集成
这是前一种方法的变体,每个微应用对应一个 HTML 自定义元素,供容器实例化,而不是提供全局函数。
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<!-- 这些脚本不会马上渲染应用 -->
<!-- 而是分别提供自定义标签 -->
<script src="https://browse.example.com/bundle.js"></script>
<script src="https://order.example.com/bundle.js"></script>
<script src="https://profile.example.com/bundle.js"></script>
<div id="micro-frontend-root"></div>
<script type="text/javascript">
// 这些标签名是上面代码定义的
const webComponentsByRoute = {
'/': 'micro-frontend-browse-restaurants',
'/order-food': 'micro-frontend-order-food',
'/user-profile': 'micro-frontend-user-profile',
};
const webComponentType = webComponentsByRoute[window.location.pathname];
// 渲染第一个微应用(自定义标签)
const root = document.getElementById('micro-frontend-root');
const webComponent = document.createElement(webComponentType);
root.appendChild(webComponent);
</script>
</body>
</html>
主要区别在于使用 Web Component 代替全局变量。如果你喜欢 Web Component 规范,那么这是一个不错的选择。如果你希望在容器应用程序和微应用之间定义自己的接口,那么你可能更喜欢前面的示例。
样式
CSS 没有模块系统、命名空间和封装。就算有,也通常缺乏浏览器支持。在微前端环境中,这些问题会更严重。h2 { color: black; }
,而另一个团队的则为 h2 { color: blue; }
,而这两个选择器都附加在同一页面上,就会冲突!
这不是一个新问题,但由于这些选择器是由不同的团队在不同的时间编写的,并且代码可能分散在不同的库中,因此更难避免。
多年来,有许多方法可以让 CSS
变得更易于管理。有些人选择使用严格的命名约定,例如 BEM
,以确保选择器的范围是足够小的。其他一些人则使用预处理器,例如 SASS
,其选择器嵌套可以用作命名空间。一种较新的方法是通过 CSS
模块 或各种 CSS-in-JS
库,以编程的方式写 CSS。某些开发者还会使用 shadow DOM
来隔离样式。
只要你选择一种能确保开发人员的样式互不影响的方案即可。
共享组件库
上面我们提到,视觉一致性很重要,一种解决方法是应用间共享可重用的 UI 组件库。
说起来容易,做起来难。创建这样一个库的主要好处是减少工作量。此外,你的组件库可以充当样式指南,作为开发人员和设计师之间进行协作的重要桥梁。
第一个容易出错的点,就是过早地创建了太多组件。比如你试图创建一个囊括所有常见 UI 组件的组件库。但是,经验告诉我们,在实际使用组件之前,我们很难猜测组件的 API 应该是什么样的,强行做组件会导致早期的混乱。因此,我们宁愿让团队根据需求创建自己的组件,即使这最初会导致某些重复。
让 API 自然出现,一旦组件的 API 变得显而易见,就可以将重复的代码整合到共享库中。
与任何共享内部库一样,库的所有权和治理权很难分配。一种人认为,所有开发成员都拥有库的所有权,实际上这意味着没有人拥有库的所有权。如果没有明确的约定或技术远见,共享组件库很快就会成为不一致代码的大杂烩。如果取另一个极端,即完全集中式的开发共享库,后果就是创建组件的人与使用这些组件的人之间将存在很大的脱节。
我们见过的最好的合作方式是,任何人都可以为库贡献代码,但是有一个 托管者(一个人或一个团队)负责确保这些代码的质量、一致性和有效性。
跨微应用通信
关于微前端的最常见问题之一是如何让应用彼此通信。我们建议应该尽可能少地通信,因为这通常会引入不必要的耦合。
不过跨应用通信的需求还是存在的。
-
使用自定义事件通信,是降低耦合的一种好方法。不过这样做会使微应用之间的接口变得模糊。
-
可以考虑 React 应用中常见的机制:自上而下传递回调和数据。
-
第三种选择是使用地址栏作为通信桥梁,我们将在后面详细探讨 。
如果你使用的是 Redux
,那么通常你会为整个应用创建一个全局状态。但如果每个微应用是独立的,那么每个微应用就都应该有自己的 Redux
和全局状态。
无论选择哪种方法,我们都希望我们的微应用通过消息或事件进行通信,并避免任何共享状态,以避免耦合。
你还应该考虑如何自动验证集成没有中断。功能测试是解法之一,但是由于实现和维护成本,我们倾向于只做一部分功能测试。或者,你可以实施消费者驱动接口,让每个微应用指定它对其他微应用的要求,这样你就不用实际将它们全部集成在一起并在浏览器中测试。
后端通讯
如果我们有独立的团队独立处理前端应用,那么后端开发又是怎样的呢?
我们坚信全栈团队的价值,从界面代码一直到后台 API 开发,再到数据库和网站架构。
我们推荐的模式是 Backends For Frontends
模式,其中每个前端应用程序都有一个相应的后端,后端的目的仅仅是为了满足该前端的需求。BFF模式起初的粒度可能是每个前端平台(PC页面、手机页面等)对应一个后端应用,但最终会变为每个微应用对应一个后端应用。
这里要说明一下,一个后端应用可能有独立业务逻辑和数据库的,也可能只是下游服务的聚合器。如果微前端应用只有一个与之通信的API,并且该API相当稳定,那么为它单独构建一个后台可能根本没有太大价值。指导原则是:构建微前端应用的团队不必等待其他团队为其构建什么事物。
因此,如果一个微前端用到的新功能需要后端接口的变更,那么这一前一后两个地方就应该交给一个团队来开发。
另一个常见的问题是,如何做身份验证和鉴权?
显然用户只需要进行一次身份验证,因此身份验证应该放在容器应用里。容器可能具有某种登录形式,通过该登录形式我们可以获得某种令牌。该令牌将归容器所有,并可以在初始化时注入到每个微前端中。最后,微前端可以将令牌发送到服务器,然后服务器进行验证。
测试
在测试方面,我们看不到单体式前端和微前端之间的太大差异。
显而易见的差距是容器应用程序对各种微前端的集成测试。
详细的例子
接下来我们来实现一个详细的例子。demo.microfrontends.com
上查看最终部署的结果,完整的源代码可以在 Github 上看到。
容器
我们将从 容器 开始,因为它是我们的切入点 package.json
:
{
"name": "@micro-frontends-demo/container",
"description": "Entry point and container for a micro frontends demo",
"scripts": {
"start": "PORT=3000 react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test"
},
"dependencies": {
"react": "^16.4.0",
"react-dom": "^16.4.0",
"react-router-dom": "^4.2.2",
"react-scripts": "^2.1.8"
},
"devDependencies": {
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"jest-enzyme": "^6.0.2",
"react-app-rewire-micro-frontends": "^0.0.1",
"react-app-rewired": "^2.1.1"
},
"config-overrides-path": "node_modules/react-app-rewire-micro-frontends"
}
可以看出,这是一个用 create-react-app
创建的 React 应用。
要注意我并没有把其他微应用包含到 package.json
的依赖里。
如果你想知道如何选择和展示微应用,可以看一下 App.js
。我们使用React Router
将当前URL与预定义的路由列表进行匹配,并渲染相应的组件:
<Switch>
<Route exact path="/" component={Browse} />
<Route exact path="/restaurant/:id" component={Restaurant} />
<Route exact path="/random" render={Random} />
</Switch>
Browser
和 Restaurant
组件是这样的:
const Browse = ({ history }) => (
<MicroFrontend history={history} name="Browse" host={browseHost} />
);
const Restaurant = ({ history }) => (
<MicroFrontend history={history} name="Restaurant" host={restaurantHost} />
);
两个组件都渲染了一个 MicroFrontend
组件。除了history
对象(稍后将变得很重要)之外,我们还指定应用程序的唯一名称,以及对应的后端 host。host 的值可能是 http://localhost:3001
或 browse.demo.microfrontends.com
。
MicroFrontend 只是另一个 React 组件:
class MicroFrontend extends React.Component {
render() {
return <main id={`${this.props.name}-container`} />;
}
}
渲染时,我们要做的只是在页面上放置一个容器元素,其ID对于微前端应用来说是唯一的。我们使用React componentDidMount
作为下载和安装微应用的触发器:
// class MicroFrontend
componentDidMount() {
const { name, host } = this.props;
const scriptId = `micro-frontend-script-${name}`;
if (document.getElementById(scriptId)) {
this.renderMicroFrontend();
return;
}
fetch(`${host}/asset-manifest.json`)
.then(res => res.json())
.then(manifest => {
const script = document.createElement('script');
script.id = scriptId;
script.src = `${host}${manifest['main.js']}`;
script.onload = this.renderMicroFrontend;
document.head.appendChild(script);
});
}
必须从 manifest
文件中获取脚本的 URL
,因为 react-scripts
输出的编译的 JS 文件的文件名中带有哈希值以方便缓存。
设置脚本的URL
后,剩下的就是将其添加到文档并初始化:
// class MicroFrontend
renderMicroFrontend = () => {
const { name, history } = this.props;
window[`render${name}`](`${name}-container`, history);
// E.g.: window.renderBrowse('browse-container', history);
};
最后要做的是清理工作。当 MicroFrontend
从页面中删除组件时,我们也应该卸载相关的微应用。
componentWillUnmount() {
const { name } = this.props;
window[`unmount${name}`](`${name}-container`);
}
微前端应用
接下来介绍 window.renderBrowse
方法是怎么实现的:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
window.renderBrowse = (containerId, history) => {
ReactDOM.render(<App history={history} />, document.getElementById(containerId));
registerServiceWorker();
};
window.unmountBrowse = containerId => {
ReactDOM.unmountComponentAtNode(document.getElementById(containerId));
};
上面代码用到了 ReactDOM.render
和 ReactDOM.unmountComponentAtNode
。
为了独立开发和运行微前端。每个微前端应用还具有额外的 index.html
,以在容器外部独立呈现:
<html lang="en">
<head>
<title>Restaurant order</title>
</head>
<body>
<main id="container"></main>
<script type="text/javascript">
window.onload = () => {
window.renderRestaurant('container');
};
</script>
</body>
</html>
从现在开始,微前端大多只是普通的 React
应用程序。'browser' 应用餐厅列表,提供 <input>
用来搜索和过滤,并用 <Link>
把结果包裹起来,用户点击时导航到一个特定的餐厅。然后,我们将切换到第二个 'order'
微应用,展示一个带有菜单的餐厅页面。
通过路由进行跨应用程序通信
我们之前提到过 ,应将跨应用通信保持在最低限度。在此示例中,我们唯一的通信是 browser
页面需要告诉 order
页面要加载哪个餐厅。我们使用路由来解决此问题。
涉及到三个 React
应用,都用React Router
进行路由,但是以两种略有不同的方式进行初始化。
对于容器应用程序,我们创建一个,它会在内部实例化一个history
对象,我们使用该对象来处理客户端历史记录,也可以使用它来将多个React Router
链接在一起。初始化路由的方式为
<Router history={this.props.history}>
这个 histroy
是由容器应用提供的,所有微应用共用这个 history
对象。这使得用 url
作为消息传递方式变得十分简便。例如,我们有一个像这样的链接:
<Link to={`/restaurant/${restaurant.id}`}>
单击此链接后,该路径将在容器中更新,该容器将看到新的URL并确定应该安装和呈现餐厅微应用。然后,该微应用自己的路由逻辑将从URL中提取餐厅ID。
我希望这个示例能够显示 URL 的灵活性和强大功能。使用 URL 作为消息传递应该满足一下条件:
-
URL 的结构是开放透明的
-
URL 的访问权限是全局的
-
URL 的长度是有限的
-
面向用户建模,URL 应该易于理解
-
它是声明式的,而不是命令式的。即 URL 表示当前页面的位置,而不是当前页面该做什么
-
它迫使微前端应用进行间接通信,而不是直接依赖
当使用路由作为微前端应用之间的通信方式时,我们选择的路由即构成合同。合同一旦确定,不能轻易修改,所以我们应该进行自动化测试,以检查合同是否得到遵守。
共享内容
虽然我们希望每个团队和微应用尽可能独立,但是有些事情还是会共享的。
上面提过共享组件库,但是对于这个小型应用而言,组件库会显得过大。因此,我们有一个小 的公共内容库,其中包括图像、JSON数据和CSS,这些内容被所有其他微应用共享。
还有一个重要的东西需要共享:依赖库。重复的依赖项是微前端的一个常见缺点。即使在应用程序之间共享这些依赖也非常困难,我们来讨论如何实现依赖库的共享。
第一步是选择要共享的依赖项。对我们已编译代码的分析表明,大约50%的代码是由 react
和 react-dom
贡献。这两个库是我们最核心的依赖项,因此如果把这两个库单独提取出来作为共享库,会非常有效。最后,它们是非常稳定和成熟的库,升级也很慎重,所以升级工作应该不会太困难。
至于如何提取,我们需要做的就是在 webpack 配置中将库标记为外部库(externals)
:
module.exports = (config, env) => {
config.externals = {
react: 'React',
'react-dom': 'ReactDOM'
}
return config;
};
然后,用 script
向每个index.html
文件添加几个标签,以从共享内容服务器中获取这两个库:
<body>
<div id="root"></div>
<script src="%REACT_APP_CONTENT_HOST%/react.prod-16.8.6.min.js"></script>
<script src="%REACT_APP_CONTENT_HOST%/react-dom.prod-16.8.6.min.js"></script>
</body>
缺点
与所有架构一样,微前端架构中也存在一些折衷。我们得到好处的同时,也伴随着成本。
下载量
独立构建的 JavaScript 文件可能导致重复的公共依赖,从而增加用户的下载量。例如,如果每个微应用都包括自己的 React 副本,那么用户就得多次下载 React。
这个问题不容易解决,那可以得到缓解。首先,即使我们不做任何优化,每个单独页面的加载速度也有可能比构建单个整体式前端要快。原因是如果独立地编译每个页面,我们就可以有效地进行代码拆分,页面只加载当前页面的依赖项。这可能会导致初始页面加载很快,但随后的导航速度变慢,因为用户被迫在每个页面重新下载相同的依赖项。我们可以对用户常去的页面进行分析,然后单独优化他们的依赖项。
每个项目是不同的,你必须针对性地进行分析。
环境差异
当微应用越来越多,你在本地开发时肯定无法把所有微应用和对应的后端都启动起来,那么你就不得不在本地进行环境的简化。
如果开发环境和生成环境的环境是不同的,这往往会造成问题。所以你需要保证,如果开发者想要完全模拟生成环境,也是可以做到的。只不过会非常耗时。
治理复杂性
微前端作为一个更加分布式的体系结构,将不可避免地要管理更多的东西:更多的代码库、更多的工具、更多的构建管道、更多的服务器、更多的域名等。因此在采用这样的体系结构之前,您需要考虑几个问题:
-
你是否有足够的自动化措施来配置和管理所需的其他基础架构?
-
你的前端开发、测试和发布过程是否可以扩展到多个应用程序?
-
你是否准备好了让决策变得更加分散,甚至难以控制感?
-
你将如何确保多个独立的前端代码库的质量、一致性和或治理水平?
结论
多年来,随着前端不断变复杂,我们看到了对更可扩展的体系结构日益增长的需求。我们应该能够通过独立的自治团队来开发软件。
虽然微前端不是唯一的办法,但我们已经看到了许多微前端达到这些目标的实际案例,并且随着时间的推移,我们已经能够逐渐将这项技术应用于旧网站。无论微型前端对你和你的团队是不是正确的方法,微前端都是一种趋势,在这种趋势下,前端工程化和前端体系结构都将变得越来越重要。
交流
关注公众号,后台回复福利,即可看到福利,你懂的。
每次整理文章,一般都到2点才睡觉,一周4次左右,挺苦的,还望支持,给点鼓励
转载:https://blog.csdn.net/qq449245884/article/details/101730876