博客v0.8迭代记录
一拖再拖,终于将博客升级到了v0.8.0
,主要涉及到底层框架react-vue
、服务接口vite ssr
、项目管理pnpm monorepo
和部署方式docker compose
等方面的改动,感觉可以记录一下。
react-vue
灵活的写法
在前面这篇博客:实现一个类React的Vue3框架中已经提到了相关的实现思路和方法。这里就大致回顾一下。
初始的想法是想用一种更灵活的方式写Vue3,没有什么比JSX更灵活的了,于是设计了唯一支持的组件结构
function Count({value = 0}: CountProps) {
const data = reactive({
count: value
})
const onClick = () => {
data.count++
}
return () => {
return (<button onClick={onClick}>click {data.count} </button>)
}
}
功能类似于Vue3的组件setup方法,区别在于返回的是一个render
方法,当依赖变化触发视图更新时,会重新执行render方法参与diff算法,最后将变更同步到DOM中。
之前阅读了snabbdom
和infernoJS
的源码
于是react-vue
参考infernoJS
的最长上升子序列算法,实现了一个简易的diff过程。
至于reactive
等数据响应,就直接使用了@vue/reactivity
这个库。
相较于之前写NeZha
亲力亲为的做法,react-vue
有很多地方都是参考vue3源码编写的,如
injectHook
注册生命周期函数watch
监听指定getter的变化queueJob
、flushJobs
等effect.scheduler
实现的批量更新provide
、inject
跨父级组件传递数据
从这个库的名字就可以看出来,这次写的更随意了(不想太折腾了~)。
遇到不太容易实现的功能,就去翻一下Vue的源码,这个过程还是很有收获的,比如对于Vue3的props,为啥不能直接写成解构赋值的形式这个问题,在实现的时候就知道了答案
其本质是在mount
时将props通过shallowReactive(props)
放在了组件实例上,在updateComponent
时,通过新的props更新旧的props,触发shallowReactive
后旧props的setter,如果是解构赋值,render函数就不会触发props的getter,也就不会再触发render函数了
需要支持SSR
由于博客的SEO非常重要,因此还是需要实现一下SSR,对于react-vue
而言,主要需要实现renderHTML
和hydrate
这两个方法
renderHTML
将给定的VNode渲染为HTML字符串,主要运行在server端,作为HTML相应返回
function renderHTML(root: VNode): string {
startHydrate()
// 首先将调用diff获取初始化组件节点,获取完整的节点树
mount(root, {} as Element)
stopHydrate()
return VNode2HTML(root)
}
hydrate
主要用于浏览器接收到server响应后的更新操作,复用已经被渲染的DOM节点
export function hydrate(root: VNode, dom: HTMLElement) {
startHydrate()
mount(root, {} as Element) // 获取完整的虚拟DOM树
stopHydrate()
return walk(root, dom.children[0])
}
两个方法的本质是相似的,都是通过深度优先遍历获得结果,具体的实现这里就不再赘述。
由于不太喜欢将isHydrate
这个状态通过函数参数的方式在mount阶段一直透传下去,我定义了一个getHost
方法,发布不同平台下对应节点操作的方法,对于SSR而言,每个方法都是空的
const dom = {
// ... 真实的DOM操作接口
}
const ssr = {
insert(child: Text | Element, parent: Element, anchor?: Element | null) {
},
remove(child: Element) {
},
createText(content: string) {
return null as unknown as Text
},
createElement(type: string) {
return null as unknown as HTMLElement
},
setAttribute(dom: Element, prop: string, lastValue: any, nextValue: any) {
},
setText(node: Element, text: string) {
}
}
const isBrowser = typeof window !== 'undefined'
export function getHost() {
return (!isBrowser || isHydrateProcessing()) ? ssr : dom
}
typescript类型支持
由于定义的()=>()=>jsx.Element
这种组件类型,并不是@types/react
中定义的合法的组件类型
function Button() {
return () => {
return (<button>click me</button>)
}
}
当时写了一篇博客研究这个问题:自定义React中JSX的Element类型。
最后的解决办法是重写了一个非常简易的JSX类型声明
declare global {
namespace JSX {
type Element = ReactElement | CustomFunctionComponent
interface ElementClass {
(prop: any): any
}
interface IntrinsicElements {
[prop: string]: any
}
}
}
然后修改react/jsx-runtime
{
"compilerOptions": {
"jsx": "react-jsx",
"paths": {
"react/jsx-runtime": [
"./node_modules/@shymean/react-vue-jsx"
]
}
}
}
虽然解决了一片红色错误,但在重构过程中其实还是有一些类型声明的问题,比如函数组件的key需要手动在组件props声明,不然就会提示属性未定义,这个后面再研究一下。
react-vue-router
基本上是迁移之前的NeZha-Router
,貌似没有啥特别的
react-vue-store
我之前一直非常反感Redux
、Vuex
的字符串访问action的方式,这也是我为什么习惯使用Webstrom开发的原因(webstrom支持vuex字符串跳转到源码)。Vue3提供了一个更符合组合API、类型更友好的Pinia库,用起来非常方便。于是我也写了一个极简的react-vue-store
,真的极简,就几行代码
export function defineStore<Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A extends _ActionsTree>(option: DefineStoreOptions<Id, S, G, A>) {
const {state, actions, id, instance} = option
let store
return function useStore(_instance: StoreInstance = instance) {
if (!_instance._s.has(id)) {
const data = reactive(state ? state() : {})
store = Object.assign(data, actions)
_instance._s.set(id, store)
} else {
store = _instance._s.get(id)
}
return store as Store<Id, S, G, A>
}
}
SSR与Web应用最大的区别在于,每个用户的访问状态应该是隔离的,因此需要store是独立的,所以defineStore接受一个instance
的配置项,表示是哪个实例的状态
export interface StoreInstance {
_s: Map<string, Store>
}
export function createStoreInstance(): StoreInstance {
return {
_s: new Map()
}
}
使用起来与Pinia基本一致
import {createStoreInstance, defineStore} from '@shymean/react-vue-store'
const instance = createStoreInstance() // 不同store可以复用同一个instance
export const useMainStore = defineStore({
id: 'test',
instance,
state(): { x: number, y: number } {
return {
x: 1,
y: 2
}
},
getters: {
doubleX(): number {
return 2 * this.x
}
},
actions: {
addX() {
this.x += 1
}
},
})
在组件中使用
function App() {
const store = useMainStore()
const count = ref(0)
const add = () => {
store.addX()
}
return () => {
return (<div>
<button onClick={add}>click {count.value}</button>
<SubApp/>
</div>)
}
}
vite
上一个版本是使用NeZha + Webpack 实现的开发环境和SSR,存在的缺点就是启动慢、热更新慢、打包慢。因此这个版本决定使用Vite。
回想第一次接触vite还是在它刚初始化仓库的时候,一天一个版本,当时还写了一篇博客分析源码:尝鲜Vue3——vite源码分析 ,现在vite都已经快要3.0了,时间过得太快了(当然这篇博客里面分析的源码也已经过时了)
ssr server
vite基础的开发环境基本上没啥好说的了,主要是vite SSR这里,还是遇到了一些问题。主要参考官方文档。
vite SSR 的本质实际上是将vite作为中间件,主要处理文件编译、热更新等逻辑,最后由实际的Node server服务器渲染并返回HTML。
因此整个应用大概是这个样子
const development = process.env.NODE_ENV === 'development'
async function createServer() {
const app = express()
const vite = await createViteServer({
server: {
middlewareMode: true,
hmr: development
},
appType: 'custom'
})
// 使用 vite 的 Connect 实例作为中间件
app.use(vite.middlewares)
const port = 3001
app.listen(port)
console.log('ssr server listen at', port)
app.use(express.static(path.resolve(__dirname, "./dist/client/assets")));
app.use('/*', async (req, res, next) => {
const url = req.originalUrl
if (url === '/favicon.ico') return res.end('')
try {
const filePath = development ? path.resolve(__dirname, 'index.html') : path.resolve(__dirname, 'dist/client/index.html')
let template = fs.readFileSync(filePath, 'utf-8')
let render
if (development) {
// 注入热更新等逻辑
template = await vite.transformIndexHtml(url, template)
const {render: devRender} = await vite.ssrLoadModule('/src/entry-server.tsx')
render = devRender
} else {
const {default: {render: prodRender}} = await import('./dist/server/entry-server.js')
render = prodRender
}
const {html: appHtml, initData, seoData = {}} = await render(url)
const html = template
.replace(`<!--ssr-init-data-->`, initData)
.replace(`<!--ssr-outlet-->`, appHtml)
.replace(`<!--ssr-seo-data-->`, seoData)
res.status(200).set({'Content-Type': 'text/html'}).end(html)
} catch (e) {
vite.ssrFixStacktrace(e)
next(e)
}
})
}
可以很明显地看见其中一些区分运行环境的代码
- 只有开发环境需要热更新
- 生产环境需要获取打包后的相关资源路径,因此在生产环境下读取的是输出后的html文件作为模板
- 生产环境不应该处理文件编译等逻辑,因此使用的入口文件是编译后的
render方法用于处理服务端生成HTML的逻辑,根据当前请求的url,找到对应的路由组件,然后执行对应的数据初始化和renderHTML
逻辑,此外还需要考虑根据初始化数据来执行SEO数据渲染的相关逻辑
function renderTDK(seo: ITDKData) {
return `
<title>${seo.title}</title>
<meta name="description" content="${seo.description}">
<meta name="keywords" content="${seo.keywords}">
`
}
export async function render(url: string) {
const to = getMatchRouteConfig(url, routes)
let location = createLocation(url, (to && to.path) || '')
if (!to) {
return ''
}
// store instance
const instance = createStoreInstance()
const component = to.component as ServerComponent
let seoData: ITDKData | undefined
if (component) {
if (component.asyncData) {
await component.asyncData({instance, location})
}
if (component.asyncSEO) {
seoData = await component.asyncSEO({instance, location})
}
}
const store = useArticleStore(instance)
return {
html: renderHTML(<App url={url} instance={instance} location={location}/>),
initData: JSON.stringify(store),
seoData: renderTDK(seoData)
}
}
在App中,会通过provide
注入当前访问组件的store 实例,在页面组件中,就可以通过inject
获取访问实例,这样就规避了react-vue-store
中不同访问用户状态共享的问题。
打包优化
在处理生产环境的产物时,需要考虑一些性能点
node_modules
里面的文件一般不会经常变化,而业务代码会经常迭代,需要拆包- 博客中使用
highlight.js
渲染markdown中的代码,这个库体积比较大,采用的是第三方CDN(省钱省流量) - 微软都放弃IE了,因此决定这次打包的target使用es module,不再考虑老版本的兼容
对于第1点,通过manualChunks
就可以解决
const buildConfig = {
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (id.includes('node_modules')) {
return 'vendor';
} else {
return 'custom';
}
}
}
},
}
对于第2点,声明external: ['highlight.js']
时,rollup就不会把这个包的内容打包到输出文件,但怎么让highlight.js
引用到正常的window.hljs
呢
可以配置output.globals,'highlight.js': "window.hljs"
,跟webpack的externals
比较相似。
const buildConfig = {
//
rollupOptions: {
external: ['highlight.js'],
output: {
globals: {
'highlight.js': "window.hljs"
},
manualChunks: (id: string) => {
if (id.includes('node_modules')) {
return 'vendor';
} else {
return 'custom';
}
}
}
},
}
看起来能解决问题,打包一下却发现了问题,globals
的配置并没有生效!
排查发现,globals
貌似只能在output.format
为iife
、umd
时才能生效,但是更新format
之后,又不能使用manualChunks
了。
拆包肯定是很重要的,外部第三方CDN也很重要,二者可以兼得吗?
最后用了个比较hack的办法,使用vite的虚拟模块插件
const highLightPlugin = (isSSR) => {
const virtualModuleId = 'virtual:highlight.js'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'highlightPlugin',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
if (!isSSR) {
return `const val = window.hljs; export default val`
} else {
return `import highlight from 'highlight.js'; export default highlight`
}
}
}
}
}
然后将代码里面依赖highlight.js
的地方替换成virtual:highlight.js
,即
// import highlight from 'highlight.js'
import highlight from 'virtual:highlight.js'
完美解决~
最后的es打包结果还是比较满意的,比上一个NeZha的版本体积小了不少。
内存泄漏
由于react-vue中使用了部分全局变量,如
@vue/reactivity
中的targetMap
收集对象每个被依赖属性的effectscheduler
中的queue
和postQueue
,收集渲染中过程中的任务
在服务端渲染HTML如果没有考虑到这些地方的释放,就容易发生内存泄漏
部署之后,通过docker stats
观察了一段时间,发现内存占用从
变成了
这会导致服务运行一段时间后,内存就会被打爆。
短时间内重构react-vue的底层可能不太靠谱,先临时通过pauseTracking
、resetTracking
等方法在SSR渲染阶段关闭依赖跟踪、清空全局任务等定期删除数据。
后面需要重写renderHTML这个方法。
项目结构与部署
可以看做是之前Docker部署monorepo项目这篇文章的实践。
本来计划了很久的使用Docker部署,一直没有行动起来。这次终于一起搞定了。
下次换服务器的时候,就不用再重新折腾一遍MySQL、Node环境了。
小结
感觉拖延症越来越严重了,本来计划两周时间就可以切换到新版本的,结果拖了两三个月~
虽然目前已经上线了,实际上也有一些TODO还没有完成,比如计划使用css module重构整个样式管理,现在也只有推迟到下一个版本了。
另外还有一些BUG、比如SSR内存泄漏、组件key提示错误以及其他一些未发现的问题,后面再慢慢修复吧,就这样。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。