用typescript撸个前端框架InDiv
有个同事跟我说:需求还是不够多,都有时间造轮子了。。。
前言
这个轮子从18年4月22造到18年10月12日,本来就是看了一个文章讲前端框架的路由实现原理之后,想试着撸一个路由试试,结果越写越多,到最后就莫名其妙变成了个mvvm框架了。顺便写了个比较渣的文档和服务端渲染。。。
InDiv简介
名字其实是瞎起的,因为组件要被包在一个div里,所以叫了InDiv
。
整个项目是用typescript
写的,真心说一句ts真优雅。
在思考怎么写的时候参照了大量ng react vue的架构与实践,用自己能想到的最好的方法实现了一下,也算是对自己的锻炼了一番(其实在写的时候,发现越写越像ng,可能是我真的太喜欢angular了吧)。
之后还实现了一个服务端渲染的,但是有点简陋。。。
此刻多么想致敬下三大框架的开发者大佬们,造轮子不易
- 主要分为模块(NvModule),组件(Component),和服务。
- 模板使用字符串模板,我自己定义了一些例如:
nv-class
,nv-repeat
等指令,然后再模板中仅仅可以使用来自组件实例中state的值($.
)和实例的方法(@
),所以显得比较丑陋(先造出来再说)。 - 暂时没有指令和pipe。其实在字符串模板的里可以使用组件上带有返回值的方法(
nv-src="@buildSrc($.src)"
),返回值会被渲染到模板中,也算是暂时没有做出pipe的补充。 - 自带路由,采用基于virtual DOM的异步渲染,但是路由懒加载暂时还没有。
- 模块负责导入导出组件,导入其他模块和注册服务。当前模块内的组件可以使用来自根模块和当前模块的任何服务及组件,也可以使用被导入模块中导出的组件。
- 如果没有特殊声明,在任何模块中被声明的服务将成为全局单例,但组件或服务只能注入当前模块内的服务或来自根模块的服务;而在组件中被声明的服务将跟组件实例走,每个组件实例都有一个独立的服务实例。(其实是实现了个3级的注入器)。
- 组件实现了几个生命周期,在ts里可以通过
implements
类型,而在js里只能手写生命周期方法。 - 通过
Object.defineProperty
监听state
,任何直接更改state
的属性 及 通过setState
更改state
的操作都为同步操作,会引起当前组件的重新渲染;而在子组件中,通过调用props
中父组件的方法去更改父组件state
的时候,子组件不会立刻就得到更新后的props
,因为渲染为异步的,而且渲染之后才能得到propos
。 - 因为使用
Object.defineProperty
监听state
,所以无法监听到state中数组item的增加插入移除,所以如果想更改数组结构请只用setState
重置state
中的该项。 - 在ts中实现了依靠
constructor
的参数类型当做令牌的依赖注入并通过@Injected
声明需要注入;在js中只实现了依靠静态属性injectTokens: string[]
声明字符串当令牌的服务。 - 封了了
axios
作为http服务,并在Utils类中集合了一些我平时用的工具。
使用
组件
- 通过注解
Component
来提供元数据,并声明选择器,模板,及组件providers - 通过注解
Injected
来声明下面的类需要注入服务 - 通过
implements
来实现生命周期钩子函数 - 提供
SetState, GetLocation, SetLocation
等类型,在组件中可以使用ths.setState, this.getLocation, this.setLocation
等内置方法来改变状态、获取路由状态、设置路由状态等 - 如果使用
JavaScript
开发,除了不能使用Injected
声明需要注入服务而通过类的静态属性injectTokens: string[]
注入服务之外都差不多
import { Component, SetState, GetLocation, SetLocation, Injected, OnInit, RouteChange, OnDestory } from 'indiv'; import TestService from '../../service/test'; @Injected @Component({ selector: 'app-container-component', template: (` <div class="app-container" nv-class="$.showSideBar" nv-on:click="@changeShowSideBar()"> <side-bar handle-side-bar="{@changeShowSideBar}" can-show-sidebar="{$.showSideBar}" ></side-bar> <div nv-repeat="let test in $.testList" nv-key="test.id"> name:{{test.name}} id:{{test.id}} <side-bar nv-key="test.id" handle-side-bar="{@changeShowSideBar}" can-show-sidebar="{$.showSideBar}" ></side-bar> </div> <router-render></router-render> </div> `), providers: [{ provide: TestService, useClass: TestService, }, // 也可以直接 TestService,TestService当做令牌 ], }) export default class AppContainerComponent implements OnInit, RouteChange, OnDestory { public state: { showSideBar: string; testList: {id: number;name: string;}[]; } public setState: SetState; constructor( private testS: TestService, ) { this.subscribeToken = this.testS.subscribe(this.subscribe); } public nvOnInit() { this.state = { showSideBar: 'open', testList: [ { id:0, name: 'dima' }, { id: 1, name: 'xxx' } ] }; } public nvRouteChange(lastRoute?: string, newRoute?: string): void {} public nvOnDestory() { this.subscribeToken.unsubscribe(); } public changeShowSideBar() { if (this.state.showSideBar === 'open') { this.state.showSideBar = 'close'; } else { // this.state.showSideBar = 'open'; 也可以用setState this.setState({showSideBar: 'close'}); } } }
服务与依赖注入
- 跟
angular
的服务类似默认为全局单例,但是可以在@Injectable({isSingletonMode: false})
指定isSingletonMode
为false
,这样该服务实例就不会在IOC容器内创建出来,每次注入都会重新通过工厂函数创建个新的服务实例 - 通过注解
Injected
,服务也能被注入其他服务 - 推荐使用rxjs来实现组件通信
- 服务可以在组件,模块中声明,但是有些不同
- 模仿了ng的实现
import { Subject, Subscription } from 'rxjs'; import { Injectable, Injected } from 'indiv'; @Injected @Injectable() export default class TestService { public data: number; public subject: Subject<any>; constructor( private testService2: TestService2 ) { this.data = 1; this.subject = new Subject(); } public subscribe(fun: (value: any) => void): Subscription { return this.subject.subscribe({ next: fun, }); } public update(value: any) { this.subject.next({ next: value, }); } public unsubscribe() { this.subject.subscribe(); } }
模块
- 模块通过
@NvModule
装饰器接收五个参数,声明某些组件(component)、服务(service)属于这个模块 - 模块可以导出组件给其他模块用
- 整个应用需要一个根模块,并且如果不使用路由则组要在根模块定义
bootstrap
import { NvModule } from 'indiv'; import AppContainerComponent from '../pages/app.container.component'; import TestService from '../service/test.service'; import TestService2 from '../service/test2.service'; @NvModule({ imports: [], // 引入其他模块 providers: [ { provide: TestService, useClass: TestService, }, TestService2, ], components: [ AppContainerComponent, ], exports: [ AppContainerComponent, ], bootstrap: AppContainerComponent, // 如果不适用路由需要在根模块声明bootstrap的组件 }) export default class AppModule { } import { InDiv } from 'indiv'; const inDiv = new InDiv(); inDiv.bootstrapModule(AppModule); // inDiv.use(router); 使用路由 inDiv.init();
生命周期钩子
仅仅实现了下面这几种,这里又大量借鉴了react。除此之外class的setter getter
也可以当做生命周期
constructor() nvOnInit(): void; nvBeforeMount(): void; nvAfterMount(): void; nvHasRender(): void; nvOnDestory(): void; nvWatchState(oldState?: State): void; nvRouteChange(lastRoute?: string, newRoute?: string): void; nvReceiveProps(nextProps: State): void;
虚拟DOM
通过将DOM结构转化为VNode,并diff出差异并应用在真实DOM上,其实也类似react的diff算法。
diff子元素
- 只diff同级子元素,禁止跨层级diff
- 优先匹配新旧VNode中tagName和key都相同的元素,并计算位置差异
- 旧VNode中的子元素如果没有匹配上则放入移除队列
- 新VNode中的子元素如果没有匹配上,则找到它的位置放入插入队列
- 匹配到的两个新旧VNode如果不是InDiv自定义的组件元素,则开始diff两个匹配元素的属性事件等并继续diff下一层子元素
- 匹配到的两个新旧VNode如果是InDiv自定义的组件元素,则跳过匹配下一层子元素,将diff交给组件的compiler
- 最后在每个组件的内部统一update各个队列,遵循先移除后插入再替换属性等
to do
- 支持自定义指令
- 路由懒加载
- 使用
Proxy
代替Object.defineProperty
或实现脏检查取消state
和props
- @indiv/cli
最后
关于其他字符串模板,http ,utils 路由等在文档 里都有,文笔不好还请各位见谅(估计没人能看懂)。
如果看不懂的话可以去看文档的源码,完全用indiv实现。(吹一波牛逼)
其实整个项目就是一时兴起写的,也没有写单元测试,估计bug不少。作为一个前端菜鸡,还是在深知自己众多不足以及明白好记性不如烂笔头的道理下,多造轮子总归不会错的。
最后感谢各位大佬看到最后