更新 : 2020-6-22
當 ngtemplate 被丟到千里之外, detech change 失效
refer issue : https://github.com/angular/vscode-ng-language-service/issues/824
舉個例子,
比如我們把一個 template 傳進去 mat dialog 里面,然后讓 dialog 里面負責 container.insert template
template 的 “家” 是打開 dialog 的組件. 假設 template 里面有一個 click 事件會修改外面組件的值.
但是呢,你會發現點擊的時候並不會更新.
如果是 index 在 template 內就 ok.
如果你希望它更新的話,那么 click 就要寫 cdr.markForCheck 咯。
所以要記得哦,template 被傳去千里之外后,它的家就失去對它的監聽了.
更新: 2020-06-22
當 ngZone.onStable 遇上 container.createEmbeddedView
今天遇到了一個坑.
我們知道 container insert 是不會幫我們做 detect change 的.
但是很奇怪哦,如果在 AfterViewInit 的時候我們 insert template 是 ok 的.
但是如果你是在 ngZone.onStable 里面 insert template 那就不 ok 了
沒有花太多時間去研究這個,但是推測應該是 after view 以后其實還是會往下去檢測的.
但是 stable 后就肯定是不會了啦.
當然不管是上面那一個都不太邏輯. 因為我們應該要確保 insert 以后一定要 detect change 丫.
更新: 2020-06-01
使用 flexible-connected-position 時, append component after view init 的時候 host element client width 是 1 ?!
今天踩了一個坑
material 的 overlay 在處理 flexible connected position 的時候會有一個
BoundingBox 然后是 panel 然后是我們 append 的組件
更新 : 2020-02-13
關於 position 的細節
1. withFlexibleDimensions(true)
默認是 true
overlay 在決定 position 時, 它會依據我們給的先后 position
順序去看,如果其中一個可以完全顯示就馬上用那個。
如果全部都不能完整顯示,那么就要試試看調位置, 然后比分數
然后是這樣
最后比分數
所以這個是配合 minHeight, minWidth 來使用的.
還有一個設置也要留意

window resize 之所以可以繞過 lock 是通過 _isInitialRender 實現的
另外還有一種就是我們手動調用 updatePosition.
如果我們的內容是動態的,或者是 ajax 比較慢加載回來的話, 那么一般上我們都需要使用這個 reposition 來 update 一下.
總結 :
3 種情況下我們會需要 update position
1. onscroll, (lock/nolock)
2. on resize, (no lock)
3. on content change (lock/nolock)
遇到的問題是 1,3 只可以用同一個config, 比如你 lock 那么 2 個都得 lock, 如果 nolock 2個都得 nolock
因為目前 material 並沒有給多得接口用, 除非我們調用 private 的 _isInitialRender
更新: 2019-11-24
dialog vs router link
refer : https://stackoverflow.com/questions/51821766/angular-material-dialog-not-closing-after-navigation
今天發現一些場景可能導致 dialog 不會關閉. 比如當子組件打開一個 dialog 后
某一個操作把父組件給銷毀了.這個時候 dialog content 會一起銷毀掉,
因為 content 是 under 這個邏輯樹中 (當然如果你是放到 appRef 里頭就另外說)
content 銷毀了,但是 overlay 留在 body 丫.
material team 有考慮到這種情況所以做了一個 fallback 機制, 但是這個並不能解決上面的問題,因為無論如何 dialog 要求至少要啟動 animation start
如果是 router link 切換的話,渲染會在同一個 detech change 下完成,所以 animation start 是不會被觸發的。
目前 dialog 沒有提供 displose 的方法,所以基本上不無法做到的,除非你去監聽 router event 之類的。
那我覺得比較合理的處理方式是。如果組件負責打開 dialog or overlay
那么當這個組件 onDestroy 的時候,必須要確保它負責的 overlay 一定要 displose. 為此 dialog 應該要公開這個接口讓我們使用的.
更新 : 2019-11-14
小總結一下 angular 動態組件 -> portal -> overlay -> dialog
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
) { }
制作出組件工廠, 把動態組件丟進去就可以了
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(DynamicComponent);
然后就可以創建組件實例了,這個時候需要基於一個注入器
通過 Injector.create 創建出一個新的 injector 並且繼承 parent injector, ng 的 injector 有分層的概念 ngModule 的 provider 通常會放到 root injector 里頭, lazy load module 則像下面那樣創建出第 2 層級的 injector
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(DynamicComponent); const injector = Injector.create({ providers: [{ provide: 'extraProvider', useValue: 'dada' }], parent: this.injector }); const componentRef = componentFactory.create(this.injector);
這個時候組件就已經被實例化了,但是還沒有發生 detech change, OnInit 也還沒跑.
這個時候組件是獨立的,我們知道 angular 把所有東西看成 VIew
組件就是組件 view, 模板就是 embedded view
然后所有 view 都必須放到 logical view tree 里頭. 這樣 change detech 才能遍歷執行
所以現在組件創建好以后,我們需要給它一個家.
可以是 ViewContainerRef 或 ApplicationRef
像這樣
this.applicationRef.attachView(componentRef.hostView); this.container.insert(componentRef.hostView, 0);
2 者最大的區別是在 detech change 上, 如果你放到 app 里頭, 那么組件是在最上層, 一旦 app.tick 觸發. 此組件就會觸發 doCheck
如果你是放到 container 里頭,那么要看這個 container 在 logical tree 里面的第幾層. app.tick 時就不一定觸發 doCheck 了,要看 detech change 有沒有流到這一層里頭 (OnPush 的情況下)
此外, applicationRef.attachVIew 和 container 還有一個不同是, appRef attach 並不會把 dom append 出去
它只是把組件放進去 logical view tree 而且,並沒有 append to dom.
view container 則會做這個事情.
那我們得自己搞, 比如...
document.body.appendChild((componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement);
當然絕大部分情況下,我們應該使用 view container 因為這個是官方教我們正確插入 dom 的方式.
上面這個通常是用在 dialog 那種要 append to body 最外層的情況. 由於那里已經脫離的 angular 的 scope 所以我們得自己弄.
不管是 container.insert 還是 appRef.attachView 調用后,組件就會被 detech change OnInit 了.
組件 append 出去或需要一個 detech change 的 cycle 才會渲染哦. viewContainer 並不會替我們渲染組件. 它只是單純的 append 而已.
注意 :
動態組件的 detech change 是比較難懂的. 我是在發現問題,看了源碼之后,找特定關鍵字才找到了相關的文章
https://netbasal.com/things-worth-knowing-about-dynamic-components-in-angular-166ce136b3eb
意思是 componentRef.hostview.detechChange 只會讓 component DoCheck 而已.
因為 componentRef.hostView 並不是 componentRef.instance.changeDetectorRef
componentRef.hostView 是一個 RootView 而不是 LView
RootView 重寫了 detech change
LView 的 detechChanges 是這樣的
所以你會發現 hostView['_lView'] === instance.changeDetectorRef['_IView']
但是 detechChange 卻不一樣. 至於為什么這樣設計我也不清楚. 總之只有動態 create 出來的 component 才會有這個 RootViewRef
那么問題來了, 外部如何讓內部 detech change 呢 ?
第一種方法就是傳 rxjs 流進去咯. 里面監聽然后 mark for check.
第二種是通過 componentRef.injector 獲取到內部的 changeDetectorRef, 然后調用 markForCheck
此外別無它法, componentRef.detechChange 由於是上層,它只能讓 component DoCheck 而已. 記住了。
所以,記住這幾個點.
1. componentFactory.create
這個時候組件只是被實例化, 沒有 detech change, 沒有 OnInit 沒有 DoCheck.此時它也沒有在 logical tree 里頭
2. ComponentRef.hostView 是 RootViewRef 而不是平常我們看到的 LView
RootViewRef 重寫了 changesDetech 方法,所以當我們調用 hostView.changesDetech 的時候,我們的組件並沒有渲染, 因為它執行的是 component 的上一層, 這只會讓 component 執行 DoCheck 而已.
3. appRef.attachView(componentRef.hostView)
插入到 logical tree 頂端. 每一次 app.tick 就會被執行 hostView.detechChange. <-- 記住它只是讓 component DoCheck 而不是 render.
appRef.attach 不會 append dom, 我們需要自己寫代碼去 append dom.
4.container.insert
插入到 container 這一層級的 logical tree, app.tick 如果有流到這一次就會被 detech change. <-- 還是一樣它只能讓 component DoCheck 而不是 render.
會 append dom 到 container 的位置.
5. 唯一能讓 component detech change render 的方式是傳一個 rxjs 流進去, 或者通過 componentRef.injector 獲取到內部的 ViewRef (也就是 ChangeDetectorRef)
好說完 component,現在說說 template
template 是通過 <ng-template> 制作出來的。
const context = {}; const viewRef = this.templateRef.createEmbeddedView(context);
和 component 相同的,這個時候 viewRef 還沒有被 detech change. 也沒有在 logical tree 里頭.
我們可以通過 appRef.attachView 或者 container.insert 讓它插入到 logical tree 里頭.
這里主要說說它和 component 不同的地方.
首先它沒有 RootView 這個概念, 我們獲取到的就是 ViewRef.
另外 template 和 component 一個很大的區別在於它的通訊值.
template 本身就被定義在某個 component 當中, 然后又被丟到另一個可能千里之外的 component.container 里頭.
<ng-template #template let-age="age" > {{ value }} and {{ age }} </ng-template>
value 來自定義 template 的 component, age 來自使用 template 的 component.
那它的 detech change 是這樣工作的。
當定義它的 component 發生 detech change 時, value 就被更新了, 使用它的組件並不會因此觸發 detech change 之類的, age 也不會從新去拿.
就只是更新了 value 然后渲染出效果而已. 如果你在期中偷偷的修改了 age,ng 是不會發現的, 因為它不會去 get age.
反過來如果是使用 template 的組件做了 detech change, 定義它的組件也不會發生 detech change, 但是呢 value 卻會去 getter 一下 (這里和 age 的表現不相同).
上面說了 component 和 template 的基本用法和 detech change 的更新機制.
現在說說 cdk 的 portal
cdk 提我們封裝了上面這些動態創建 component 和 template 的方法. 其實也沒有什么好封裝的啦,不就那幾行...
但是還是得要搞清楚它是怎樣用起來的。
我們通常會用到是
ComponentPortal
4個都很合理,我們上一段都有用到這些. 至於為什么可以替換 componentFactory 呢, 這個不是很清楚,不是都一樣的嗎.. ?
另一個可能會用到得是
mat dialog 傳遞 data 和 dialogRef 就是用這種簡單方式做的. 它不像上一段使用 Injector.create 然后提供 provider
它只是用一個 weakmap 來實現而已.
然后是 TemplatePortal
都是動態創建需要的東西.
TemplatePortal 和 ComponentPortal 都繼承了 Portal 類
沒什么特別的,只是有 attach 和 detech 的方法而已。然后主要, 這 2 個方法其實內部是調用了 outlet.attach 和 detech
也就是說邏輯根本沒有寫在這里,這 2 個方法只是一個委托方法而已,除了讓初學者亂沒有看出其它意義.
再來一個 DomPortal
很簡單,就是放了一個 dom 在里面...
最后是 PortalOutlet, outlet 的職責是 container 用來 append dom 的,
這個是最常用到的,可以簡單理解它就是 container
另一個是專門給 append body 用的,類似上段我們說的 appRef 然后自己 append body
整個 portal 看下來沒有什么奇特的地方,就真的只是封裝而已.
我覺得最需要懂得邏輯在這里, portal.attach
cdk outlet 和 dom outlet 區別就在於此
先看看 ckd outlet attach template
關鍵在 viewContainerRef, 這個 container 說的是 outlet 這個指令依賴注入得 container (outlet 這個位置)
再看看 ckd outlet attach component
關鍵也在 container, 如果 portal 本身有 container, 就用,不然就用 outlet 的.
這里和剛才 template 不同,template 沒有判斷 portal 是否有 container
這樣的設計我是覺得挺奇怪的,我把 portal 交給了 outlet 結果, portal 被 attach 在原本的 container 里, 這里關 outlet 什么是呢 ?
然后 outlet 被 destroy 時也 destroy 掉 portal ?
我們把疑點留着,等下一起討論. 繼續往下走.
這是 dom outlet 的 attach template
dom outlet 不是指令,它是 new 出來的,所以它本身不會有 view container ref, 所以 portal 理應要有 view container ref
這里沒有任何判斷就直接使用了 portal.viewContainerRef ... 挺勇敢的嘛...
此外這里還有一個 cut and paste 的動作. 當 portal 內容 attach 到 container 后, 這里做了一個 dom 操作就是把內容 cut and paste 到 outlet element 里頭.
這應該是和 cdk outlet 最大的不同點.
最后是 dom outlet 的 attach component
這里有做 viewcontainer 判斷, 和 template 的處理手法不同.
如果 portal 沒有 viewcontainer 那么就放到 appRef 里頭. 最后依然會 cut and paste.
解析完了。總結一下我覺得不太容易理解的幾個點.
1. outlet 決定位置
cdk outlet attach template, dom outlet attach component/template 都可以確保最終的內容渲染在 outlet 位置上.
但是 cdk outlet attach component 卻不是這樣..
這個確定是一個 bug, https://github.com/angular/components/issues/17650 -> https://github.com/angular/components/pull/17731
所以 cdk outlet 的會確保最終內容出現在 outlet 里
2. cdk outlet append component 時,有可能把 component 系在 appRef 上, 但是 append template 時卻不會這樣.
反而強制要求 portal 一定要有 container (我的想到的一個解是, template 定義在 component 里,所以它肯定是有 container 的)
在我的理解中. template 和 component 應該保持行為一致. 讓使用者決定要用哪一種. 可以簡單的替換.
可能 cdk 很靈活時因為 mat 需要這么靈活. 但對我來說這些不一致會導致維護起來比較麻煩.
所以我的做法通常是 portal 不需要帶 container 邏輯.
更新: 2019-11-08
記入一下 overlay 的使用
material 有 8 個組件用到了 overlay
autocomplete
datepicker
select
menu
bottom sheet
dialog
snackbar
tooltip
在真實項目中,還有很多組件是沒有的. 比如
小 form
比如大 message tip
這些都得我們自己去實現. 所以就需要用到 overlay 了.
先說說它的過程
當我們調用 overlay.create 的時候, overlay 會在 body 層創建一個 div
然后依據我們的 width height 在放一個 div 在里面 (其實好像有 3 - 4 層 div)
如果我們要 backdrop 也可以通過 overlay 設置.
有了 backdrop 我們就可以監聽點擊事件然后關掉 overlay 了.
這里有一個小體驗. 很久以前,我是用 body click + stop bubble 來實現這種 modal close 的. 后來發現大家都用 overlay + 透明 backdrop 來做
省去了不少麻煩. stop bubble 在多層次的情況下不太好處理, 但是這個做法也有它的局限. 比如只能 body scroll 因為 backdrop 在最上層, 會把其它 div 擋住, 如果我們依賴其它 div 來做 scroll
那么就 scroll 不了的. 所以多用 body scroll 還是比較正確的姿勢.
我還發現一個小秘密,就是 material tooltip 沒有使用 backdrop 但是缺可以點擊 body 關閉. 它也是通過監聽 body click 實現的,因為 tooltip 內只可以是字, 所以不會有點擊事件也就不需要顧慮 bubble 的問題. 很巧妙的在設計上躲過了實現的難題.
做小 modal 要搞懂 position strategy
const positionStrategy = this.overlay.position() .flexibleConnectedTo(origin) .withTransformOriginOn('.transformOrigin') .withFlexibleDimensions(false) .withViewportMargin(8) .withPush(false) .withLockedPosition(true) .withPositions([ { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'top' }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'top' }, { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom' } ]); const overlayRef = this.overlay.create({ positionStrategy, scrollStrategy: this.overlay.scrollStrategies.reposition(), hasBackdrop: true, });
scroll strategy 用的是 reposition, 這個很好里理解, 就是當 scroll 的時候我們的 modal 需要始終維持對的位置.
來說說 position strategy, 和 big modal 不同, small modal 需要一個位置, 通常是在我們點擊按鈕的附近.
可以叫它 origin element, 我們要呈現的內容 (content) 必須和 origin element 做一個定位.
flexibleConnectedTo(origin element) 就 content connect to origin 的意思
withPositions 提供一個位置匹配, origin 9 個點, content 9 個點, 所以總共可以擺出 81 一個位置.
我們提供一個 array 寫上各種匹配方式, 要有順序之分哦,overlay 會先后判斷可見范圍,找出一個可見度最高的作為展現, 比如 drop down 在屏幕上方,顯示位置是下,在屏幕下方顯示位置是上,這種體驗.
withTransformOriginOn(content element selector string) 主要是給我們做 animation scale 用的,由於 content 出現的位置是不固定的
所以 animation 展示的位置也是不固定的,overlay 會通過我們傳入的 selector 找到 element 然后把 transform origin 設置進去.
withFlexibleDimensions 這個我到現在都沒有搞懂是啥, default 是 true, 但是我發現它的效果怪怪的,所以就不用了. 跳
withViewportMargin 我們不希望我們的 content 和 viewport 黏在一起, 就可以放這個 margin 給它.
紅色區域就是那個 margin
withPush 默認是 true, 有了這個, 用戶不管 scroll 上下左右, 我們的 content 就會一直保持在可見區, 會跟着 scroll 走.
withScrollableContainers(element) 這個是用於當我們有多層 scroll bar 時用到的,默認情況下, overlay 是通過 scrollDispatcher 去監聽 body scroll 的.
但是如果我們的 origin 在一個 div scroll 里, 只監聽 body scroll 是無法做出正確體驗的,所以我們要讓 overlay 知道這個事情.
做法是這樣的, 我們得把我們能 scroll 的 element 都注冊進去 scrollDispatcher (可以自己調用 register 或者用 cdkScrollable 指令)
當 scrollDispatcher 有了所有的 scrollable div, 當我們調用 withScrollableContainers,它會拿我們傳入的 element 去 match (element 的 parent 如果有在 scrollable list 中就去監聽這個 scrollable 的滾動事件)
這樣當 scroll 的時候, 我們的 content 就會正確的被 reposition 了.
withLockedPosition 當我們 scroll 的時候, overlay 會替我們 reposition 但是有時候這種跳來跳去不一定是好的體驗,這個時候我們可以使用 lock, content 顯示時會用最佳位置,然后就一直保持這個位置,不管用戶 resize or scroll.
到這個環境, overlay 算是做出來了. 下一個是做 content 的 animation
通常 overlay append content 我們都希望有同一種 animation 體驗,所以一般上會封裝 animation
它的具體做法是做一個 container 組件, overlay 每次 append 都是這個 container 組件,然后這個組件在 append 我們的動態組件.
const containerInjector = new PortalInjector(this.vcr.injector, new WeakMap()); const containerPortal = new ComponentPortal(ContainerComponent, this.vcr, containerInjector); const container = overlayRef.attach(containerPortal).instance;
overlay 內部有一個 dom portal outlet (這個和我們經常用的 cdk portal outlet 指令不是同一個哦),我們調用 overlay.attach(我們的 portal)
overlay 會調用 DomPortalOutlet.attachComponent.
這里的關鍵是我們傳入的 portal 是否有 viewContainerRef 它會決定之后的 detech change 時機和 injection.
如果有 viewcontainer 那么會把 portal 先創建到 view container 然后通過 outletElement (body 的 div) appendchild (cut and paste) 出去.
如果沒有會直接創建 component 然后放入 appRef.views 里面. 然后依然 append to body
大部分情況下我們 portal 應該要有 view container ref.
下一個動作就是 container append 動態組件了.
<ng-template cdkPortalOutlet></ng-template>
我們可以在 container.html 使用 cdkPortalOutlet
@ViewChild(CdkPortalOutlet, { static: true }) portalOutlet: CdkPortalOutlet;
通過 viewchild + static 獲取到這個指令. (看到 static true 的用途了吧...嘻嘻)
static 的特色是,在 component construtor 運行完后就可以獲取到這個屬性值了, 不需要等到 after view init.
container.animationStateChanged.pipe(filter(e => e.toState === 'enter' && e.phaseName === 'done'), take(1)).subscribe(e => { container.autoFocus(); }); const contentInjector = new PortalInjector(this.vcr.injector, new WeakMap([[MODAL_DATA, 'data']])); const contentPortal = new ComponentPortal(AbcComponent, null, contentInjector); // 這里 view container ref 是 null container.attachComponentPortal(contentPortal);
注意那個 animationStateChanged. overlay dispose 是很突兀的,所以我們幾乎不可能直接調用。
正確的做法是通過控制我們 container 的 animation 來完成關閉, 比如先 fade out container,然后監聽 container fade out done 才調用 overlay dispose.
上面這個例子是做了一個 autofocus, 看的出來 container 內部封裝了 cdk focus trap 功能.
另一個要留意的是, container.attachComponent
剛才我們說 container 內有一個 cdk portal outlet, 拿我們只需要開一個接口接受動態組件,然后就可以 attach 出去了。
cdk portal outlet vs dom portal outlet
cdk portal outlet 處理 view container ref 的方式有點不同, cdk poral outlet 本身有自己的 view container ref (剛才 dom outlet 是用 appRef)
如果 portal 自帶 view container ref, 那么會直接把 portal 插入到其中, 所以內容不會被 append 到 cdk portal outlet 的位置哦. (這有點怪,注釋說了只是邏輯樹會插入到 portal 的 view container, 但是渲染應該是在 portal outlet 的位置才對呀. 但是沒有..)
提了一個 issue 希望能問個明白
https://github.com/angular/components/issues/17650
如果沒有, 就會使用 cdk portal outlet 的 viewcontainer 了. 這通常會是我們想要的結果.
在學習 overlay 和 portal 的時候,一直沒有弄明白 viewContainerRef 在其中扮演的角色
這里說一下來龍去脈
當我們創建一個 overlay 時,同時創建了一個 portal outlet
當我們要 append 內容時,內部其實時調用了 DomPortalOutlet 的 attachComponentPortal 方法
這時候會依據 portal <-- 傳入的component portal,不是 portal outlet 哦,不要搞混了.
時候有 viewContainerRef 決定如何創建 component.
如果有就調用 viewContainerRef create component 方法, 這時會 insert component to container 渲染. 然后再通過 dom 操作 cut and paste 去 portal outlet (body)。
如果沒有的話就直接通過 component factory create component 然后把 view 放入到全局 appRef 里面. 這時候組件並沒有 append to dom 任何地方.
然后 cut and paste to portal outlet.
當 app.tick 時,所有的 appRef.views 就會 detech change.
2 者有什么區別呢 ?
在 portal 的文檔里並沒有解釋太多... 只是說什么邏輯樹和 view 樹的不同而已.
反而是 dialog 的文檔里解釋了
從源碼上看確實如此.
在使用了 viewContainerRef 之后, detech change 的時機是依據 viewContainerRef 的
而放入 appRef 的情況, detech change 的時機是 app.tick 每一次都觸發.
appRef.attachView 將 view 放入了一個 array 中.
在 tick 的時候調用 detech change.
至於 injector 其實蠻困惑的,因為 attachComponentPortal injector 是基於 component portal 的 injector,跟 viewContainerRef 沒有啥關系丫. 那為什么 dialog 文檔說有關系呢
看了源碼就會發現了,dialog 創建 portal 使用的 injector 是 userInjector || rootInjector, 而所謂的 userInjector 就是 viewContainerRef.injector.
這樣就真相大白了咯。