写在前面
我宣布:React 最好的脚手架为 Angular CLI!
本文为整活教程,切勿在生产项目中使用。
背景
写了六年 Angular 项目,最近有一天写累了,突发奇想,能否在 Angular 项目中使用其它框架,换换口味呢?
当然,前提是:不通过微前端的方法,且在不修改项目和框架默认配置的情况下实现。也就是说,我在原本 Angular 项目的 package.json 中额外引入另一个框架的依赖,并且可以直接使用。如果需要再引入额外的大量配置,或者去解开 Angular 框架所隐藏的 Webpack 配置文件,去魔改框架编译配置等,那就没必要了。
于是,我发现 React 可以满足这一点。你甚至可以直接用 Angular 项目模板当做 React 脚手架。
本文代码仓库:react in angular: 在 Angular 框架中玩 React (gitee.com)
创建项目
首先通过 Angular CLI 创建一个标准 Angular 项目:
项目创建完成后,写一个空白的 container 组件,以做准备:
项目可以正常运行起来后,接下来就可以引入 React 依赖了。
引入 React 依赖
根据我测试所得,在 Angular 中使用最基础的 React ,只需引入最基础的 react 与 react-dom 即可。于是首先执行这两条命令:
npm install react react-dom
npm install --save-dev @types/react @types/react-dom
要想 React 组件写起来方便,css 预处理与样式隔离也是需要做的。在这里我推荐使用 css in js 方案。因为我已经试过在 Angular 项目里面, React 无法识别基于 scss 的 css module 。而且这么玩,去修改 Angular 的底层对 scss 的配置,使其去支持 react 的 css module ,是得不偿失的。
npm install @emotion/react @emotion/styled
Emotion 不需要额外配置就能直接使用 styled 函数了。
最后,在 tsconfig.json 添加两行配置以支持 tsx 与 React 模块的引用:
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
经过简单地依赖安装与配置,现在我们就可以直接在这个 Angular 项目中,写 React 组件了。
创建一个 React 组件
要做就做一个有意义的组件,而不是 demo 。我在网上找一个时间小部件,把它改写为 React 的代码。
把它源码下载下来,效果如下:
优化和修改亿点点细节,使其成为 React 组件:
strip-clock.component.tsx
import React, { useEffect, useRef, useState } from 'react';
import Container from './strip-clock.style';
type StripsType = Array<{
sub: Array<{ value: number, pop: boolean }>,
transform?: string,
}>
const StripClock: React.FC = () => {
const [strips, setStrips] = useState<StripsType>([]);
const isIntersecting = useRef<boolean>(false);
const loopInterval = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const stripsRef = useRef<StripsType>([]);
const numberHeight = useRef(0);
const init = () => {
// hrStrips
let hrStrips: StripsType = [];
hrStrips[0] = { sub: [] };
for (let i = 0; i <= 2; i++) {
hrStrips[0].sub.push({ value: i, pop: false });
}
hrStrips[1] = { sub: [] };
for (let i = 0; i <= 9; i++) {
hrStrips[1].sub.push({ value: i, pop: false });
}
// minStrips
let minStrips: StripsType = [];
minStrips[0] = { sub: [] };
for (let i = 0; i <= 5; i++) {
minStrips[0].sub.push({ value: i, pop: false });
}
minStrips[1] = { sub: [] };
for (let i = 0; i <= 9; i++) {
minStrips[1].sub.push({ value: i, pop: false });
}
// secStrips
let secStrips: StripsType = [];
secStrips[0] = { sub: [] };
for (let i = 0; i <= 5; i++) {
secStrips[0].sub.push({ value: i, pop: false });
}
secStrips[1] = { sub: [] };
for (let i = 0; i <= 9; i++) {
secStrips[1].sub.push({ value: i, pop: false });
}
stripsRef.current = [...hrStrips, ...minStrips, ...secStrips];
}
const update = () => {
setStrips([...stripsRef.current]);
}
const highlight = (strip: number, d: number) => {
stripsRef.current[strip].sub[d].pop = true;
update();
setTimeout(() => {
stripsRef.current[strip].sub[d].pop = false;
update();
}, 950);
}
const stripSlider = (strip: number, number: number) => {
let d1 = Math.floor(number / 10);
let d2 = number % 10;
stripsRef.current[strip].transform = `translateY(${d1 * -numberHeight.current}px)`;
highlight(strip, d1);
stripsRef.current[strip + 1].transform = `translateY(${d2 * -numberHeight.current}px)`;
highlight(strip + 1, d2);
update();
}
const render = () => {
if (!isIntersecting.current) {
return;
}
const time = new Date();
const hours = time.getHours();
const mins = time.getMinutes();
const secs = time.getSeconds();
stripSlider(0, hours);
stripSlider(2, mins);
stripSlider(4, secs);
}
const loopControl = (run: boolean) => {
if (run) {
clearInterval(loopInterval.current);
loopInterval.current = setInterval(() => render(), 1000);
render();
} else {
clearInterval(loopInterval.current);
loopInterval.current = null;
}
}
useEffect(() => {
init();
update();
const intersectionObserver = new IntersectionObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) {
return;
}
for (let entry of entries) {
isIntersecting.current = entry.isIntersecting;
loopControl(isIntersecting.current);
}
});
containerRef.current && intersectionObserver.observe(containerRef.current);
return () => {
containerRef.current && intersectionObserver.unobserve(containerRef.current);
loopControl(false);
}
}, []);
return (
<Container onNumberHeightChange={(e) => numberHeight.current = e}>
<div className='clock' ref={containerRef}>
<div className='hr'>
<div className='strip' style={{ transform: strips[0]?.transform }}>
{strips[0]?.sub.map((each, i) => {
return (
<div className={`number ${each.pop ? 'pop' : ''}`} key={i}>{each.value}</div>
);
})}
</div>
<div className='strip' style={{ transform: strips[1]?.transform }}>
{strips[1]?.sub.map((each, i) => {
return (
<div className={`number ${each.pop ? 'pop' : ''}`} key={i}>{each.value}</div>
);
})}
</div>
</div>
<div className='min'>
<div className='strip' style={{ transform: strips[2]?.transform }}>
{strips[2]?.sub.map((each, i) => {
return (
<div className={`number ${each.pop ? 'pop' : ''}`} key={i}>{each.value}</div>
);
})}
</div>
<div className='strip' style={{ transform: strips[3]?.transform }}>
{strips[3]?.sub.map((each, i) => {
return (
<div className={`number ${each.pop ? 'pop' : ''}`} key={i}>{each.value}</div>
);
})}
</div>
</div>
<div className='sec'>
<div className='strip' style={{ transform: strips[4]?.transform }}>
{strips[4]?.sub.map((each, i) => {
return (
<div className={`number ${each.pop ? 'pop' : ''}`} key={i}>{each.value}</div>
);
})}
</div>
<div className='strip' style={{ transform: strips[5]?.transform }}>
{strips[5]?.sub.map((each, i) => {
return (
<div className={`number ${each.pop ? 'pop' : ''}`} key={i}>{each.value}</div>
);
})}
</div>
</div>
</div>
</Container>
);
}
export default StripClock;
strip-clock.style.tsx
import React, { ReactNode, useEffect, useRef } from "react";
import styled from "@emotion/styled";
type PropsType = {
onNumberHeightChange: (e: number) => void,
children: ReactNode,
}
const maxWidth = 500;
const Container = styled((props: PropsType) => {
const { onNumberHeightChange, children, ...rest } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [vars, setVars] = React.useState<React.CSSProperties>({} as React.CSSProperties);
const resizeChange = (entry: ResizeObserverEntry) => {
let containerWidth = entry.target.clientWidth;
if (containerWidth == 0) {
return;
}
let numberWidth = (containerWidth * 0.8 * 0.8 / 3) * 0.8 / 2;
setVars({
'--container-height': entry.target.clientHeight + 'px',
'--strip-border-radius': numberWidth / 2 + 'px',
'--number-height': numberWidth + 'px',
'--number-font-size': (numberWidth > 40 ? 16 : 14) + 'px',
} as React.CSSProperties);
onNumberHeightChange(numberWidth);
}
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) {
return;
}
for (let entry of entries) {
resizeChange(entry);
}
});
containerRef.current && resizeObserver.observe(containerRef.current);
return (): void => {
containerRef.current && resizeObserver.unobserve(containerRef.current);
};
}, []);
return (
<div {...rest} style={vars}>
<div className='container' ref={containerRef}>{children}</div>
</div>
);
})(({ }) => ({
'--antd-dust-red': '#f5222d',
'--antd-volcano': '#fa541c',
'--antd-sunset-orange': '#fa8c16',
'--antd-calendula-gold': '#faad14',
'--antd-sunrise-yellow': '#fadb14',
'--antd-lime': '#a0d911',
'--antd-polar-green': '#52c41a',
'--antd-cyan': '#13c2c2',
'--antd-daybreak-blue': '#1890ff',
'--antd-geek-blue': '#2f54eb',
'--antd-golden-purple': '#722ed1',
'--antd-magenta': '#eb2f96',
'--ion-color-light': '#f4f5f8',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
width: '100%',
height: `100%`,
background: 'linear-gradient(-45deg, var(--antd-daybreak-blue), var(--antd-cyan))',
overflow: 'hidden',
'& > .container': {
position: 'relative',
width: '100%',
maxWidth: `${maxWidth}px`,
height: '100%',
overflow: 'hidden',
'& > .clock': {
display: 'flex',
flexDirection: 'row',
gap: '10%',
padding: '0 10%',
width: '80%',
height: '100%',
'& > .hr, & > .min, & > .sec': {
flex: '1',
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
gap: '20%',
marginTop: 'calc(var(--container-height) / 2 - var(--number-height) / 2)',
'& > .strip': {
flex: '1',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--antd-cyan)',
borderRadius: 'var(--strip-border-radius)',
boxShadow: '-16px -16px 32px -8px var(--antd-daybreak-blue), 16px 16px 32px var(--antd-geek-blue)',
transition: 'transform 500ms ease-in-out',
'& > .number': {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: 'var(--number-height)',
color: '#f4f5f8',
fontSize: 'var(--number-font-size)',
fontFamily: '"Roboto Mono", monospace',
borderRadius: '50%',
transition: 'all 500ms 100ms ease',
userSelect: 'none',
},
'& > .number.pop': {
color: 'var(--antd-daybreak-blue)',
fontWeight: 'bold',
transform: 'scale(1.3)',
backgroundColor: 'var(--ion-color-light)',
boxShadow: '-4px -4px 8px -4px var(--antd-daybreak-blue), 4px 4px 8px var(--antd-geek-blue)',
},
},
},
},
},
}));
export default Container;
引入 Angular 组件中
在 Angular 项目中创建一个组件,用来引入上述 React 代码:
在 strip-clock.component.ts 的生命周期中,对 React 组件进行创建与卸载:
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, ViewEncapsulation } from "@angular/core";
import { ReactComponent } from "src/app/react-component";
import StripClock from "./react/strip-clock.component";
@Component({
encapsulation: ViewEncapsulation.Emulated,
selector: 'app-strip-clock',
template: `
<div style="width: 100%; height: 100%" #react></div>
`,
})
export class StripClockComponent implements AfterViewInit, OnDestroy {
@ViewChild("react", { static: true }) reactEleRef!: ElementRef<HTMLDivElement>;
private reactComponent?: ReactComponent;
ngAfterViewInit() {
this.reactComponent = new ReactComponent(this.reactEleRef.nativeElement, StripClock);
}
ngOnDestroy(): void {
this.reactComponent?.destroy();
}
}
react-component.ts 该文件用于封装 React 组件创建与卸载的方法:
import { createRoot, Root } from "react-dom/client";
import React from "react";
export class ReactComponent {
private readonly root: Root;
constructor(container: HTMLDivElement, component: React.FC) {
this.root = createRoot(container);
this.root.render(React.createElement(component));
}
public destroy(): void {
this.root.unmount();
}
}
将 StripClockComponent 放入 ContainerComponent 中,就能看到 React 组件正确地在 Angular 中显示出来了:
container.component.html
<div class="container">
<div class="title">container</div>
<div class="strip-clock">
<div class="title">strip-clock</div>
<app-strip-clock></app-strip-clock>
</div>
</div>
组件传值与回调
要达到正常使用的效果,组件传值与回调功能是必不可少的。
实现该功能,先对上述所使用的封装类 react-component.ts 将其后缀改为 tsx ,并做些许扩充,实现 props 传参:
react-component.tsx
import { createRoot, Root } from "react-dom/client";
import React, { useState } from "react";
export class ReactComponent {
private readonly root: Root;
public propsState: { [key in string]: { set: (v: any) => void } } = {};
constructor(container: HTMLDivElement, component: React.FC<any>, props?: {
[key in string]: {
value: any,
state?: boolean,
}
}) {
const content = () => {
const propsValue: { [key in string]: any } = {};
if (props != null) {
for (let key in props) {
if (props[key].state) {
const [value, setValue] = useState<any>(props[key].value);
propsValue[key] = value;
this.propsState[key] = { set: setValue };
} else {
propsValue[key] = props[key].value;
}
}
}
return (<>{React.createElement(component, propsValue)}</>);
}
this.root = createRoot(container);
this.root.render(React.createElement(content));
}
public destroy(): void {
this.root.unmount();
}
}
然后创建一个 Angular 组件与 React 组件,同上文,将 React 组件托管与 Angular 组件中:
test.component.tsx
import React from 'react';
const Test: React.FC<{ currentTime: number, clickCallback: () => {} }> = (props) => {
return (
<div>
<div>这是 Test 组件,测试组件传值</div>
<div>React: {props.currentTime}</div>
<a style={{ textDecoration: 'underline', cursor: 'pointer' }}
onClick={props.clickCallback}>
点击此处,回调到 Angular 组件
</a>
</div>
)
}
export default Test;
test.component.ts
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, SimpleChanges, ViewChild, ViewEncapsulation } from "@angular/core";
import { ReactComponent } from "src/app/react-component";
import Test from "./react/test.component";
@Component({
encapsulation: ViewEncapsulation.Emulated,
selector: 'app-test',
template: `
<div #react></div>
`,
})
export class TestComponent implements AfterViewInit, OnChanges, OnDestroy {
@ViewChild("react", { static: true }) reactEleRef!: ElementRef<HTMLDivElement>;
@Input() currentTime = 0;
private reactComponent?: ReactComponent;
ngAfterViewInit() {
this.reactComponent = new ReactComponent(this.reactEleRef.nativeElement, Test, {
currentTime: { value: this.currentTime, state: true },
clickCallback: { value: () => this.reactClick() }
});
}
ngOnChanges(changes: SimpleChanges): void {
this.reactComponent?.propsState['currentTime'].set(changes['currentTime'].currentValue);
}
ngOnDestroy(): void {
this.reactComponent?.destroy();
}
reactClick(): void {
console.log('React 组件点击回调');
}
}
container.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-container',
templateUrl: './container.component.html',
styleUrls: ['./container.component.scss']
})
export class ContainerComponent implements OnInit {
currentTime = 0;
ngOnInit(): void {
setInterval(() => {
this.currentTime = new Date().getTime();
}, 200);
}
}
上述代码的作用是接收 TestComponent 父组件 ContainerComponent 传入的值,并将其传入到 React 子组件中,并能根据父组件 ContainerComponent 中的变化,在 React 子组件中实现响应。同时 React 子组件中有传入一个回调方法,回调至 TestComponent 组件。
可以看到,ContainerComponent 组件中的 currentTime 通过 TestComponent 传入到 React 子组件中,更新该值的同时, React 能正确响应到变化。并且 React 子组件的点击事件也能正确回调到 Angular组件。
该传入值可以通过 useEffect 监听到响应。
结束
代码已提交至 gitee ,详细实现方法直接看代码即可。本文内容并不是啥高大上的框架技术与底层源码,而是考验你思维的灵活性与框架使用的熟练度。这一点恰好也是很多前端人所欠缺的。
本文仅仅只实现了创建组件、传值、响应、回调,其实有耐心的话也可以加上状态管理,组件通信等高级功能,从“图个乐”的程度达到能在真实项目中使用的程度。当然也没这个必要。重点就在于,在“图个乐”的玩法中,学习灵活地玩转不同的框架,理解前端框架的本质,才能更好地应对当今框架层出不穷的内卷时代。